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_013U497kUNL8ArhV7jkzCpKamain
commit
84cafc48a2
|
|
@ -0,0 +1,7 @@
|
|||
bin/
|
||||
obj/
|
||||
publish/
|
||||
dist/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
// 기록 실패는 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 컴파일 없음).
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue