JJServiceManager 1.0.0: Windows 서비스 기동/종료 관리 프로그램

- 초기화면: 서비스 Start/Stop/Status/Log, 체크 항목 일괄 제어, 상태 자동 갱신
- 추가 선택 화면: 전체 서비스 목록, 열 정렬, 검색, 등록된 서비스 표시
- 로그: 화면클리어/로그 클리어/복사, 서비스 로그 파일 실시간 보기(개별 창/탭/좌우 분할)
- 설정: 시작시 기동, 전역 단축키, Esc 동작, Windows 종료 버튼 표시
- 트레이, 관리자 권한 실행, 중복 실행 방지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
main
diadmin 2026-10-27 01:56:10 +00:00
commit 84cafc48a2
No known key found for this signature in database
23 changed files with 3741 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
bin/
obj/
publish/
dist/
.vs/
*.user
*.suo

343
Core/LogPanel.cs Normal file
View File

@ -0,0 +1,343 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace JJServiceManager
{
public enum LogLevel { Info, Success, Warning, Error, Notice }
/// <summary>로그 한 줄</summary>
public class LogLine : ObservableObject
{
private bool _isChecked;
private double _fillerHeight;
public int No { get; init; }
public DateTime Time { get; init; }
public string Text { get; init; } = "";
public LogLevel Level { get; init; }
public bool ShowTime { get; init; }
public bool IsFiller { get; init; }
public bool IsChecked { get => _isChecked; set => Set(ref _isChecked, value); }
public double FillerHeight
{
get => _fillerHeight;
set => Set(ref _fillerHeight, Math.Max(0, value));
}
public string NoText => IsFiller ? "" : No.ToString();
public string Display => IsFiller ? "" : (ShowTime ? $"[{Time:HH:mm:ss}] {Text}" : Text);
public Visibility FillerVisibility => IsFiller ? Visibility.Visible : Visibility.Collapsed;
public Visibility LineVisibility => IsFiller ? Visibility.Collapsed : Visibility.Visible;
public Brush Foreground => Level switch
{
LogLevel.Success => Brushes2.Running,
LogLevel.Warning => Brushes2.Pending,
LogLevel.Error => Brushes2.Stopped,
LogLevel.Notice => LogPanel.NoticeBrush,
_ => LogPanel.NormalBrush
};
/// <summary>복사할 때의 텍스트</summary>
public string CopyText => ShowTime ? $"[{Time:yyyy-MM-dd HH:mm:ss}] {Text}" : Text;
}
/// <summary>앞쪽 여러 줄을 한 번에 지울 수 있는 컬렉션 (변경 알림 1회)</summary>
public sealed class LogCollection : ObservableCollection<LogLine>
{
public void RemoveFirst(int count)
{
if (count <= 0) return;
CheckReentrancy();
var list = (List<LogLine>)Items;
list.RemoveRange(0, Math.Min(count, list.Count));
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Count"));
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new System.Collections.Specialized.NotifyCollectionChangedEventArgs(
System.Collections.Specialized.NotifyCollectionChangedAction.Reset));
}
}
/// <summary>
/// 번호 + 체크박스가 있는 로그 목록.
/// - 화면클리어: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
/// - 로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터 다시 시작
/// - 파일은 절대 지우지 않음
/// </summary>
public class LogPanel : ContentControl
{
public static readonly SolidColorBrush NormalBrush = Brushes2.Make("#212121");
public static readonly SolidColorBrush NoticeBrush = Brushes2.Make("#1565C0");
private const double LineHeight = 22;
private readonly LogCollection _lines = new LogCollection();
private readonly ListBox _list;
private readonly TextBlock _info;
private readonly DispatcherTimer _infoTimer;
private ScrollViewer _scroll;
private LogLine _filler;
private int _fillerNewLines;
private int _nextNo = 1;
private bool _scrollPending;
/// <summary>줄 앞에 시각 표시 여부 (작업 로그 = true, 파일 로그 = false)</summary>
public bool ShowTimestamp { get; set; } = true;
/// <summary>화면에 유지할 최대 줄 수 (오래된 줄부터 화면에서만 빠짐)</summary>
public int MaxLines { get; set; } = 20000;
public int Count => _lines.Count - (_filler != null ? 1 : 0);
public LogPanel()
{
var root = XamlLoader.LoadElement("LogPanel.xaml");
Content = root;
_list = root.Find<ListBox>("lstLog");
_info = root.Find<TextBlock>("txtInfo");
_list.ItemsSource = _lines;
root.Find<Button>("btnSelectAll").Click += (s, e) => SetAllChecked(true);
root.Find<Button>("btnSelectNone").Click += (s, e) => SetAllChecked(false);
root.Find<Button>("btnClearScreen").Click += (s, e) => ClearScreen();
root.Find<Button>("btnCopy").Click += (s, e) => CopyChecked();
root.Find<Button>("btnClearLog").Click += (s, e) => ClearLog();
_list.Loaded += (s, e) => _scroll ??= FindChild<ScrollViewer>(_list);
_list.SizeChanged += (s, e) => AdjustFiller();
_list.PreviewKeyDown += OnListKeyDown;
// 화면클리어용 빈 공간은 선택되지 않게 함
_list.SelectionChanged += (s, e) =>
{
if (_filler != null && e.AddedItems.Contains(_filler)) _list.SelectedItems.Remove(_filler);
};
_infoTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(4) };
_infoTimer.Tick += (s, e) => { _info.Text = ""; _infoTimer.Stop(); };
}
// ───────────────────────── 추가 ─────────────────────────
public void Add(string text, LogLevel level = LogLevel.Info)
=> AddMany(new[] { text }, level);
public void AddMany(IEnumerable<string> texts, LogLevel level = LogLevel.Info)
{
var list = texts as IList<string> ?? texts.ToList();
if (list.Count == 0) return;
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => AddMany(list, level)));
return;
}
bool atBottom = IsAtBottom();
var now = DateTime.Now;
foreach (var t in list)
{
var line = new LogLine
{
No = _nextNo++,
Time = now,
Text = t ?? "",
Level = level,
ShowTime = ShowTimestamp
};
if (_filler != null)
{
_lines.Insert(_lines.Count - 1, line);
_fillerNewLines++;
_filler.FillerHeight -= LineHeight;
if (_filler.FillerHeight <= 0)
{
_lines.Remove(_filler);
_filler = null;
}
}
else
{
_lines.Add(line);
}
}
// 최대 줄 수를 여유분 이상 넘으면 화면에서만 오래된 줄을 한 번에 제거
int slack = Math.Max(500, MaxLines / 10);
if (Count > MaxLines + slack)
{
int remove = Count - MaxLines;
if (_filler != null) remove = Math.Min(remove, _lines.Count - 1);
_lines.RemoveFirst(remove);
atBottom = true;
}
if (atBottom) ScrollToEndDeferred();
}
// ───────────────────────── 버튼 동작 ─────────────────────────
/// <summary>화면클리어: 이전 로그는 위쪽에 그대로 두고, 새 로그가 화면 맨 위부터 보이도록 함</summary>
public void ClearScreen()
{
if (_filler != null)
{
_lines.Remove(_filler);
_filler = null;
}
if (_lines.Count == 0) return;
double vh = ViewportHeight();
if (vh <= 0) return; // 화면이 보이지 않는 상태
_filler = new LogLine { IsFiller = true, FillerHeight = vh };
_fillerNewLines = 0;
_lines.Add(_filler);
ScrollToEndDeferred();
ShowInfo("화면을 비웠습니다. 위로 스크롤하면 이전 로그를 볼 수 있습니다.");
}
/// <summary>로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터</summary>
public void ClearLog()
{
_lines.Clear();
_filler = null;
_fillerNewLines = 0;
_nextNo = 1;
ShowInfo("화면의 로그를 지웠습니다. 번호는 1번부터 시작합니다.");
}
public void SetAllChecked(bool value)
{
foreach (var l in _lines)
if (!l.IsFiller) l.IsChecked = value;
}
public void CopyChecked()
{
var picked = _lines.Where(l => !l.IsFiller && l.IsChecked).ToList();
if (picked.Count == 0)
{
ShowInfo("복사할 줄을 체크하세요.");
return;
}
CopyLines(picked);
}
private void CopyLines(IList<LogLine> lines)
{
var sb = new StringBuilder();
foreach (var l in lines) sb.AppendLine(l.CopyText);
for (int retry = 0; retry < 5; retry++)
{
try
{
Clipboard.SetText(sb.ToString());
ShowInfo($"{lines.Count}줄을 복사했습니다.");
return;
}
catch
{
System.Threading.Thread.Sleep(60);
}
}
ShowInfo("클립보드를 사용할 수 없습니다. 잠시 후 다시 시도하세요.");
}
private void OnListKeyDown(object sender, KeyEventArgs e)
{
// Ctrl+C: 마우스로 선택(파란 표시)한 줄 복사, 없으면 체크한 줄 복사
if (e.Key == Key.C && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
{
var picked = new HashSet<LogLine>(_list.SelectedItems.Cast<LogLine>());
var sel = _lines.Where(l => !l.IsFiller && picked.Contains(l)).ToList();
if (sel.Count > 0) CopyLines(sel); else CopyChecked();
e.Handled = true;
}
// Space: 선택한 줄의 체크 토글
else if (e.Key == Key.Space)
{
var sel = _list.SelectedItems.Cast<LogLine>().Where(l => !l.IsFiller).ToList();
if (sel.Count > 0)
{
bool v = !sel.All(l => l.IsChecked);
foreach (var l in sel) l.IsChecked = v;
e.Handled = true;
}
}
}
// ───────────────────────── 스크롤 ─────────────────────────
private double ViewportHeight()
{
_scroll ??= FindChild<ScrollViewer>(_list);
if (_scroll != null && _scroll.ViewportHeight > 0) return _scroll.ViewportHeight;
return Math.Max(0, _list.ActualHeight - 4);
}
private void AdjustFiller()
{
if (_filler == null) return;
_filler.FillerHeight = ViewportHeight() - _fillerNewLines * LineHeight;
if (_filler.FillerHeight <= 0)
{
_lines.Remove(_filler);
_filler = null;
}
ScrollToEndDeferred();
}
private bool IsAtBottom()
{
_scroll ??= FindChild<ScrollViewer>(_list);
if (_scroll == null) return true;
return _scroll.VerticalOffset >= _scroll.ScrollableHeight - 2;
}
private void ScrollToEndDeferred()
{
if (_scrollPending) return;
_scrollPending = true;
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
_scrollPending = false;
_scroll ??= FindChild<ScrollViewer>(_list);
_scroll?.ScrollToBottom();
}));
}
private void ShowInfo(string msg)
{
_info.Text = msg;
_infoTimer.Stop();
_infoTimer.Start();
}
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
if (parent == null) return null;
int n = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < n; i++)
{
var c = VisualTreeHelper.GetChild(parent, i);
if (c is T t) return t;
var r = FindChild<T>(c);
if (r != null) return r;
}
return null;
}
}
}

413
Core/LogTailer.cs Normal file
View File

@ -0,0 +1,413 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
namespace JJServiceManager
{
/// <summary>
/// 서비스 로그 파일을 실시간으로 읽음 (tail).
/// - 파일 지정: 그 파일을 계속 추적
/// - 폴더 지정: 폴더 안에서 가장 최근에 수정된 파일을 자동으로 추적 (날짜별 로그에 유리)
/// - 읽기 전용으로만 열며, 파일을 수정·삭제하지 않음
/// </summary>
public sealed class LogTailer : IDisposable
{
private const int PollMs = 500;
private const int FolderScanEvery = 4; // 폴더 모드: 2초마다 최신 파일 확인
private static readonly TimeSpan SwitchIdle = TimeSpan.FromSeconds(5); // 지금 파일이 이만큼 멈춰 있어야 새 파일로 전환
private const int InitialBytes = 256 * 1024; // 처음 열 때 끝에서부터 읽을 양
private const int InitialMaxLines = 1000; // 처음 열 때 보여줄 최대 줄 수
private const int MaxReadPerTick = 4 * 1024 * 1024;
/// <summary>파일별 읽기 상태 (폴더 모드에서 파일을 오가도 이어서 읽음)</summary>
private sealed class FileState
{
public string Path;
public long Position;
public int BomLength;
public Encoding Encoding; // null = 아직 판단 전 (영문만 나온 상태)
public Decoder Decoder;
public string Partial = "";
public DateTime PartialSince;
}
private readonly ServiceEntry _entry;
private readonly Action<IList<string>, LogLevel> _output;
private readonly object _sync = new object();
private readonly Dictionary<string, FileState> _states = new Dictionary<string, FileState>(StringComparer.OrdinalIgnoreCase);
private Timer _timer;
private int _busy;
private int _tick;
private volatile bool _disposed;
private FileState _current;
private string _lastProblem;
public event Action<string> FileChanged;
public string CurrentFile => _current?.Path;
public LogTailer(ServiceEntry entry, Action<IList<string>, LogLevel> output)
{
_entry = entry;
_output = output;
}
public void Start()
{
_timer = new Timer(_ => Tick(), null, 0, PollMs);
}
/// <summary>처음부터 다시 읽음 (인코딩·경로 변경 시)</summary>
public void Reload()
{
lock (_sync)
{
_states.Clear();
_current = null;
_lastProblem = null;
}
}
private void Tick()
{
if (_disposed) return;
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
try
{
lock (_sync) Poll();
}
catch (Exception ex)
{
Problem("로그를 읽는 중 오류: " + ex.Message);
}
finally
{
Interlocked.Exchange(ref _busy, 0);
}
}
private void Poll()
{
if (_current == null || (_entry.LogIsFolder && (_tick++ % FolderScanEvery == 0)))
{
var path = ResolvePath();
if (path == null)
{
_current = null;
return;
}
if (_current == null || !string.Equals(path, _current.Path, StringComparison.OrdinalIgnoreCase))
{
SwitchTo(path);
return;
}
}
ReadNew(_current);
}
private void SwitchTo(string path)
{
// 이전 파일에 남은 내용을 마저 읽고 넘어감
if (_current != null && File.Exists(_current.Path)) ReadNew(_current);
_lastProblem = null;
FileChanged?.Invoke(path);
if (_states.TryGetValue(path, out var known))
{
_current = known;
Emit(new[] { $"──────── 파일 전환: {path} ────────" }, LogLevel.Notice);
ReadNew(known);
}
else
{
var st = new FileState { Path = path };
_states[path] = st;
_current = st;
Emit(new[] { $"──────── 파일: {path} ────────" }, LogLevel.Notice);
ReadInitial(st);
}
}
private string ResolvePath()
{
var p = _entry.LogPath;
if (string.IsNullOrWhiteSpace(p))
{
Problem("로그 경로가 지정되지 않았습니다.");
return null;
}
if (_entry.LogIsFolder)
{
if (!Directory.Exists(p))
{
Problem("로그 폴더를 찾을 수 없습니다: " + p);
return null;
}
var newest = new DirectoryInfo(p)
.EnumerateFiles()
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (newest == null)
{
Problem("로그 폴더에 파일이 없습니다: " + p);
return null;
}
// 여러 파일에 동시에 기록되는 폴더에서 파일이 계속 바뀌지 않도록,
// 지금 보는 파일이 잠시 멈춰 있을 때만 새 파일로 넘어감
if (_current != null && !string.Equals(newest.FullName, _current.Path, StringComparison.OrdinalIgnoreCase))
{
var cur = new FileInfo(_current.Path);
if (cur.Exists && DateTime.UtcNow - cur.LastWriteTimeUtc < SwitchIdle)
return _current.Path;
}
return newest.FullName;
}
if (!File.Exists(p))
{
Problem("로그 파일을 찾을 수 없습니다: " + p);
return null;
}
return p;
}
private static FileStream OpenRead(string path)
=> new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
// ───────────────────────── 읽기 ─────────────────────────
private void ReadInitial(FileState st)
{
using var fs = OpenRead(st.Path);
PrepareEncoding(st, fs);
long len = fs.Length;
long start = Math.Max(st.BomLength, len - InitialBytes);
if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++;
bool fromMiddle = start > st.BomLength;
var text = ReadRange(st, fs, start, len);
var lines = SplitLines(text, out st.Partial);
st.PartialSince = DateTime.UtcNow;
// 중간부터 읽었으면 첫 줄은 잘렸을 수 있으므로 버림
if (fromMiddle && lines.Count > 0) lines.RemoveAt(0);
if (lines.Count > InitialMaxLines) lines = lines.GetRange(lines.Count - InitialMaxLines, InitialMaxLines);
Emit(lines, LogLevel.Info);
}
private void ReadNew(FileState st)
{
FileInfo fi;
try { fi = new FileInfo(st.Path); fi.Refresh(); }
catch { return; }
if (!fi.Exists)
{
if (!_entry.LogIsFolder) Problem("로그 파일이 사라졌습니다: " + st.Path);
_states.Remove(st.Path);
if (ReferenceEquals(st, _current)) _current = null;
return;
}
long len = fi.Length;
if (len < st.Position)
{
Emit(new[] { "──────── 파일 크기가 줄어들어 처음부터 다시 읽습니다 ────────" }, LogLevel.Notice);
st.Position = 0;
st.Partial = "";
st.Encoding = null;
st.Decoder = null;
st.BomLength = 0;
}
if (len == st.Position)
{
FlushStalePartial(st);
return;
}
using var fs = OpenRead(st.Path);
if (st.Position == 0) PrepareEncoding(st, fs);
long start = Math.Max(st.Position, st.BomLength);
if (len - start > MaxReadPerTick)
{
Emit(new[] { "──────── 로그가 너무 빠르게 늘어나 일부를 건너뜁니다 ────────" }, LogLevel.Notice);
start = len - MaxReadPerTick;
if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++;
st.Partial = "";
st.Decoder = st.Encoding?.GetDecoder();
}
var text = st.Partial + ReadRange(st, fs, start, len);
var lines = SplitLines(text, out var rest);
if (rest != st.Partial) st.PartialSince = DateTime.UtcNow;
st.Partial = rest;
Emit(lines, LogLevel.Info);
}
/// <summary>[start, end) 구간을 읽어 문자열로. 읽은 만큼 Position 이동</summary>
private string ReadRange(FileState st, FileStream fs, long start, long end)
{
if (end <= start)
{
st.Position = Math.Max(st.Position, start);
return "";
}
fs.Seek(start, SeekOrigin.Begin);
var buf = new byte[end - start];
int read = ReadFully(fs, buf);
st.Position = start + read;
if (read == 0) return "";
// 자동 인코딩: 한글 등 0x80 이상 바이트가 처음 나올 때 판단
if (st.Encoding == null && HasHighByte(buf, read))
{
st.Encoding = LooksLikeUtf8(buf, read) ? new UTF8Encoding(false) : NonUtf8Encoding(buf, read);
st.Decoder = st.Encoding.GetDecoder();
}
if (st.Encoding == null)
return Encoding.ASCII.GetString(buf, 0, read); // 아직 영문만 있음
st.Decoder ??= st.Encoding.GetDecoder();
var chars = new char[st.Decoder.GetCharCount(buf, 0, read)];
int n = st.Decoder.GetChars(buf, 0, read, chars, 0);
return new string(chars, 0, n);
}
/// <summary>BOM 확인 및 인코딩 설정</summary>
private void PrepareEncoding(FileState st, FileStream fs)
{
st.BomLength = 0;
st.Encoding = null;
st.Decoder = null;
var head = new byte[4];
fs.Seek(0, SeekOrigin.Begin);
int n = ReadFully(fs, head);
Encoding bomEnc = null;
if (n >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) { bomEnc = new UTF8Encoding(false); st.BomLength = 3; }
else if (n >= 2 && head[0] == 0xFF && head[1] == 0xFE) { bomEnc = new UnicodeEncoding(false, false); st.BomLength = 2; }
else if (n >= 2 && head[0] == 0xFE && head[1] == 0xFF) { bomEnc = new UnicodeEncoding(true, false); st.BomLength = 2; }
var setting = (_entry.LogEncoding ?? "auto").ToLowerInvariant();
if (bomEnc != null) st.Encoding = bomEnc; // BOM이 있으면 항상 BOM을 따름
else if (setting == "utf-8") st.Encoding = new UTF8Encoding(false);
else if (setting == "cp949") st.Encoding = Encoding.GetEncoding(949);
// 자동 + BOM 없음 → 내용을 보고 판단 (ReadRange)
st.Decoder = st.Encoding?.GetDecoder();
}
/// <summary>줄바꿈 없이 끝난 마지막 줄이 2초 넘게 그대로면 그대로 표시</summary>
private void FlushStalePartial(FileState st)
{
if (st.Partial.Length > 0 && (DateTime.UtcNow - st.PartialSince).TotalSeconds > 2)
{
Emit(new[] { st.Partial }, LogLevel.Info);
st.Partial = "";
}
}
private static List<string> SplitLines(string text, out string rest)
{
var parts = text.Split('\n');
rest = parts[^1];
var list = new List<string>(parts.Length);
for (int i = 0; i < parts.Length - 1; i++)
list.Add(parts[i].TrimEnd('\r', '\0'));
return list;
}
/// <summary>UTF-8이 아니면 CP949(한국어)로 올바르게 읽히는지 먼저 확인, 아니면 시스템 기본 코드페이지</summary>
private static Encoding NonUtf8Encoding(byte[] buf, int count)
{
try
{
var strict = Encoding.GetEncoding(949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
// 끝에서 잘린 2바이트 문자는 판단에서 제외
int n = count;
if (n > 0 && buf[n - 1] >= 0x80) n--;
strict.GetCharCount(buf, 0, n);
return Encoding.GetEncoding(949);
}
catch { }
try { return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.ANSICodePage); }
catch { return Encoding.GetEncoding(949); }
}
private static bool HasHighByte(byte[] b, int count)
{
for (int i = 0; i < count; i++) if (b[i] >= 0x80) return true;
return false;
}
private static bool LooksLikeUtf8(byte[] b, int count)
{
int i = 0;
// 앞부분이 잘린 멀티바이트 문자는 건너뜀
while (i < count && (b[i] & 0xC0) == 0x80) i++;
while (i < count)
{
byte c = b[i];
int need;
if (c < 0x80) { i++; continue; }
else if ((c & 0xE0) == 0xC0) need = 1;
else if ((c & 0xF0) == 0xE0) need = 2;
else if ((c & 0xF8) == 0xF0) need = 3;
else return false;
if (i + need >= count) return true; // 끝에서 잘린 문자는 판단 제외
for (int k = 1; k <= need; k++)
if ((b[i + k] & 0xC0) != 0x80) return false;
i += need + 1;
}
return true;
}
private static bool IsUtf16(Encoding e) => e != null && (e.CodePage == 1200 || e.CodePage == 1201);
private static int ReadFully(Stream s, byte[] buf)
{
int total = 0;
while (total < buf.Length)
{
int n = s.Read(buf, total, buf.Length - total);
if (n <= 0) break;
total += n;
}
return total;
}
private void Problem(string message)
{
if (message == _lastProblem) return; // 같은 메시지는 한 번만
_lastProblem = message;
Emit(new[] { message }, LogLevel.Warning);
}
private void Emit(IList<string> lines, LogLevel level)
{
if (lines.Count == 0 || _disposed) return;
_output(lines, level);
}
public void Dispose()
{
_disposed = true;
_timer?.Dispose();
_timer = null;
}
}
}

496
Core/LogWindows.cs Normal file
View File

@ -0,0 +1,496 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace JJServiceManager
{
/// <summary>서비스 한 개의 로그 파일 보기 (상단 정보줄 + 로그 목록)</summary>
public sealed class LogTailView : DockPanel, IDisposable
{
private readonly LogTailer _tailer;
private readonly TextBlock _path;
private readonly LogPanel _panel;
public ServiceEntry Entry { get; }
/// <summary>인코딩을 바꾸면 설정 저장용으로 호출됨</summary>
public event Action EntryChanged;
public LogTailView(ServiceEntry entry)
{
Entry = entry;
LastChildFill = true;
// ── 상단 정보줄
var bar = new Border
{
Background = Brushes2.Make("#F1F8FB"),
BorderBrush = Brushes2.Make("#B0BEC5"),
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 0, 4)
};
SetDock(bar, Dock.Top);
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var title = new TextBlock
{
Text = entry.DisplayName,
FontWeight = FontWeights.Bold,
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 12, 0),
Foreground = Brushes2.Make("#155E75")
};
Grid.SetColumn(title, 0);
_path = new TextBlock
{
Text = (entry.LogIsFolder ? "폴더: " : "파일: ") + entry.LogPath,
Foreground = Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis
};
Grid.SetColumn(_path, 1);
var encLabel = new TextBlock { Text = "인코딩", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(10, 0, 4, 0) };
Grid.SetColumn(encLabel, 2);
var enc = new ComboBox { Width = 90, VerticalAlignment = VerticalAlignment.Center };
enc.Items.Add(new ComboBoxItem { Content = "자동", Tag = "auto" });
enc.Items.Add(new ComboBoxItem { Content = "UTF-8", Tag = "utf-8" });
enc.Items.Add(new ComboBoxItem { Content = "CP949", Tag = "cp949" });
enc.SelectedIndex = (entry.LogEncoding ?? "auto") switch { "utf-8" => 1, "cp949" => 2, _ => 0 };
Grid.SetColumn(enc, 3);
var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) };
Grid.SetColumn(open, 4);
open.Click += (s, e) => OpenInExplorer();
grid.Children.Add(title);
grid.Children.Add(_path);
grid.Children.Add(encLabel);
grid.Children.Add(enc);
grid.Children.Add(open);
bar.Child = grid;
Children.Add(bar);
// ── 로그 목록
_panel = new LogPanel { ShowTimestamp = false, MaxLines = 50000 };
Children.Add(_panel);
_tailer = new LogTailer(entry, (lines, level) => _panel.AddMany(lines, level));
_tailer.FileChanged += f => Dispatcher.BeginInvoke(new Action(() =>
{
_path.Text = entry.LogIsFolder ? $"폴더: {entry.LogPath} → {Path.GetFileName(f)}" : "파일: " + f;
_path.ToolTip = f;
}));
enc.SelectionChanged += (s, e) =>
{
if (enc.SelectedItem is ComboBoxItem item)
{
entry.LogEncoding = (string)item.Tag;
EntryChanged?.Invoke();
_panel.Add("──────── 인코딩 변경: " + item.Content + " ────────", LogLevel.Notice);
_tailer.Reload();
}
};
_tailer.Start();
}
/// <summary>로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음</summary>
public void PathChanged()
{
_path.Text = (Entry.LogIsFolder ? "폴더: " : "파일: ") + Entry.LogPath;
_path.ToolTip = null;
_panel.Add("──────── 로그 경로 변경: " + Entry.LogPath + " ────────", LogLevel.Notice);
_tailer.Reload();
}
private void OpenInExplorer()
{
try
{
var f = _tailer.CurrentFile;
if (!string.IsNullOrEmpty(f) && File.Exists(f))
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{f}\"") { UseShellExecute = true });
else if (Directory.Exists(Entry.LogPath))
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Entry.LogPath}\"") { UseShellExecute = true });
else
{
var dir = Path.GetDirectoryName(Entry.LogPath);
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true });
}
}
catch { }
}
public void Dispose() => _tailer.Dispose();
}
/// <summary>
/// 로그창 띄우기:
/// 1. 개별 창 2. 하나의 창에 탭 3. 창을 좌우로 나누어 비교
/// </summary>
public sealed class LogWindowManager
{
private readonly Func<AppSettings> _settings;
private readonly Action _save;
// 개별 창
private readonly Dictionary<string, Window> _individual = new Dictionary<string, Window>(StringComparer.OrdinalIgnoreCase);
// 탭
private Window _tabWindow;
private TabControl _tabs;
// 좌우 분할
private Window _splitWindow;
private SplitPane _left, _right;
public LogWindowManager(Func<AppSettings> settings, Action save)
{
_settings = settings;
_save = save;
}
public void Open(ServiceEntry entry)
{
switch (_settings().LogWindowMode)
{
case LogWindowMode.Tabs: OpenTab(entry); break;
case LogWindowMode.Split: OpenSplit(entry); break;
default: OpenIndividual(entry); break;
}
}
/// <summary>서비스 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신)</summary>
public void ServicesChanged()
{
_left?.RefreshChoices(LogServices());
_right?.RefreshChoices(LogServices());
}
/// <summary>서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 서비스의 로그창을 닫음</summary>
public void EntryPathChanged(ServiceEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.LogPath))
{
Close(entry.ServiceName);
ServicesChanged();
return;
}
foreach (var v in OpenViews(entry.ServiceName)) v.PathChanged();
ServicesChanged();
}
/// <summary>서비스가 목록에서 빠졌을 때 그 서비스의 로그창을 닫음</summary>
public void Close(string serviceName)
{
if (_individual.TryGetValue(serviceName, out var w)) w.Close();
if (_tabs != null)
{
foreach (var t in _tabs.Items.Cast<TabItem>().ToList())
{
if (t.Content is LogTailView v && Same(v.Entry.ServiceName, serviceName))
{
v.Dispose();
_tabs.Items.Remove(t);
}
}
if (_tabs.Items.Count == 0) _tabWindow?.Close();
}
if (_left != null && _left.ShowsName(serviceName)) _left.Clear();
if (_right != null && _right.ShowsName(serviceName)) _right.Clear();
}
private IEnumerable<LogTailView> OpenViews(string serviceName)
{
if (_individual.TryGetValue(serviceName, out var w) && w.Content is Border b && b.Child is LogTailView iv)
yield return iv;
if (_tabs != null)
foreach (TabItem t in _tabs.Items)
if (t.Content is LogTailView tv && Same(tv.Entry.ServiceName, serviceName)) yield return tv;
if (_left?.View != null && _left.ShowsName(serviceName)) yield return _left.View;
if (_right?.View != null && _right.ShowsName(serviceName)) yield return _right.View;
}
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
public void CloseAll()
{
foreach (var w in _individual.Values.ToList()) w.Close();
_tabWindow?.Close();
_splitWindow?.Close();
}
private List<ServiceEntry> LogServices()
=> _settings().Services.Where(s => !string.IsNullOrWhiteSpace(s.LogPath)).ToList();
private LogTailView NewView(ServiceEntry entry)
{
var v = new LogTailView(entry);
v.EntryChanged += _save;
return v;
}
private static Window NewWindow(string title, object content, double w = 960, double h = 620)
{
var win = new Window
{
Title = title,
Content = content,
Width = w,
Height = h,
FontFamily = new FontFamily("Malgun Gothic"),
FontSize = 13,
Background = Brushes2.Make("#FAFAFA"),
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
AppIcon.Apply(win);
return win;
}
private static void Activate(Window w)
{
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
w.Show();
w.Activate();
}
// ───────────────────────── 1. 개별 창 ─────────────────────────
private void OpenIndividual(ServiceEntry entry)
{
if (_individual.TryGetValue(entry.ServiceName, out var existing))
{
Activate(existing);
return;
}
var view = NewView(entry);
var win = NewWindow($"로그 - {entry.DisplayName}", new Border { Padding = new Thickness(6), Child = view });
win.Closed += (s, e) =>
{
view.Dispose();
_individual.Remove(entry.ServiceName);
};
_individual[entry.ServiceName] = win;
// 여러 창이 겹치지 않도록 조금씩 비켜서 띄움
int n = _individual.Count - 1;
win.WindowStartupLocation = WindowStartupLocation.Manual;
win.Left = Math.Max(0, (SystemParameters.WorkArea.Width - win.Width) / 2 + n * 28 % 200);
win.Top = Math.Max(0, (SystemParameters.WorkArea.Height - win.Height) / 2 + n * 28 % 200);
Activate(win);
}
// ───────────────────────── 2. 탭 ─────────────────────────
private void OpenTab(ServiceEntry entry)
{
if (_tabWindow == null)
{
_tabs = new TabControl { Margin = new Thickness(6) };
_tabWindow = NewWindow("로그 (탭)", _tabs);
_tabWindow.Closed += (s, e) =>
{
foreach (TabItem t in _tabs.Items)
(t.Content as LogTailView)?.Dispose();
_tabs = null;
_tabWindow = null;
};
}
foreach (TabItem t in _tabs.Items)
{
if (t.Content is LogTailView v && string.Equals(v.Entry.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase))
{
_tabs.SelectedItem = t;
Activate(_tabWindow);
return;
}
}
var view = NewView(entry);
var tab = new TabItem { Content = view };
var header = new StackPanel { Orientation = Orientation.Horizontal };
header.Children.Add(new TextBlock { Text = entry.DisplayName, VerticalAlignment = VerticalAlignment.Center });
var close = new Button
{
Content = "✕",
MinWidth = 0,
MinHeight = 0,
Padding = new Thickness(4, 0, 4, 0),
Margin = new Thickness(8, 0, 0, 0),
FontSize = 10,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
ToolTip = "탭 닫기"
};
close.Click += (s, e) =>
{
view.Dispose();
_tabs.Items.Remove(tab);
if (_tabs.Items.Count == 0) _tabWindow.Close();
};
header.Children.Add(close);
tab.Header = header;
_tabs.Items.Add(tab);
_tabs.SelectedItem = tab;
Activate(_tabWindow);
}
// ───────────────────────── 3. 좌우 분할 ─────────────────────────
private void OpenSplit(ServiceEntry entry)
{
if (_splitWindow == null)
{
var grid = new Grid { Margin = new Thickness(6) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(6) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
_left = new SplitPane(NewView, "왼쪽");
_right = new SplitPane(NewView, "오른쪽");
_left.RefreshChoices(LogServices());
_right.RefreshChoices(LogServices());
var splitter = new GridSplitter
{
Width = 6,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = Brushes2.Make("#CFD8DC"),
ResizeBehavior = GridResizeBehavior.PreviousAndNext
};
Grid.SetColumn(_left, 0);
Grid.SetColumn(splitter, 1);
Grid.SetColumn(_right, 2);
grid.Children.Add(_left);
grid.Children.Add(splitter);
grid.Children.Add(_right);
_splitWindow = NewWindow("로그 비교 (좌우)", grid, 1400, 700);
_splitWindow.Closed += (s, e) =>
{
_left.Dispose();
_right.Dispose();
_left = _right = null;
_splitWindow = null;
};
}
if (!_left.Shows(entry) && !_right.Shows(entry))
{
if (_left.IsEmpty) _left.Show(entry);
else if (_right.IsEmpty) _right.Show(entry);
else _right.Show(entry);
}
Activate(_splitWindow);
}
/// <summary>좌우 분할 창의 한쪽</summary>
private sealed class SplitPane : DockPanel, IDisposable
{
private readonly Func<ServiceEntry, LogTailView> _factory;
private readonly ComboBox _combo;
private readonly ContentControl _host;
private LogTailView _view;
private bool _updating;
public bool IsEmpty => _view == null;
public LogTailView View => _view;
private readonly UIElement _placeholder;
public SplitPane(Func<ServiceEntry, LogTailView> factory, string side)
{
_factory = factory;
var top = new DockPanel { Margin = new Thickness(0, 0, 0, 4) };
SetDock(top, Dock.Top);
var lbl = new TextBlock { Text = side + " 서비스:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) };
SetDock(lbl, Dock.Left);
_combo = new ComboBox { DisplayMemberPath = nameof(ServiceEntry.DisplayName), MinWidth = 200 };
top.Children.Add(lbl);
top.Children.Add(_combo);
Children.Add(top);
_placeholder = new TextBlock
{
Text = "위에서 서비스를 선택하거나, 초기화면에서 Log 버튼을 누르세요.",
Foreground = Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
_host = new ContentControl { Content = _placeholder };
Children.Add(_host);
_combo.SelectionChanged += (s, e) =>
{
if (_updating) return;
if (_combo.SelectedItem is ServiceEntry se) Show(se);
};
}
public bool Shows(ServiceEntry e) => ShowsName(e.ServiceName);
public bool ShowsName(string serviceName)
=> _view != null && string.Equals(_view.Entry.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase);
public void Clear()
{
_view?.Dispose();
_view = null;
_host.Content = _placeholder;
_updating = true;
_combo.SelectedItem = null;
_updating = false;
}
public void Show(ServiceEntry entry)
{
_view?.Dispose();
_view = _factory(entry);
_host.Content = _view;
SelectInCombo(entry);
}
public void RefreshChoices(List<ServiceEntry> services)
{
_updating = true;
_combo.ItemsSource = services;
_updating = false;
if (_view != null) SelectInCombo(_view.Entry);
}
private void SelectInCombo(ServiceEntry entry)
{
_updating = true;
_combo.SelectedItem = (_combo.ItemsSource as IEnumerable<ServiceEntry>)?
.FirstOrDefault(x => string.Equals(x.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase));
_updating = false;
}
public void Dispose() => _view?.Dispose();
}
}
}

173
Core/Models.cs Normal file
View File

@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JJServiceManager
{
/// <summary>바인딩용 기본 클래스</summary>
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected bool Set<T>(ref T field, T value, [CallerMemberName] string name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>로그창 띄우기 방식</summary>
public enum LogWindowMode
{
Individual = 0, // 개별 창
Tabs = 1, // 하나의 창에 탭
Split = 2 // 창을 좌우로 나누어 비교
}
/// <summary>Esc 키 동작</summary>
public enum EscAction
{
Tray = 0, // 트레이로 (백그라운드)
Exit = 1 // 완전 종료
}
/// <summary>등록된 서비스 한 건</summary>
public class ServiceEntry
{
public string ServiceName { get; set; } = "";
public string DisplayName { get; set; } = "";
/// <summary>로그 파일 경로 또는 로그 폴더 경로 (없으면 빈 값)</summary>
public string LogPath { get; set; } = "";
/// <summary>true면 LogPath는 폴더이며, 그 안의 가장 최근 수정 파일을 표시</summary>
public bool LogIsFolder { get; set; }
/// <summary>로그 인코딩: auto / utf-8 / cp949</summary>
public string LogEncoding { get; set; } = "auto";
}
/// <summary>프로그램 설정 (settings.json)</summary>
public class AppSettings
{
public List<ServiceEntry> Services { get; set; } = new List<ServiceEntry>();
/// <summary>컴퓨터 시작 시 기동</summary>
public bool RunAtStartup { get; set; }
/// <summary>단축키 사용 여부</summary>
public bool HotkeyEnabled { get; set; } = true;
/// <summary>단축키 보조키 (System.Windows.Input.ModifierKeys 값)</summary>
public int HotkeyModifiers { get; set; } = 2 | 4; // Ctrl + Shift
/// <summary>단축키 키 (System.Windows.Input.Key 값)</summary>
public int HotkeyKey { get; set; } = 44; // Key.A
/// <summary>서비스 시작 시 로그보기 = 작업 로그를 화면 하단에 표시할지 여부</summary>
public bool ShowOperationLog { get; set; } = true;
/// <summary>로그창 띄우기 방식</summary>
public LogWindowMode LogWindowMode { get; set; } = LogWindowMode.Individual;
/// <summary>Esc 키 동작</summary>
public EscAction EscAction { get; set; } = EscAction.Tray;
/// <summary>Windows 강제종료 / 재시작 버튼 표시</summary>
public bool ShowShutdownButtons { get; set; } = true;
/// <summary>창 크기·위치</summary>
public double WindowLeft { get; set; } = double.NaN;
public double WindowTop { get; set; } = double.NaN;
public double WindowWidth { get; set; } = 1000;
public double WindowHeight { get; set; } = 720;
}
/// <summary>설정 파일 저장/불러오기</summary>
public static class SettingsStore
{
public static string DataFolder { get; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager");
public static string SettingsPath => Path.Combine(DataFolder, "settings.json");
private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
{
WriteIndented = true,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
Converters = { new JsonStringEnumConverter() }
};
public static AppSettings Load()
{
try
{
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
var s = JsonSerializer.Deserialize<AppSettings>(json, Options);
if (s != null)
{
s.Services ??= new List<ServiceEntry>();
return s;
}
}
}
catch
{
// 설정 파일이 손상된 경우 기본값 사용 (원본 파일은 지우지 않음)
}
return new AppSettings();
}
public static void Save(AppSettings settings)
{
try
{
Directory.CreateDirectory(DataFolder);
var tmp = SettingsPath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(settings, Options));
File.Copy(tmp, SettingsPath, true);
File.Delete(tmp);
}
catch
{
// 저장 실패는 무시 (다음 저장 때 다시 시도)
}
}
}
/// <summary>작업 로그를 날짜별 파일에 추가 기록 (삭제 기능 없음)</summary>
public static class OperationLogFile
{
private static readonly object Sync = new object();
public static string Folder => Path.Combine(SettingsStore.DataFolder, "logs");
public static void Append(DateTime time, string text)
{
try
{
lock (Sync)
{
Directory.CreateDirectory(Folder);
var path = Path.Combine(Folder, time.ToString("yyyy-MM-dd") + ".log");
File.AppendAllText(path, $"[{time:yyyy-MM-dd HH:mm:ss}] {text}{Environment.NewLine}");
}
}
catch
{
// 기록 실패는 무시
}
}
}
}

271
Core/Services.cs Normal file
View File

@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.ServiceProcess;
using System.Text;
using Microsoft.Win32;
namespace JJServiceManager
{
/// <summary>Windows 서비스 조회/제어</summary>
public static class ServiceOps
{
public static string StatusText(ServiceControllerStatus? s) => s switch
{
ServiceControllerStatus.Running => "실행 중",
ServiceControllerStatus.Stopped => "중지됨",
ServiceControllerStatus.StartPending => "시작 중",
ServiceControllerStatus.StopPending => "중지 중",
ServiceControllerStatus.Paused => "일시 중지됨",
ServiceControllerStatus.PausePending => "일시 중지 중",
ServiceControllerStatus.ContinuePending => "계속 중",
null => "서비스 없음",
_ => s.ToString()
};
public static string StartTypeText(ServiceStartMode? m, bool delayed = false) => m switch
{
ServiceStartMode.Automatic => delayed ? "자동(지연된 시작)" : "자동",
ServiceStartMode.Manual => "수동",
ServiceStartMode.Disabled => "사용 안 함",
ServiceStartMode.Boot => "부팅",
ServiceStartMode.System => "시스템",
null => "",
_ => m.ToString()
};
/// <summary>상태 조회. 서비스가 없으면 null</summary>
public static ServiceControllerStatus? GetStatus(string serviceName)
{
try
{
using var sc = new ServiceController(serviceName);
return sc.Status;
}
catch
{
return null;
}
}
/// <summary>서비스 시작 후 실행 상태가 될 때까지 대기</summary>
public static void Start(string serviceName, TimeSpan timeout)
{
using var sc = new ServiceController(serviceName);
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Running) throw new InfoException("이미 실행 중입니다.");
if (sc.Status == ServiceControllerStatus.Paused) sc.Continue();
else if (sc.Status != ServiceControllerStatus.StartPending) sc.Start();
sc.WaitForStatus(ServiceControllerStatus.Running, timeout);
}
/// <summary>서비스 중지 후 중지 상태가 될 때까지 대기</summary>
public static void Stop(string serviceName, TimeSpan timeout)
{
using var sc = new ServiceController(serviceName);
sc.Refresh();
if (sc.Status == ServiceControllerStatus.Stopped) throw new InfoException("이미 중지되어 있습니다.");
if (!sc.CanStop && sc.Status == ServiceControllerStatus.Running)
throw new InvalidOperationException("이 서비스는 중지를 허용하지 않습니다.");
if (sc.Status != ServiceControllerStatus.StopPending) sc.Stop();
sc.WaitForStatus(ServiceControllerStatus.Stopped, timeout);
}
/// <summary>예외를 사람이 읽기 쉬운 메시지로</summary>
public static string Describe(Exception ex)
{
if (ex is System.ServiceProcess.TimeoutException) return "제한 시간 안에 상태가 바뀌지 않았습니다.";
var inner = ex;
while (inner.InnerException != null) inner = inner.InnerException;
if (inner is Win32Exception w && !ReferenceEquals(inner, ex))
return $"{ex.Message} ({w.Message})";
return ex.Message;
}
/// <summary>Status 버튼용 상세 정보</summary>
public static List<string> GetDetails(string serviceName)
{
var lines = new List<string>();
using var sc = new ServiceController(serviceName);
sc.Refresh();
var reg = ReadRegistry(serviceName);
lines.Add($"표시 이름 : {sc.DisplayName}");
lines.Add($"서비스명 : {sc.ServiceName}");
lines.Add($"상태 : {StatusText(sc.Status)}");
ServiceStartMode? mode = null;
try { mode = sc.StartType; } catch { }
lines.Add($"시작 유형 : {StartTypeText(mode, reg.DelayedStart)}");
int pid = TryGetProcessId(sc);
if (pid > 0)
{
string pname = "";
try
{
using var p = Process.GetProcessById(pid);
pname = p.ProcessName;
var started = p.StartTime;
lines.Add($"프로세스 : {pname} (PID {pid}), 시작 {started:yyyy-MM-dd HH:mm:ss}");
}
catch
{
lines.Add($"프로세스 : PID {pid}");
}
}
if (!string.IsNullOrEmpty(reg.ImagePath)) lines.Add($"실행 파일 : {reg.ImagePath}");
if (!string.IsNullOrEmpty(reg.Account)) lines.Add($"로그온 계정 : {reg.Account}");
try
{
var deps = sc.ServicesDependedOn;
if (deps.Length > 0)
{
var names = new List<string>();
foreach (var d in deps) { names.Add(d.ServiceName); d.Dispose(); }
lines.Add($"필요한 서비스 : {string.Join(", ", names)}");
}
}
catch { }
if (!string.IsNullOrEmpty(reg.Description)) lines.Add($"설명 : {reg.Description}");
return lines;
}
// ───────────────────────── 레지스트리 ─────────────────────────
public class RegInfo
{
public string Description = "";
public string ImagePath = "";
public string Account = "";
public bool DelayedStart;
}
public static RegInfo ReadRegistry(string serviceName)
{
var info = new RegInfo();
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Services\" + serviceName);
if (key == null) return info;
info.Description = ResolveIndirect(key.GetValue("Description") as string);
info.ImagePath = key.GetValue("ImagePath") as string ?? "";
info.Account = key.GetValue("ObjectName") as string ?? "";
info.DelayedStart = (key.GetValue("DelayedAutostart") is int d) && d == 1;
}
catch { }
return info;
}
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
private static extern int SHLoadIndirectString(string pszSource, StringBuilder pszOutBuf, int cchOutBuf, IntPtr ppvReserved);
/// <summary>"@%SystemRoot%\...dll,-123" 같은 간접 문자열을 실제 문구로 변환</summary>
private static string ResolveIndirect(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (!value.StartsWith("@")) return value;
try
{
var sb = new StringBuilder(2048);
if (SHLoadIndirectString(value, sb, sb.Capacity, IntPtr.Zero) == 0) return sb.ToString();
}
catch { }
return "";
}
// ───────────────────────── PID 조회 ─────────────────────────
[StructLayout(LayoutKind.Sequential)]
private struct SERVICE_STATUS_PROCESS
{
public int dwServiceType;
public int dwCurrentState;
public int dwControlsAccepted;
public int dwWin32ExitCode;
public int dwServiceSpecificExitCode;
public int dwCheckPoint;
public int dwWaitHint;
public int dwProcessId;
public int dwServiceFlags;
}
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool QueryServiceStatusEx(SafeHandle hService, int infoLevel, ref SERVICE_STATUS_PROCESS buffer, int bufSize, out int bytesNeeded);
private static int TryGetProcessId(ServiceController sc)
{
try
{
var ssp = new SERVICE_STATUS_PROCESS();
if (QueryServiceStatusEx(sc.ServiceHandle, 0, ref ssp, Marshal.SizeOf<SERVICE_STATUS_PROCESS>(), out _))
return ssp.dwProcessId;
}
catch { }
return 0;
}
}
/// <summary>오류가 아닌 안내성 메시지 (이미 실행 중 등)</summary>
public class InfoException : Exception
{
public InfoException(string message) : base(message) { }
}
/// <summary>서비스 선택 화면의 한 줄</summary>
public class ServiceInfo : ObservableObject
{
private bool _isSelected;
public string ServiceName { get; init; } = "";
public string DisplayName { get; init; } = "";
public string Description { get; init; } = "";
public string StatusText { get; init; } = "";
public string StartTypeText { get; init; } = "";
public bool IsRegistered { get; init; }
public bool IsSelected { get => _isSelected; set => Set(ref _isSelected, value); }
public string Tooltip => $"서비스명: {ServiceName}" + (IsRegistered ? " (이미 등록됨)" : "");
public static List<ServiceInfo> LoadAll(ISet<string> registered)
{
var result = new List<ServiceInfo>();
ServiceController[] all;
try { all = ServiceController.GetServices(); }
catch { return result; }
foreach (var sc in all)
{
try
{
var reg = ServiceOps.ReadRegistry(sc.ServiceName);
ServiceStartMode? mode = null;
try { mode = sc.StartType; } catch { }
ServiceControllerStatus? st = null;
try { st = sc.Status; } catch { }
bool isReg = registered.Contains(sc.ServiceName);
result.Add(new ServiceInfo
{
ServiceName = sc.ServiceName,
DisplayName = sc.DisplayName,
Description = reg.Description,
StatusText = ServiceOps.StatusText(st),
StartTypeText = ServiceOps.StartTypeText(mode, reg.DelayedStart),
IsRegistered = isReg,
IsSelected = isReg
});
}
catch { }
finally { sc.Dispose(); }
}
return result;
}
}
}

263
Core/SystemIntegration.cs Normal file
View File

@ -0,0 +1,263 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Principal;
using System.Text;
using System.Windows.Input;
using System.Windows.Interop;
namespace JJServiceManager
{
/// <summary>
/// 컴퓨터 시작 시 기동.
/// 관리자 권한 프로그램은 레지스트리 Run 키로는 자동 실행되지 않으므로
/// 작업 스케줄러(로그온 시, 가장 높은 권한)를 사용함.
/// </summary>
public static class StartupManager
{
private const string TaskName = "JJServiceManager";
public static bool IsEnabled()
{
var r = RunSchtasks($"/query /tn \"{TaskName}\"");
return r.ExitCode == 0;
}
public static (bool ok, string message) Enable()
{
var exe = Environment.ProcessPath ?? "";
var user = WindowsIdentity.GetCurrent().Name;
var xml =
$@"<?xml version=""1.0"" encoding=""UTF-16""?>
<Task version=""1.2"" xmlns=""http://schemas.microsoft.com/windows/2004/02/mit/task"">
<RegistrationInfo>
<Description>JJServiceManager 자동 실행</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{SecurityElement.Escape(user)}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id=""Author"">
<UserId>{SecurityElement.Escape(user)}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context=""Author"">
<Exec>
<Command>{SecurityElement.Escape(exe)}</Command>
<Arguments>--tray</Arguments>
<WorkingDirectory>{SecurityElement.Escape(Path.GetDirectoryName(exe) ?? "")}</WorkingDirectory>
</Exec>
</Actions>
</Task>";
var tmp = Path.Combine(Path.GetTempPath(), "JJServiceManager_task.xml");
try
{
File.WriteAllText(tmp, xml, Encoding.Unicode);
var r = RunSchtasks($"/create /tn \"{TaskName}\" /xml \"{tmp}\" /f");
return (r.ExitCode == 0, r.Output);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
try { File.Delete(tmp); } catch { }
}
}
public static (bool ok, string message) Disable()
{
if (!IsEnabled()) return (true, "");
var r = RunSchtasks($"/delete /tn \"{TaskName}\" /f");
return (r.ExitCode == 0, r.Output);
}
/// <summary>명령 프롬프트 출력 인코딩 (한국어 Windows = CP949)</summary>
private static Encoding OemEncoding()
{
try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
catch { return Encoding.UTF8; }
}
private static (int ExitCode, string Output) RunSchtasks(string args)
{
try
{
var psi = new ProcessStartInfo("schtasks.exe", args)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = OemEncoding(),
StandardErrorEncoding = OemEncoding()
};
using var p = Process.Start(psi);
var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
p.WaitForExit(15000);
return (p.ExitCode, output.Trim());
}
catch (Exception ex)
{
return (-1, ex.Message);
}
}
}
/// <summary>전역 단축키 (어느 프로그램을 쓰고 있어도 동작)</summary>
public sealed class HotkeyManager : IDisposable
{
private const int WM_HOTKEY = 0x0312;
private const int HotkeyId = 0x4A4A; // "JJ"
private const uint MOD_NOREPEAT = 0x4000;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private readonly HwndSource _source;
private bool _registered;
public event Action Pressed;
public HotkeyManager(IntPtr hwnd)
{
_source = HwndSource.FromHwnd(hwnd);
_source?.AddHook(WndProc);
}
/// <summary>단축키 등록. 다른 프로그램이 사용 중이면 false</summary>
/// <summary>마지막으로 등록에 성공한 단축키</summary>
public (ModifierKeys mods, Key key)? Active { get; private set; }
/// <summary>
/// 단축키 등록. 다른 프로그램이 사용 중이면 false를 돌려주고,
/// 이전에 쓰던 단축키가 있으면 그것을 다시 등록해 유지함
/// </summary>
public bool Register(ModifierKeys modifiers, Key key)
{
var previous = Active;
Unregister();
if (_source == null || key == Key.None) return false;
if (TryRegister(modifiers, key))
{
Active = (modifiers, key);
return true;
}
if (previous.HasValue && TryRegister(previous.Value.mods, previous.Value.key))
Active = previous;
else
Active = null;
return false;
}
private bool TryRegister(ModifierKeys modifiers, Key key)
{
// ModifierKeys 값(Alt=1, Ctrl=2, Shift=4, Win=8)은 RegisterHotKey의 MOD_* 값과 같음
uint mods = (uint)modifiers | MOD_NOREPEAT;
uint vk = (uint)KeyInterop.VirtualKeyFromKey(key);
_registered = RegisterHotKey(_source.Handle, HotkeyId, mods, vk);
return _registered;
}
public void Unregister()
{
if (_registered && _source != null)
{
UnregisterHotKey(_source.Handle, HotkeyId);
_registered = false;
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_HOTKEY && wParam.ToInt32() == HotkeyId)
{
Pressed?.Invoke();
handled = true;
}
return IntPtr.Zero;
}
public void Dispose()
{
Unregister();
_source?.RemoveHook(WndProc);
}
/// <summary>"Ctrl + Shift + A" 형태 문자열</summary>
public static string Format(ModifierKeys mods, Key key)
{
var sb = new StringBuilder();
if (mods.HasFlag(ModifierKeys.Control)) sb.Append("Ctrl + ");
if (mods.HasFlag(ModifierKeys.Alt)) sb.Append("Alt + ");
if (mods.HasFlag(ModifierKeys.Shift)) sb.Append("Shift + ");
if (mods.HasFlag(ModifierKeys.Windows)) sb.Append("Win + ");
sb.Append(KeyName(key));
return sb.ToString();
}
public static string KeyName(Key key)
{
if (key >= Key.D0 && key <= Key.D9) return ((int)(key - Key.D0)).ToString();
if (key >= Key.NumPad0 && key <= Key.NumPad9) return "Num " + (int)(key - Key.NumPad0);
return key switch
{
Key.None => "",
Key.OemPlus => "=",
Key.OemMinus => "-",
Key.OemComma => ",",
Key.OemPeriod => ".",
Key.Oem1 => ";",
Key.Oem2 => "/",
Key.Oem3 => "`",
Key.Oem4 => "[",
Key.Oem5 => "\\",
Key.Oem6 => "]",
Key.Oem7 => "'",
_ => key.ToString()
};
}
}
/// <summary>Windows 강제 종료 / 재시작</summary>
public static class PowerOps
{
public static void ForceShutdown() => Run("/s /f /t 0");
public static void ForceRestart() => Run("/r /f /t 0");
private static void Run(string args)
{
var psi = new ProcessStartInfo("shutdown.exe", args)
{
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(psi);
}
}
}

114
Core/Ui.cs Normal file
View File

@ -0,0 +1,114 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace JJServiceManager
{
/// <summary>Views 폴더의 XAML 리소스를 실행 시 읽어들임</summary>
public static class XamlLoader
{
public static object Load(string fileName)
{
var asm = typeof(XamlLoader).Assembly;
var resName = "JJServiceManager.Views." + fileName;
using var stream = asm.GetManifestResourceStream(resName)
?? throw new InvalidOperationException("화면 리소스를 찾을 수 없습니다: " + resName);
return XamlReader.Load(stream);
}
public static FrameworkElement LoadElement(string fileName) => (FrameworkElement)Load(fileName);
public static T Find<T>(this FrameworkElement root, string name) where T : class
=> root.FindName(name) as T ?? throw new InvalidOperationException($"'{name}' 요소를 찾을 수 없습니다.");
}
public static class AppIcon
{
private static ImageSource _image;
private static System.Drawing.Icon _icon;
/// <summary>트레이용 아이콘</summary>
public static System.Drawing.Icon Icon
{
get
{
if (_icon == null)
{
try { _icon = System.Drawing.Icon.ExtractAssociatedIcon(Environment.ProcessPath); }
catch { _icon = System.Drawing.SystemIcons.Application; }
}
return _icon;
}
}
/// <summary>창 제목줄용 아이콘</summary>
public static ImageSource Image
{
get
{
if (_image == null)
{
try
{
_image = Imaging.CreateBitmapSourceFromHIcon(Icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
_image.Freeze();
}
catch { }
}
return _image;
}
}
public static void Apply(Window w)
{
var img = Image;
if (img != null) w.Icon = img;
}
}
public static class Brushes2
{
public static SolidColorBrush Make(string hex)
{
var b = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString(hex));
b.Freeze();
return b;
}
public static readonly SolidColorBrush Running = Make("#2E7D32");
public static readonly SolidColorBrush Stopped = Make("#C62828");
public static readonly SolidColorBrush Pending = Make("#EF6C00");
public static readonly SolidColorBrush Unknown = Make("#757575");
}
public static class Dialogs
{
public static bool Confirm(Window owner, string message, string title = "확인")
{
var r = owner != null && owner.IsVisible
? MessageBox.Show(owner, message, title, MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No)
: MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
return r == MessageBoxResult.Yes;
}
public static void Info(Window owner, string message, string title = "알림")
{
if (owner != null && owner.IsVisible)
MessageBox.Show(owner, message, title, MessageBoxButton.OK, MessageBoxImage.Information);
else
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
}
public static void Error(Window owner, string message, string title = "오류")
{
if (owner != null && owner.IsVisible)
MessageBox.Show(owner, message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
else
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}

32
JJServiceManager.csproj Normal file
View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RootNamespace>JJServiceManager</RootNamespace>
<AssemblyName>JJServiceManager</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>app.ico</ApplicationIcon>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>1.0.0</Version>
<Product>JJServiceManager</Product>
<Description>Windows 서비스 기동/종료 관리</Description>
<SatelliteResourceLanguages>ko</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<!-- WPF + WinForms(트레이 아이콘) -->
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<!-- 화면 정의(XAML)는 실행 시 읽어들이는 리소스로 포함 -->
<EmbeddedResource Include="Views\*.xaml" />
</ItemGroup>
</Project>

93
Program.cs Normal file
View File

@ -0,0 +1,93 @@
using System;
using System.Linq;
using System.Text;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
namespace JJServiceManager
{
public static class Program
{
private const string MutexName = "JJServiceManager_SingleInstance_Mutex";
private const string ShowEventName = "JJServiceManager_SingleInstance_Show";
[STAThread]
public static int Main(string[] args)
{
// CP949(한국어 ANSI) 로그 파일 읽기용
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 한 번만 실행: 이미 실행 중이면 기존 창을 띄우고 종료
using var mutex = new Mutex(true, MutexName, out bool createdNew);
if (!createdNew)
{
// 기존 창이 앞으로 나올 수 있도록 허용
AllowSetForegroundWindow(-1);
// 첫 번째 실행이 아직 준비 중일 수 있으므로 잠시 재시도
for (int i = 0; i < 20; i++)
{
try
{
using var ev = EventWaitHandle.OpenExisting(ShowEventName);
ev.Set();
break;
}
catch
{
Thread.Sleep(250);
}
}
return 0;
}
using var showEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowEventName);
var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
app.Resources.MergedDictionaries.Add((ResourceDictionary)XamlLoader.Load("Styles.xaml"));
app.DispatcherUnhandledException += OnUnhandled;
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
OperationLogFile.Append(DateTime.Now, "처리되지 않은 오류: " + e.ExceptionObject);
var settings = SettingsStore.Load();
bool startInTray = args.Any(a => string.Equals(a, "--tray", StringComparison.OrdinalIgnoreCase));
var main = new MainWindow(settings);
app.SessionEnding += (s, e) => main.OnSessionEnding();
if (!startInTray) main.Show();
// 두 번째 실행 신호를 받으면 창 표시
var waiter = new Thread(() =>
{
while (true)
{
try
{
showEvent.WaitOne();
if (app.Dispatcher.HasShutdownStarted) return;
app.Dispatcher.BeginInvoke(new Action(main.ShowFromTray));
}
catch
{
return;
}
}
})
{ IsBackground = true, Name = "SingleInstanceWaiter" };
waiter.Start();
return app.Run();
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
private static void OnUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
{
OperationLogFile.Append(DateTime.Now, "오류: " + e.Exception);
MessageBox.Show("예상하지 못한 오류가 발생했습니다.\n\n" + e.Exception.Message,
"JJServiceManager", MessageBoxButton.OK, MessageBoxImage.Warning);
e.Handled = true;
}
}
}

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# JJServiceManager
Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
## 실행 파일
| 파일 | 크기 | 필요 조건 |
|---|---|---|
| `JJServiceManager.exe` (런타임 필요판) | 약 1.4MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 |
| `JJServiceManager.exe` (단독 실행판) | 약 65MB | 없음 |
항상 **관리자 권한**으로 실행됩니다 (실행할 때 UAC 창이 한 번 뜹니다).
## 화면
### 초기화면
- **추가**: Windows 서비스 전체 목록에서 관리할 서비스 선택
- **삭제**: 체크한 서비스를 목록에서 제외 (Windows 서비스 자체는 건드리지 않음)
- **선택 Start / 선택 Stop**: 체크한 서비스를 차례로 시작/중지
- **Windows 강제종료 / Windows 재시작**: 확인 후 즉시 실행 (설정에서 버튼 숨김 가능)
- **설정**
- 서비스마다 **Start / Stop / Status / Log** 버튼, 상태는 2초마다 자동 갱신 (초록=실행 중, 빨강=중지됨)
- 서비스 줄 **우클릭**: 로그 파일 지정 / 로그 폴더 지정 / 경로 해제
- 하단 **로그**: 작업 기록 (번호 + 체크박스)
- 전체선택 / 전체취소 / 복사(체크한 줄)
- **화면클리어**: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
- **로그 클리어**: 화면의 로그를 모두 지우고 번호를 1번부터 다시 시작
- 로그 파일은 어떤 경우에도 지우지 않음
- **Esc**: 설정에 따라 트레이로 / 완전 종료 (창의 X 버튼도 같음)
### 추가 선택 화면
- 설명 / 상태 / 이름 / 시작유형 열, 열 제목을 누르면 정렬(▲▼)
- 전체선택 / 전체취소 / 선택완료, 검색
- 이미 등록된 서비스는 노란색 + 체크된 상태로 표시
- 체크를 해제하고 선택완료하면 초기화면에서 빠짐
### 로그창 (서비스 로그 파일)
- 로그 **파일** 지정 또는 로그 **폴더** 지정(폴더 안의 가장 최근 수정 파일을 자동 추적)
- 실시간 표시 (0.5초 간격), 인코딩 자동/UTF-8/CP949
- 띄우기 방식: 1. 개별 창 / 2. 하나의 창에 탭 / 3. 좌우 분할 비교
### 설정
| 항목 | 내용 |
|---|---|
| 컴퓨터 시작시 기동 | 로그인 시 트레이로 자동 실행 (작업 스케줄러 `JJServiceManager` 등록) |
| 단축키 | 직접 눌러서 지정 (기본 Ctrl + Shift + A), Enable 토글. 누르면 창 보이기/숨기기 |
| 서비스 시작시 로그보기 | 작업 로그를 초기화면 하단에 표시할지 여부 |
| 로그창 띄우기 | 개별 창 / 탭 / 좌우 분할 |
| Esc | 트레이로 (백그라운드) / 완전 종료 |
| Windows 종료키 | Windows 강제종료 / 재시작 버튼 표시 여부 |
## 저장 위치
- 설정: `%AppData%\JJServiceManager\settings.json`
- 작업 로그: `%AppData%\JJServiceManager\logs\yyyy-MM-dd.log` (추가만 하고 삭제하지 않음)
## 빌드
[.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 후 `build.bat` 실행 → `publish` 폴더에 두 가지 exe 생성
화면 정의는 `Views/*.xaml`이며 실행 시 읽어들입니다 (XAML 컴파일 없음).

54
Views/LogPanel.xaml Normal file
View File

@ -0,0 +1,54 @@
<DockPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 로그 도구 모음 -->
<WrapPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<Button x:Name="btnSelectAll" Content="전체선택"/>
<Button x:Name="btnSelectNone" Content="전체취소"/>
<Button x:Name="btnClearScreen" Content="화면클리어" ToolTip="새 로그를 화면 맨 위부터 다시 출력합니다. 위로 스크롤하면 이전 로그가 보입니다. 번호는 이어집니다."/>
<Button x:Name="btnCopy" Content="복사" ToolTip="체크한 줄을 클립보드에 복사합니다."/>
<Button x:Name="btnClearLog" Content="로그 클리어" ToolTip="화면의 로그를 모두 지우고 번호를 1번부터 다시 시작합니다. (로그 파일은 지우지 않습니다)"/>
<TextBlock x:Name="txtInfo" VerticalAlignment="Center" Margin="10,0,0,0" Foreground="#757575"/>
</WrapPanel>
<!-- 로그 목록 (번호 + 체크박스) -->
<ListBox x:Name="lstLog"
BorderBrush="#BDBDBD"
FontFamily="Consolas, D2Coding, Malgun Gothic"
FontSize="12.5"
SelectionMode="Extended"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFiller}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
<Setter Property="Focusable" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<!-- 화면클리어용 빈 공간 -->
<Border Height="{Binding FillerHeight}" Visibility="{Binding FillerVisibility}" Background="Transparent"/>
<!-- 로그 한 줄 -->
<DockPanel Height="22" Visibility="{Binding LineVisibility}" Background="Transparent">
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding IsChecked, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center" Margin="4,0,6,0"/>
<TextBlock DockPanel.Dock="Left" Text="{Binding NoText}" Width="58" TextAlignment="Right" Margin="0,0,10,0" VerticalAlignment="Center" Foreground="#1565C0"/>
<TextBlock Text="{Binding Display}" VerticalAlignment="Center" Foreground="{Binding Foreground}"/>
</DockPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>

98
Views/MainWindow.xaml Normal file
View File

@ -0,0 +1,98 @@
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*" MinHeight="120"/>
<RowDefinition Height="8"/>
<RowDefinition Height="*" MinHeight="140"/>
</Grid.RowDefinitions>
<!-- ───────── 상단 버튼 ───────── -->
<Border Grid.Row="0" BorderBrush="#B0BEC5" BorderThickness="1" Background="White" Padding="4" Margin="0,0,0,6">
<DockPanel>
<Button x:Name="btnSettings" DockPanel.Dock="Right" Content="설정"/>
<StackPanel x:Name="pnlShutdown" DockPanel.Dock="Right" Orientation="Horizontal" Margin="0,0,12,0">
<Button x:Name="btnShutdown" Content="Windows 강제종료" Style="{DynamicResource DangerButton}"/>
<Button x:Name="btnRestart" Content="Windows 재시작" Style="{DynamicResource DangerButton}"/>
</StackPanel>
<WrapPanel>
<Button x:Name="btnAdd" Content="추가" ToolTip="관리할 Windows 서비스를 선택합니다."/>
<Button x:Name="btnDelete" Content="삭제" ToolTip="체크한 서비스를 목록에서 뺍니다. (Windows 서비스는 그대로 유지)"/>
<Rectangle Width="1" Fill="#CFD8DC" Margin="8,4"/>
<Button x:Name="btnStartChecked" Content="선택 Start" ToolTip="체크한 서비스를 차례로 시작합니다."/>
<Button x:Name="btnStopChecked" Content="선택 Stop" ToolTip="체크한 서비스를 차례로 중지합니다."/>
</WrapPanel>
</DockPanel>
</Border>
<!-- ───────── 등록된 서비스 목록 ───────── -->
<Border Grid.Row="1" BorderBrush="#B0BEC5" BorderThickness="1" Background="White">
<DockPanel>
<Border DockPanel.Dock="Top" Background="#ECEFF1" BorderBrush="#CFD8DC" BorderThickness="0,0,0,1" Padding="8,5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox x:Name="chkAll" VerticalAlignment="Center" ToolTip="전체 체크 / 해제"/>
<TextBlock Grid.Column="1" Text="서비스" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="상태" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock x:Name="txtCount" Grid.Column="3" Foreground="#607D8B" VerticalAlignment="Center" Margin="0,0,6,0"/>
</Grid>
</Border>
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="icServices">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#ECEFF1" BorderThickness="0,0,0,1" Padding="8,4" Background="Transparent">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox IsChecked="{Binding IsChecked, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" ToolTip="{Binding Tooltip}">
<Ellipse Width="10" Height="10" Fill="{Binding StatusBrush}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ServiceNameText}" Foreground="#90A4AE" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Content="Start" Tag="start" Style="{DynamicResource RowButton}" IsEnabled="{Binding IsIdle}"/>
<Button Content="Stop" Tag="stop" Style="{DynamicResource RowButton}" IsEnabled="{Binding IsIdle}"/>
<Button Content="Status" Tag="status" Style="{DynamicResource RowButton}"/>
<Button Content="Log" Tag="log" Style="{DynamicResource RowButton}" ToolTip="{Binding LogTooltip}" FontWeight="{Binding LogFontWeight}"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock x:Name="txtEmpty" Text="[추가] 버튼을 눌러 관리할 서비스를 등록하세요."
Foreground="#90A4AE" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DockPanel>
</Border>
<GridSplitter Grid.Row="2" Height="8" HorizontalAlignment="Stretch" VerticalAlignment="Center" Background="Transparent" ResizeDirection="Rows" ShowsPreview="False"/>
<!-- ───────── 작업 로그 ───────── -->
<Border Grid.Row="3" BorderBrush="#B0BEC5" BorderThickness="1" Background="White" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="로그" Style="{DynamicResource SectionTitle}"/>
<Grid>
<ContentControl x:Name="ccLog"/>
<TextBlock x:Name="txtLogHidden" Visibility="Collapsed" Foreground="#90A4AE" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="작업 로그 표시가 꺼져 있습니다. (설정 → 서비스 시작 시 로그보기)"/>
</Grid>
</DockPanel>
</Border>
</Grid>

95
Views/ServicePicker.xaml Normal file
View File

@ -0,0 +1,95 @@
<DockPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="8">
<!-- 상단: 전체선택 / 전체취소 / 선택완료 -->
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="btnDone" Content="선택완료" FontWeight="Bold" Background="#E3F2FD" BorderBrush="#64B5F6"/>
<Button x:Name="btnCancel" Content="닫기"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="btnAll" Content="전체선택" ToolTip="지금 보이는 목록을 모두 체크합니다."/>
<Button x:Name="btnNone" Content="전체취소" ToolTip="지금 보이는 목록의 체크를 모두 해제합니다."/>
<TextBlock Text="검색" VerticalAlignment="Center" Margin="14,0,6,0"/>
<TextBox x:Name="txtSearch" Width="220" VerticalContentAlignment="Center" Height="28"
ToolTip="이름, 설명, 서비스명으로 찾기"/>
<CheckBox x:Name="chkOnlySelected" Content="선택된 것만 보기" VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
</DockPanel>
<!-- 하단 안내 -->
<DockPanel DockPanel.Dock="Bottom" Margin="0,6,0,0">
<Border DockPanel.Dock="Left" Width="14" Height="14" Background="#FFF8E1" BorderBrush="#FFB300" BorderThickness="1" Margin="0,0,6,0"/>
<TextBlock x:Name="txtStatus" Foreground="#607D8B" VerticalAlignment="Center"
Text="노란색 줄 = 이미 초기화면에 등록된 서비스 · 열 제목을 누르면 정렬(▲▼)됩니다"/>
</DockPanel>
<Grid>
<DataGrid x:Name="grid"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
CanUserSortColumns="True"
SelectionMode="Extended"
SelectionUnit="FullRow"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#ECEFF1"
RowHeight="28"
EnableRowVirtualization="True"
BorderBrush="#B0BEC5"
Background="White">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="ToolTip" Value="{Binding Tooltip}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRegistered}" Value="True">
<Setter Property="Background" Value="#FFF8E1"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Padding" Value="4,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#BBDEFB"/>
<Setter Property="Foreground" Value="#0D47A1"/>
</Trigger>
</Style.Triggers>
</Style>
</DataGrid.CellStyle>
<DataGrid.Columns>
<DataGridTemplateColumn Header="선택" Width="50" SortMemberPath="IsSelected">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="설명" Binding="{Binding Description}" Width="3*"/>
<DataGridTextColumn Header="상태" Binding="{Binding StatusText}" Width="90"/>
<DataGridTextColumn Header="이름" Binding="{Binding DisplayName}" Width="2*"/>
<DataGridTextColumn Header="시작유형" Binding="{Binding StartTypeText}" Width="120"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock x:Name="txtLoading" Text="서비스 목록을 불러오는 중…" FontSize="14" Foreground="#607D8B"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DockPanel>

74
Views/Settings.xaml Normal file
View File

@ -0,0 +1,74 @@
<DockPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button x:Name="btnOk" Content="확인" FontWeight="Bold" MinWidth="80" Background="#E3F2FD" BorderBrush="#64B5F6"/>
<Button x:Name="btnCancel" Content="취소" MinWidth="80"/>
</StackPanel>
<Border BorderBrush="#B0BEC5" BorderThickness="1" Background="White" Padding="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="210"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 1. 컴퓨터 시작시 기동 -->
<TextBlock Grid.Row="0" Text="컴퓨터 시작시 기동" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkStartup" Grid.Row="0" Grid.Column="1" Style="{DynamicResource ToggleSwitch}"
ToolTip="Windows에 로그인하면 트레이로 자동 실행합니다. (작업 스케줄러에 등록)"/>
<!-- 2. 단축키 -->
<TextBlock Grid.Row="1" Text="단축키" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<TextBox x:Name="txtHotkey" Width="190" Height="28" IsReadOnly="True" IsReadOnlyCaretVisible="False"
VerticalContentAlignment="Center" HorizontalContentAlignment="Center"
FontWeight="Bold" Cursor="Hand" Background="#FAFAFA"
ToolTip="여기를 클릭한 뒤 원하는 키 조합을 직접 누르세요. (Ctrl / Alt / Win 중 하나 이상 포함)"/>
<TextBlock Text="Enable" VerticalAlignment="Center" Margin="16,0,8,0"/>
<CheckBox x:Name="chkHotkey" Style="{DynamicResource ToggleSwitch}"/>
</StackPanel>
<!-- 3. 서비스 시작시 로그보기 -->
<TextBlock Grid.Row="2" Text="서비스 시작시 로그보기" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox x:Name="chkOpLog" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="Start / Stop 등 작업 로그를 초기화면 하단에 표시" Foreground="#78909C" VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<!-- 4. 로그창 띄우기 -->
<TextBlock Grid.Row="3" Text="로그창 띄우기" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbLogMode" Grid.Row="3" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
<ComboBoxItem Content="1. 개별 창"/>
<ComboBoxItem Content="2. 하나의 창에 탭으로"/>
<ComboBoxItem Content="3. 창을 좌우로 나누어 비교"/>
</ComboBox>
<!-- 5. Esc -->
<TextBlock Grid.Row="4" Text="Esc" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbEsc" Grid.Row="4" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
<ComboBoxItem Content="트레이로 (백그라운드)"/>
<ComboBoxItem Content="완전 종료"/>
</ComboBox>
<!-- 6. Windows 종료키 -->
<TextBlock Grid.Row="5" Text="Windows 종료키" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox x:Name="chkShutdownButtons" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="초기화면에 Windows 강제종료 / 재시작 버튼 표시" Foreground="#78909C" VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<TextBlock Grid.Row="6" Grid.ColumnSpan="2" x:Name="txtDataPath" Foreground="#90A4AE" Margin="0,14,0,0" TextWrapping="Wrap"/>
</Grid>
</Border>
</DockPanel>

67
Views/Styles.xaml Normal file
View File

@ -0,0 +1,67 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 공통 버튼 -->
<Style TargetType="Button">
<Setter Property="Padding" Value="10,4"/>
<Setter Property="Margin" Value="3"/>
<Setter Property="MinWidth" Value="64"/>
<Setter Property="MinHeight" Value="28"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="BorderBrush" Value="#9E9E9E"/>
</Style>
<!-- 위험 작업 버튼 (Windows 종료/재시작) -->
<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="#FFEBEE"/>
<Setter Property="BorderBrush" Value="#E57373"/>
<Setter Property="Foreground" Value="#B71C1C"/>
</Style>
<!-- 서비스 목록의 작은 버튼 -->
<Style x:Key="RowButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="MinWidth" Value="58"/>
<Setter Property="MinHeight" Value="26"/>
<Setter Property="Padding" Value="6,2"/>
<Setter Property="Margin" Value="2,0"/>
</Style>
<!-- 토글 버튼 (켬/끔 스위치) -->
<Style x:Key="ToggleSwitch" TargetType="CheckBox">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="Track" Width="46" Height="24" CornerRadius="12" Background="#BDBDBD">
<Ellipse x:Name="Knob" Width="18" Height="18" Fill="White" HorizontalAlignment="Left" Margin="3,0"/>
</Border>
<TextBlock x:Name="Lbl" Text="끔" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="#616161"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="#2E7D32"/>
<Setter TargetName="Knob" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="Lbl" Property="Text" Value="켬"/>
<Setter TargetName="Lbl" Property="Foreground" Value="#2E7D32"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 구역 제목 -->
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="4,6,4,4"/>
<Setter Property="Foreground" Value="#155E75"/>
</Style>
</ResourceDictionary>

700
Windows/MainWindow.cs Normal file
View File

@ -0,0 +1,700 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Security.Principal;
using System.ServiceProcess;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using Forms = System.Windows.Forms;
namespace JJServiceManager
{
/// <summary>초기화면</summary>
public sealed class MainWindow : Window
{
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
private readonly AppSettings _settings;
private readonly ObservableCollection<ServiceRow> _rows = new ObservableCollection<ServiceRow>();
private readonly LogPanel _log;
private readonly LogWindowManager _logWindows;
private readonly DispatcherTimer _statusTimer;
private readonly ItemsControl _list;
private readonly CheckBox _chkAll;
private readonly TextBlock _txtCount, _txtEmpty, _txtLogHidden;
private readonly ContentControl _ccLog;
private readonly FrameworkElement _pnlShutdown;
private Forms.NotifyIcon _tray;
private HotkeyManager _hotkey;
private bool _exiting;
private bool _refreshing;
private bool _trayTipShown;
private bool _modalOpen;
public MainWindow(AppSettings settings)
{
_settings = settings;
Title = "JJServiceManager - 서비스 관리";
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#F5F7F8");
MinWidth = 760;
MinHeight = 520;
AppIcon.Apply(this);
RestoreWindowBounds(settings);
var root = XamlLoader.LoadElement("MainWindow.xaml");
Content = root;
_list = root.Find<ItemsControl>("icServices");
_chkAll = root.Find<CheckBox>("chkAll");
_txtCount = root.Find<TextBlock>("txtCount");
_txtEmpty = root.Find<TextBlock>("txtEmpty");
_txtLogHidden = root.Find<TextBlock>("txtLogHidden");
_ccLog = root.Find<ContentControl>("ccLog");
_pnlShutdown = root.Find<FrameworkElement>("pnlShutdown");
_log = new LogPanel { ShowTimestamp = true };
_ccLog.Content = _log;
_logWindows = new LogWindowManager(() => _settings, SaveSettings);
foreach (var e in settings.Services) _rows.Add(new ServiceRow(e));
_list.ItemsSource = _rows;
_rows.CollectionChanged += (s, e) => UpdateCounts();
// 상단 버튼
root.Find<Button>("btnAdd").Click += (s, e) => AddServices();
root.Find<Button>("btnDelete").Click += (s, e) => DeleteChecked();
root.Find<Button>("btnStartChecked").Click += async (s, e) => await RunChecked(true);
root.Find<Button>("btnStopChecked").Click += async (s, e) => await RunChecked(false);
root.Find<Button>("btnShutdown").Click += (s, e) => Shutdown(false);
root.Find<Button>("btnRestart").Click += (s, e) => Shutdown(true);
root.Find<Button>("btnSettings").Click += (s, e) => OpenSettings();
_chkAll.Click += (s, e) =>
{
bool v = _chkAll.IsChecked == true;
foreach (var r in _rows) r.IsChecked = v;
};
// 서비스 줄의 Start / Stop / Status / Log 버튼
_list.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OnRowButton));
// 우클릭: 로그 경로 지정 메뉴
_list.AddHandler(UIElement.PreviewMouseRightButtonUpEvent, new MouseButtonEventHandler(OnRowRightClick), true);
PreviewKeyDown += OnPreviewKeyDown;
// 상태 자동 갱신 (2초)
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
// 창 핸들을 미리 만들어 트레이 시작 상태에서도 단축키가 동작하게 함
new WindowInteropHelper(this).EnsureHandle();
_hotkey = new HotkeyManager(new WindowInteropHelper(this).Handle);
_hotkey.Pressed += ToggleVisibility;
CreateTray();
ApplySettings(startup: true);
UpdateCounts();
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : ""), LogLevel.Notice);
if (!IsAdmin()) Log("관리자 권한이 아닙니다. 서비스 Start/Stop이 실패할 수 있습니다.", LogLevel.Warning);
if (_rows.Count == 0) Log("[추가] 버튼을 눌러 관리할 서비스를 등록하세요.");
_ = RefreshStatuses(true);
_statusTimer.Start();
// 컴퓨터 시작시 기동이 켜져 있으면 실행 파일 위치가 바뀌었을 경우를 대비해 다시 등록
if (_settings.RunAtStartup) Task.Run(() => StartupManager.Enable());
}
// ───────────────────────── 로그 ─────────────────────────
private void Log(string text, LogLevel level = LogLevel.Info)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => Log(text, level)));
return;
}
OperationLogFile.Append(DateTime.Now, text);
if (_settings.ShowOperationLog) _log.Add(text, level);
}
// ───────────────────────── 서비스 목록 ─────────────────────────
private void UpdateCounts()
{
int running = _rows.Count(r => r.Status == ServiceControllerStatus.Running);
_txtCount.Text = _rows.Count == 0 ? "" : $"{_rows.Count}개 · 실행 중 {running}";
_txtEmpty.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void SyncEntriesFromRows()
{
_settings.Services = _rows.Select(r => r.Entry).ToList();
SaveSettings();
_logWindows.ServicesChanged();
}
private void AddServices()
{
if (_modalOpen) return;
var picker = new ServicePickerWindow(_rows.Select(r => r.ServiceName)) { Owner = this };
bool? ok;
_modalOpen = true;
try { ok = picker.ShowDialog(); }
finally { _modalOpen = false; }
if (ok != true || picker.Result == null) return;
var chosen = picker.Result;
var chosenNames = new HashSet<string>(chosen.Select(c => c.ServiceName), StringComparer.OrdinalIgnoreCase);
// 체크 해제된 기존 항목은 목록에서 뺌
var removed = _rows.Where(r => !chosenNames.Contains(r.ServiceName)).ToList();
foreach (var r in removed)
{
_rows.Remove(r);
_logWindows.Close(r.ServiceName);
}
// 새로 체크된 항목 추가 (기존 순서 유지, 새 항목은 뒤에)
var existing = new HashSet<string>(_rows.Select(r => r.ServiceName), StringComparer.OrdinalIgnoreCase);
var added = new List<string>();
foreach (var c in chosen)
{
if (existing.Contains(c.ServiceName)) continue;
var row = new ServiceRow(new ServiceEntry { ServiceName = c.ServiceName, DisplayName = c.DisplayName });
_rows.Add(row);
added.Add(c.DisplayName);
}
if (added.Count > 0) Log($"서비스 추가: {string.Join(", ", added)}", LogLevel.Success);
if (removed.Count > 0) Log($"목록에서 제외: {string.Join(", ", removed.Select(r => r.DisplayName))}");
if (added.Count == 0 && removed.Count == 0) Log("변경 사항 없음");
SyncEntriesFromRows();
_ = RefreshStatuses(true);
}
private void DeleteChecked()
{
var targets = _rows.Where(r => r.IsChecked).ToList();
if (targets.Count == 0)
{
Dialogs.Info(this, "삭제할 서비스를 체크하세요.");
return;
}
var names = string.Join("\n", targets.Select(t => " · " + t.DisplayName));
if (!Dialogs.Confirm(this, $"아래 {targets.Count}개 서비스를 목록에서 삭제할까요?\n\n{names}\n\n(Windows 서비스 자체는 그대로 유지됩니다)", "삭제"))
return;
foreach (var t in targets)
{
_rows.Remove(t);
_logWindows.Close(t.ServiceName);
}
_chkAll.IsChecked = false;
Log($"목록에서 삭제: {string.Join(", ", targets.Select(t => t.DisplayName))}");
SyncEntriesFromRows();
}
// ───────────────────────── Start / Stop / Status / Log ─────────────────────────
private async void OnRowButton(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
switch (b.Tag as string)
{
case "start": await StartService(row); break;
case "stop": await StopService(row); break;
case "status": await ShowStatus(row); break;
case "log": OpenLog(row, b); break;
}
}
private async Task StartService(ServiceRow row)
{
if (row.IsBusy) return;
row.IsBusy = true;
Log($"[{row.DisplayName}] 시작 요청");
var sw = Stopwatch.StartNew();
try
{
await Task.Run(() => ServiceOps.Start(row.ServiceName, OpTimeout));
Log($"[{row.DisplayName}] 시작 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success);
}
catch (InfoException ex) { Log($"[{row.DisplayName}] {ex.Message}", LogLevel.Warning); }
catch (Exception ex) { Log($"[{row.DisplayName}] 시작 실패: {ServiceOps.Describe(ex)}", LogLevel.Error); }
finally
{
row.IsBusy = false;
await RefreshStatuses(true);
}
}
private async Task StopService(ServiceRow row)
{
if (row.IsBusy) return;
row.IsBusy = true;
Log($"[{row.DisplayName}] 중지 요청");
var sw = Stopwatch.StartNew();
try
{
await Task.Run(() => ServiceOps.Stop(row.ServiceName, OpTimeout));
Log($"[{row.DisplayName}] 중지 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success);
}
catch (InfoException ex) { Log($"[{row.DisplayName}] {ex.Message}", LogLevel.Warning); }
catch (Exception ex) { Log($"[{row.DisplayName}] 중지 실패: {ServiceOps.Describe(ex)}", LogLevel.Error); }
finally
{
row.IsBusy = false;
await RefreshStatuses(true);
}
}
private async Task RunChecked(bool start)
{
var targets = _rows.Where(r => r.IsChecked).ToList();
if (targets.Count == 0)
{
Dialogs.Info(this, $"{(start ? "시작" : "중지")}할 서비스를 체크하세요.");
return;
}
Log($"선택한 {targets.Count}개 서비스 {(start ? "시작" : "중지")}", LogLevel.Notice);
foreach (var r in targets)
{
if (start) await StartService(r);
else await StopService(r);
}
}
private async Task ShowStatus(ServiceRow row)
{
try
{
var lines = await Task.Run(() => ServiceOps.GetDetails(row.ServiceName));
Log($"[{row.DisplayName}] 상태 정보", LogLevel.Notice);
foreach (var l in lines) Log(" " + l);
}
catch (Exception ex)
{
Log($"[{row.DisplayName}] 상태 조회 실패: {ServiceOps.Describe(ex)}", LogLevel.Error);
}
await RefreshStatuses(true);
}
private void OpenLog(ServiceRow row, FrameworkElement anchor)
{
if (string.IsNullOrWhiteSpace(row.Entry.LogPath))
{
// 경로가 없으면 파일/폴더 중 선택
ShowLogMenu(row, anchor, includeView: false);
return;
}
_logWindows.Open(row.Entry);
}
private void OnRowRightClick(object sender, MouseButtonEventArgs e)
{
var fe = e.OriginalSource as FrameworkElement;
while (fe != null && fe.DataContext is not ServiceRow) fe = VisualTreeHelper.GetParent(fe) as FrameworkElement;
if (fe?.DataContext is ServiceRow row)
{
ShowLogMenu(row, e.OriginalSource as FrameworkElement ?? fe, includeView: true);
e.Handled = true;
}
}
private void ShowLogMenu(ServiceRow row, FrameworkElement anchor, bool includeView)
{
var menu = new ContextMenu { PlacementTarget = anchor, Placement = PlacementMode.MousePoint };
if (includeView && !string.IsNullOrWhiteSpace(row.Entry.LogPath))
{
var view = new MenuItem { Header = "로그 보기", FontWeight = FontWeights.Bold };
view.Click += (s, e) => _logWindows.Open(row.Entry);
menu.Items.Add(view);
menu.Items.Add(new Separator());
}
var file = new MenuItem { Header = "로그 파일 지정…" };
file.Click += (s, e) => PickLogFile(row);
menu.Items.Add(file);
var folder = new MenuItem { Header = "로그 폴더 지정… (가장 최근 파일 자동 표시)" };
folder.Click += (s, e) => PickLogFolder(row);
menu.Items.Add(folder);
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
{
menu.Items.Add(new Separator());
var cur = new MenuItem { Header = (row.Entry.LogIsFolder ? "현재 폴더: " : "현재 파일: ") + row.Entry.LogPath, IsEnabled = false };
menu.Items.Add(cur);
var clear = new MenuItem { Header = "로그 경로 해제" };
clear.Click += (s, e) =>
{
row.Entry.LogPath = "";
row.Entry.LogIsFolder = false;
row.LogPathChanged();
Log($"[{row.DisplayName}] 로그 경로 해제");
SyncEntriesFromRows();
_logWindows.EntryPathChanged(row.Entry);
};
menu.Items.Add(clear);
}
menu.IsOpen = true;
}
private void PickLogFile(ServiceRow row)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = $"{row.DisplayName} - 로그 파일 선택",
Filter = "로그 파일 (*.log;*.txt)|*.log;*.txt|모든 파일 (*.*)|*.*",
CheckFileExists = true
};
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
{
try
{
dlg.InitialDirectory = row.Entry.LogIsFolder
? row.Entry.LogPath
: System.IO.Path.GetDirectoryName(row.Entry.LogPath);
}
catch { }
}
if (dlg.ShowDialog(this) != true) return;
row.Entry.LogPath = dlg.FileName;
row.Entry.LogIsFolder = false;
row.LogPathChanged();
Log($"[{row.DisplayName}] 로그 파일 지정: {dlg.FileName}");
SyncEntriesFromRows();
_logWindows.EntryPathChanged(row.Entry);
_logWindows.Open(row.Entry);
}
private void PickLogFolder(ServiceRow row)
{
var dlg = new Microsoft.Win32.OpenFolderDialog
{
Title = $"{row.DisplayName} - 로그 폴더 선택 (가장 최근 파일을 자동으로 표시)"
};
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
{
try
{
dlg.InitialDirectory = row.Entry.LogIsFolder
? row.Entry.LogPath
: System.IO.Path.GetDirectoryName(row.Entry.LogPath);
}
catch { }
}
if (dlg.ShowDialog(this) != true) return;
row.Entry.LogPath = dlg.FolderName;
row.Entry.LogIsFolder = true;
row.LogPathChanged();
Log($"[{row.DisplayName}] 로그 폴더 지정: {dlg.FolderName}");
SyncEntriesFromRows();
_logWindows.EntryPathChanged(row.Entry);
_logWindows.Open(row.Entry);
}
// ───────────────────────── 상태 자동 갱신 ─────────────────────────
private async Task RefreshStatuses(bool force)
{
if (_refreshing && !force) return;
_refreshing = true;
try
{
var names = _rows.Select(r => r.ServiceName).ToList();
var result = await Task.Run(() => names.ToDictionary(n => n, n => ServiceOps.GetStatus(n), StringComparer.OrdinalIgnoreCase));
foreach (var r in _rows)
{
if (!result.TryGetValue(r.ServiceName, out var st)) continue;
if (r.StatusKnown && r.Status != st && !r.IsBusy)
{
Log($"[{r.DisplayName}] 상태 변경: {ServiceOps.StatusText(r.Status)} → {ServiceOps.StatusText(st)}",
st == ServiceControllerStatus.Running ? LogLevel.Success
: st == ServiceControllerStatus.Stopped ? LogLevel.Warning : LogLevel.Info);
}
r.Status = st;
}
UpdateCounts();
}
catch
{
// 다음 주기에 다시 시도
}
finally
{
_refreshing = false;
}
}
// ───────────────────────── Windows 종료 ─────────────────────────
private void Shutdown(bool restart)
{
var what = restart ? "재시작" : "강제 종료";
if (!Dialogs.Confirm(this, $"Windows를 지금 {what}합니다.\n저장하지 않은 작업은 모두 사라집니다.\n\n계속할까요?", "Windows " + what))
return;
Log($"Windows {what} 실행", LogLevel.Warning);
try
{
if (restart) PowerOps.ForceRestart(); else PowerOps.ForceShutdown();
}
catch (Exception ex)
{
Log($"Windows {what} 실패: {ex.Message}", LogLevel.Error);
}
}
// ───────────────────────── 설정 ─────────────────────────
private void OpenSettings()
{
ShowFromTray();
if (_modalOpen) return;
bool wasStartup = _settings.RunAtStartup;
var dlg = new SettingsWindow(_settings) { Owner = this };
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
_hotkey.Unregister();
bool? dialogOk;
_modalOpen = true;
try { dialogOk = dlg.ShowDialog(); }
finally { _modalOpen = false; }
if (dialogOk != true)
{
ApplyHotkey(quiet: true);
return;
}
SaveSettings();
Log("설정 저장");
if (wasStartup != _settings.RunAtStartup || (_settings.RunAtStartup && !StartupManager.IsEnabled()))
{
var (ok, msg) = _settings.RunAtStartup ? StartupManager.Enable() : StartupManager.Disable();
if (ok) Log(_settings.RunAtStartup ? "컴퓨터 시작시 기동: 켬 (작업 스케줄러 등록)" : "컴퓨터 시작시 기동: 끔", LogLevel.Success);
else Log("컴퓨터 시작시 기동 설정 실패: " + msg, LogLevel.Error);
}
ApplySettings(startup: false);
}
private void ApplySettings(bool startup)
{
// Windows 종료 버튼 표시
_pnlShutdown.Visibility = _settings.ShowShutdownButtons ? Visibility.Visible : Visibility.Collapsed;
// 작업 로그 표시
_ccLog.Visibility = _settings.ShowOperationLog ? Visibility.Visible : Visibility.Collapsed;
_txtLogHidden.Visibility = _settings.ShowOperationLog ? Visibility.Collapsed : Visibility.Visible;
ApplyHotkey(quiet: startup);
}
private void ApplyHotkey(bool quiet)
{
var mods = (ModifierKeys)_settings.HotkeyModifiers;
var key = (Key)_settings.HotkeyKey;
if (_settings.HotkeyEnabled)
{
if (_hotkey.Register(mods, key))
{
if (!quiet) Log($"단축키 등록: {HotkeyManager.Format(mods, key)}");
}
else
{
var prev = _hotkey.Active;
Log($"단축키 {HotkeyManager.Format(mods, key)} 등록 실패 (다른 프로그램이 사용 중일 수 있습니다)"
+ (prev.HasValue ? $" → 이전 단축키 {HotkeyManager.Format(prev.Value.mods, prev.Value.key)} 유지" : ""),
LogLevel.Warning);
}
}
else
{
_hotkey.Unregister();
}
if (_tray != null)
_tray.Text = _settings.HotkeyEnabled
? Trim63($"JJServiceManager ({HotkeyManager.Format(mods, key)})")
: "JJServiceManager";
}
private static string Trim63(string s) => s.Length > 63 ? s.Substring(0, 63) : s;
private void SaveSettings()
{
if (WindowState == WindowState.Normal && IsLoaded)
{
_settings.WindowLeft = Left;
_settings.WindowTop = Top;
_settings.WindowWidth = Width;
_settings.WindowHeight = Height;
}
SettingsStore.Save(_settings);
}
private void RestoreWindowBounds(AppSettings s)
{
Width = s.WindowWidth > 300 ? s.WindowWidth : 1000;
Height = s.WindowHeight > 300 ? s.WindowHeight : 720;
bool valid = !double.IsNaN(s.WindowLeft) && !double.IsNaN(s.WindowTop)
&& s.WindowLeft >= SystemParameters.VirtualScreenLeft - 50
&& s.WindowTop >= SystemParameters.VirtualScreenTop - 50
&& s.WindowLeft < SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth - 100
&& s.WindowTop < SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight - 100;
if (valid)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = s.WindowLeft;
Top = s.WindowTop;
}
else
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
// ───────────────────────── 트레이 / Esc / 종료 ─────────────────────────
private void CreateTray()
{
var menu = new Forms.ContextMenuStrip();
menu.Items.Add("열기", null, (s, e) => Dispatcher.Invoke(ShowFromTray));
menu.Items.Add("설정", null, (s, e) => { if (!_modalOpen) Dispatcher.Invoke(OpenSettings); else Dispatcher.Invoke(ShowFromTray); });
menu.Items.Add(new Forms.ToolStripSeparator());
menu.Items.Add("완전 종료", null, (s, e) => Dispatcher.Invoke(ExitApp));
_tray = new Forms.NotifyIcon
{
Icon = AppIcon.Icon,
Text = "JJServiceManager",
ContextMenuStrip = menu,
Visible = true
};
_tray.MouseClick += (s, e) =>
{
if (e.Button == Forms.MouseButtons.Left) Dispatcher.Invoke(ShowFromTray);
};
}
public void ShowFromTray()
{
if (!IsVisible) Show();
if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
Activate();
Topmost = true; // 다른 창 뒤에 숨지 않도록 잠깐 맨 앞으로
Topmost = false;
Focus();
}
private void HideToTray()
{
SaveSettings();
Hide();
if (!_trayTipShown && _tray != null)
{
_trayTipShown = true;
var hk = _settings.HotkeyEnabled ? $" 단축키 {HotkeyManager.Format((ModifierKeys)_settings.HotkeyModifiers, (Key)_settings.HotkeyKey)}로 다시 열 수 있습니다." : "";
_tray.ShowBalloonTip(2500, "JJServiceManager", "트레이에서 계속 실행 중입니다." + hk, Forms.ToolTipIcon.Info);
}
}
private void ToggleVisibility()
{
if (IsVisible && IsActive && WindowState != WindowState.Minimized) HideToTray();
else ShowFromTray();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Escape) return;
e.Handled = true;
if (_settings.EscAction == EscAction.Exit) ExitApp();
else HideToTray();
}
protected override void OnClosing(CancelEventArgs e)
{
if (!_exiting)
{
// 창의 X 버튼도 Esc 설정을 따름
if (_settings.EscAction == EscAction.Tray)
{
e.Cancel = true;
HideToTray();
return;
}
_exiting = true;
Cleanup();
}
base.OnClosing(e);
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
Application.Current.Shutdown();
}
/// <summary>Windows 로그오프/종료 시 정리</summary>
public void OnSessionEnding()
{
if (_exiting) return;
_exiting = true;
OperationLogFile.Append(DateTime.Now, "Windows 세션 종료");
Cleanup();
}
public void ExitApp()
{
if (_exiting) return;
_exiting = true;
Cleanup();
Close();
Application.Current.Shutdown();
}
private void Cleanup()
{
OperationLogFile.Append(DateTime.Now, "프로그램 종료");
_statusTimer.Stop();
SaveSettings();
_logWindows.CloseAll();
_hotkey?.Dispose();
if (_tray != null)
{
_tray.Visible = false;
_tray.Dispose();
_tray = null;
}
}
private static bool IsAdmin()
{
try { return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); }
catch { return false; }
}
}
}

View File

@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
namespace JJServiceManager
{
/// <summary>추가 선택 화면: Windows 서비스 전체 목록에서 관리할 서비스를 고름</summary>
public sealed class ServicePickerWindow : Window
{
private readonly HashSet<string> _registered;
private readonly DataGrid _grid;
private readonly TextBox _search;
private readonly CheckBox _onlySelected;
private readonly TextBlock _loading;
private readonly TextBlock _status;
private List<ServiceInfo> _all = new List<ServiceInfo>();
private ICollectionView _view;
/// <summary>선택완료 시 체크된 서비스 (순서: 이름순)</summary>
public List<ServiceInfo> Result { get; private set; }
public ServicePickerWindow(IEnumerable<string> registeredNames)
{
_registered = new HashSet<string>(registeredNames, StringComparer.OrdinalIgnoreCase);
Title = "추가 선택 화면 - 서비스 선택";
Width = 1100;
Height = 700;
MinWidth = 700;
MinHeight = 400;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#FAFAFA");
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
AppIcon.Apply(this);
var root = XamlLoader.LoadElement("ServicePicker.xaml");
Content = root;
_grid = root.Find<DataGrid>("grid");
_search = root.Find<TextBox>("txtSearch");
_onlySelected = root.Find<CheckBox>("chkOnlySelected");
_loading = root.Find<TextBlock>("txtLoading");
_status = root.Find<TextBlock>("txtStatus");
root.Find<Button>("btnAll").Click += (s, e) => SetVisibleSelected(true);
root.Find<Button>("btnNone").Click += (s, e) => SetVisibleSelected(false);
root.Find<Button>("btnDone").Click += (s, e) => Done();
root.Find<Button>("btnCancel").Click += (s, e) => Close();
_search.TextChanged += (s, e) => _view?.Refresh();
_onlySelected.Checked += (s, e) => _view?.Refresh();
_onlySelected.Unchecked += (s, e) => _view?.Refresh();
// Space: 마우스로 선택한 줄들의 체크 토글
_grid.PreviewKeyDown += (s, e) =>
{
if (e.Key == Key.Space)
{
var sel = _grid.SelectedItems.Cast<ServiceInfo>().ToList();
if (sel.Count > 0)
{
bool v = !sel.All(x => x.IsSelected);
foreach (var x in sel) x.IsSelected = v;
e.Handled = true;
}
}
};
// 더블클릭: 체크 토글
_grid.MouseDoubleClick += (s, e) =>
{
// 줄 위에서만 (열 제목·스크롤바·체크박스 제외)
var src = e.OriginalSource as DependencyObject;
if (FindAncestor<CheckBox>(src) != null) return;
var row = FindAncestor<DataGridRow>(src);
if (row?.Item is ServiceInfo si) si.IsSelected = !si.IsSelected;
};
KeyDown += (s, e) => { if (e.Key == Key.Escape) Close(); };
Loaded += async (s, e) => await LoadAsync();
}
private static T FindAncestor<T>(DependencyObject d) where T : DependencyObject
{
while (d != null)
{
if (d is T t) return t;
d = d is Visual || d is System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return null;
}
private async Task LoadAsync()
{
_loading.Visibility = Visibility.Visible;
var reg = _registered;
_all = await Task.Run(() => ServiceInfo.LoadAll(reg));
_view = CollectionViewSource.GetDefaultView(_all);
_view.Filter = Filter;
// 기본 정렬: 이름 오름차순
_view.SortDescriptions.Add(new SortDescription(nameof(ServiceInfo.DisplayName), ListSortDirection.Ascending));
_grid.ItemsSource = _view;
foreach (var col in _grid.Columns)
if ((string)col.Header == "이름") col.SortDirection = ListSortDirection.Ascending;
_loading.Visibility = Visibility.Collapsed;
if (_all.Count == 0) _status.Text = "서비스 목록을 불러오지 못했습니다.";
}
private bool Filter(object o)
{
var s = (ServiceInfo)o;
if (_onlySelected.IsChecked == true && !s.IsSelected) return false;
var q = _search.Text?.Trim();
if (string.IsNullOrEmpty(q)) return true;
return Contains(s.DisplayName, q) || Contains(s.Description, q) || Contains(s.ServiceName, q);
}
private static bool Contains(string text, string q)
=> !string.IsNullOrEmpty(text) && text.IndexOf(q, StringComparison.OrdinalIgnoreCase) >= 0;
private void SetVisibleSelected(bool value)
{
if (_view == null) return;
foreach (ServiceInfo s in _view) s.IsSelected = value;
}
private void Done()
{
// 목록을 못 불러온 상태에서 선택완료하면 기존 등록이 지워지지 않도록 그냥 닫음
if (_view == null || _all.Count == 0)
{
Close();
return;
}
Result = _all.Where(s => s.IsSelected)
.OrderBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
DialogResult = true;
}
}
}

73
Windows/ServiceRow.cs Normal file
View File

@ -0,0 +1,73 @@
using System.ServiceProcess;
using System.Windows;
using System.Windows.Media;
namespace JJServiceManager
{
/// <summary>초기화면 서비스 목록의 한 줄</summary>
public sealed class ServiceRow : ObservableObject
{
private bool _isChecked;
private ServiceControllerStatus? _status;
private bool _statusKnown;
private bool _isBusy;
public ServiceEntry Entry { get; }
public ServiceRow(ServiceEntry entry) => Entry = entry;
public string DisplayName => string.IsNullOrEmpty(Entry.DisplayName) ? Entry.ServiceName : Entry.DisplayName;
public string ServiceName => Entry.ServiceName;
public string ServiceNameText => string.Equals(Entry.DisplayName, Entry.ServiceName, System.StringComparison.OrdinalIgnoreCase) ? "" : $"({Entry.ServiceName})";
public string Tooltip => $"서비스명: {Entry.ServiceName}\n우클릭: 로그 경로 지정";
public bool IsChecked { get => _isChecked; set => Set(ref _isChecked, value); }
/// <summary>Start/Stop 진행 중이면 false</summary>
public bool IsIdle => !_isBusy;
public bool IsBusy
{
get => _isBusy;
set { if (Set(ref _isBusy, value)) OnPropertyChanged(nameof(IsIdle)); }
}
public ServiceControllerStatus? Status
{
get => _status;
set
{
if (_statusKnown && _status == value) return;
_status = value;
_statusKnown = true;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusText));
OnPropertyChanged(nameof(StatusBrush));
}
}
public bool StatusKnown => _statusKnown;
public string StatusText => _statusKnown ? ServiceOps.StatusText(_status) : "확인 중…";
public Brush StatusBrush => !_statusKnown ? Brushes2.Unknown : _status switch
{
ServiceControllerStatus.Running => Brushes2.Running,
ServiceControllerStatus.Stopped => Brushes2.Stopped,
null => Brushes2.Unknown,
ServiceControllerStatus.Paused => Brushes2.Unknown,
_ => Brushes2.Pending
};
public string LogTooltip => string.IsNullOrWhiteSpace(Entry.LogPath)
? "로그 경로가 없습니다. 누르면 지정합니다. (우클릭: 변경)"
: (Entry.LogIsFolder ? "폴더(최신 파일): " : "파일: ") + Entry.LogPath + "\n우클릭: 변경";
public FontWeight LogFontWeight => string.IsNullOrWhiteSpace(Entry.LogPath) ? FontWeights.Normal : FontWeights.Bold;
public void LogPathChanged()
{
OnPropertyChanged(nameof(LogTooltip));
OnPropertyChanged(nameof(LogFontWeight));
}
}
}

118
Windows/SettingsWindow.cs Normal file
View File

@ -0,0 +1,118 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace JJServiceManager
{
/// <summary>설정 화면</summary>
public sealed class SettingsWindow : Window
{
private readonly AppSettings _s;
private readonly CheckBox _startup, _hotkey, _opLog, _shutdownButtons;
private readonly TextBox _hotkeyBox;
private readonly ComboBox _logMode, _esc;
private ModifierKeys _mods;
private Key _key;
public SettingsWindow(AppSettings current)
{
_s = current;
Title = "설정";
Width = 640;
SizeToContent = SizeToContent.Height;
ResizeMode = ResizeMode.NoResize;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#FAFAFA");
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
AppIcon.Apply(this);
var root = XamlLoader.LoadElement("Settings.xaml");
Content = root;
_startup = root.Find<CheckBox>("chkStartup");
_hotkey = root.Find<CheckBox>("chkHotkey");
_opLog = root.Find<CheckBox>("chkOpLog");
_shutdownButtons = root.Find<CheckBox>("chkShutdownButtons");
_hotkeyBox = root.Find<TextBox>("txtHotkey");
_logMode = root.Find<ComboBox>("cmbLogMode");
_esc = root.Find<ComboBox>("cmbEsc");
root.Find<TextBlock>("txtDataPath").Text = "설정 파일: " + SettingsStore.SettingsPath
+ "\n작업 로그 파일: " + OperationLogFile.Folder;
// 현재 값 표시
_startup.IsChecked = current.RunAtStartup;
_hotkey.IsChecked = current.HotkeyEnabled;
_opLog.IsChecked = current.ShowOperationLog;
_shutdownButtons.IsChecked = current.ShowShutdownButtons;
_logMode.SelectedIndex = (int)current.LogWindowMode;
_esc.SelectedIndex = (int)current.EscAction;
_mods = (ModifierKeys)current.HotkeyModifiers;
_key = (Key)current.HotkeyKey;
ShowHotkey();
// 단축키: 클릭 후 직접 눌러서 지정
_hotkeyBox.GotKeyboardFocus += (s, e) => _hotkeyBox.Background = Brushes2.Make("#FFF8E1");
_hotkeyBox.LostKeyboardFocus += (s, e) => { _hotkeyBox.Background = Brushes2.Make("#FAFAFA"); ShowHotkey(); };
_hotkeyBox.PreviewKeyDown += OnHotkeyKeyDown;
root.Find<Button>("btnOk").Click += (s, e) => Save();
root.Find<Button>("btnCancel").Click += (s, e) => Close();
PreviewKeyDown += (s, e) =>
{
if (e.Key == Key.Escape && !_hotkeyBox.IsKeyboardFocused) Close();
};
}
private void OnHotkeyKeyDown(object sender, KeyEventArgs e)
{
e.Handled = true;
var key = e.Key == Key.System ? e.SystemKey : e.Key;
var mods = Keyboard.Modifiers;
if (Keyboard.IsKeyDown(Key.LWin) || Keyboard.IsKeyDown(Key.RWin)) mods |= ModifierKeys.Windows;
// 보조키만 누른 상태: 진행 중 표시
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt
or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin)
{
_hotkeyBox.Text = HotkeyManager.Format(mods, Key.None) + "…";
return;
}
// Tab은 포커스 이동에 사용
if (key == Key.Tab && mods == ModifierKeys.None)
{
e.Handled = false;
return;
}
if ((mods & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Windows)) == 0)
{
_hotkeyBox.Text = "Ctrl / Alt / Win 중 하나와 함께 누르세요";
return;
}
_mods = mods;
_key = key;
ShowHotkey();
}
private void ShowHotkey() => _hotkeyBox.Text = HotkeyManager.Format(_mods, _key);
private void Save()
{
_s.RunAtStartup = _startup.IsChecked == true;
_s.HotkeyEnabled = _hotkey.IsChecked == true;
_s.HotkeyModifiers = (int)_mods;
_s.HotkeyKey = (int)_key;
_s.ShowOperationLog = _opLog.IsChecked == true;
_s.ShowShutdownButtons = _shutdownButtons.IsChecked == true;
_s.LogWindowMode = (LogWindowMode)System.Math.Max(0, _logMode.SelectedIndex);
_s.EscAction = (EscAction)System.Math.Max(0, _esc.SelectedIndex);
DialogResult = true;
}
}
}

BIN
app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

23
app.manifest Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="JJServiceManager.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 서비스 Start/Stop, Windows 종료를 위해 항상 관리자 권한으로 실행 -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

23
build.bat Normal file
View File

@ -0,0 +1,23 @@
@echo off
chcp 65001 > nul
rem JJServiceManager 빌드 (.NET 8 SDK 필요: https://dotnet.microsoft.com/download/dotnet/8.0)
cd /d "%~dp0"
echo [1/2] 런타임 필요판 (작음, .NET 8 Desktop Runtime 설치 필요)
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o publish\runtime-required
if errorlevel 1 goto :fail
echo [2/2] 단독 실행판 (큼, 설치 없이 실행)
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -o publish\standalone
if errorlevel 1 goto :fail
echo.
echo 완료: publish 폴더를 확인하세요.
pause
exit /b 0
:fail
echo.
echo 빌드 실패
pause
exit /b 1