원격 모니터링 기능 추가 (작업 중, 검토 반영 전)
- 원격 접속 허용(대상): 내장 SSH(2222) 또는 지정 포트 TLS(9988), 접속 비밀번호(PBKDF2) - 다른 PC 관리(관리): 대상 PC 등록 서비스 목록/상태/Start/Stop/Status/로그 실시간 - 초기화면: 전체 탭(PC별 묶음) + 콤보박스로 PC 탭 추가/닫기, 원격 서비스 앞 IP/hostname - Start/Stop 숫자 6자리 확인 (설정에서 내 PC/원격 각각 선택) - 설정 3탭(일반/원격 접속 허용/관리 대상 PC), hostname 자동 등록, 방화벽 자동 - 테스트용 --instance N 중복 실행 (정식 릴리즈 시 삭제) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKamain
parent
7f45f37042
commit
c8fa82b743
|
|
@ -8,13 +8,22 @@ using System.Threading;
|
|||
|
||||
namespace JJServiceManager
|
||||
{
|
||||
/// <summary>로그 보기 창이 사용하는 로그 공급원 (내 PC 파일 / 원격)</summary>
|
||||
public interface ILogSource : IDisposable
|
||||
{
|
||||
event Action<string> FileChanged;
|
||||
string CurrentFile { get; }
|
||||
void Start();
|
||||
void Reload();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서비스 로그 파일을 실시간으로 읽음 (tail).
|
||||
/// - 파일 지정: 그 파일을 계속 추적
|
||||
/// - 폴더 지정: 폴더 안에서 가장 최근에 수정된 파일을 자동으로 추적 (날짜별 로그에 유리)
|
||||
/// - 읽기 전용으로만 열며, 파일을 수정·삭제하지 않음
|
||||
/// </summary>
|
||||
public sealed class LogTailer : IDisposable
|
||||
public sealed class LogTailer : ILogSource
|
||||
{
|
||||
private const int PollMs = 500;
|
||||
private const int FolderScanEvery = 4; // 폴더 모드: 2초마다 최신 파일 확인
|
||||
|
|
@ -51,6 +60,9 @@ namespace JJServiceManager
|
|||
|
||||
public string CurrentFile => _current?.Path;
|
||||
|
||||
/// <summary>true면 처음 열 때 기존 내용은 건너뛰고 새로 추가되는 줄만 표시 (원격 재연결용)</summary>
|
||||
public bool SkipInitial { get; set; }
|
||||
|
||||
public LogTailer(ServiceEntry entry, Action<IList<string>, LogLevel> output)
|
||||
{
|
||||
_entry = entry;
|
||||
|
|
@ -190,6 +202,13 @@ namespace JJServiceManager
|
|||
PrepareEncoding(st, fs);
|
||||
|
||||
long len = fs.Length;
|
||||
if (SkipInitial)
|
||||
{
|
||||
SkipInitial = false;
|
||||
st.Position = len;
|
||||
st.Partial = "";
|
||||
return;
|
||||
}
|
||||
long start = Math.Max(st.BomLength, len - InitialBytes);
|
||||
if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++;
|
||||
bool fromMiddle = start > st.BomLength;
|
||||
|
|
|
|||
|
|
@ -9,27 +9,70 @@ using System.Windows.Media;
|
|||
|
||||
namespace JJServiceManager
|
||||
{
|
||||
/// <summary>서비스 한 개의 로그 파일 보기 (상단 정보줄 + 로그 목록)</summary>
|
||||
/// <summary>
|
||||
/// 로그창에 띄울 대상 하나 (내 PC 서비스 로그 파일 또는 원격 PC 서비스 로그)
|
||||
/// </summary>
|
||||
public sealed class LogTarget
|
||||
{
|
||||
/// <summary>창/탭을 구분하는 키 ("local|서비스명" / "remote|대상Id|서비스명")</summary>
|
||||
public string Key { get; init; } = "";
|
||||
|
||||
/// <summary>제목 (원격이면 "IP / hostname - 서비스명")</summary>
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>경로 표시</summary>
|
||||
public Func<string> PathText { get; init; } = () => "";
|
||||
|
||||
public bool IsLocal { get; init; }
|
||||
|
||||
/// <summary>내 PC 서비스일 때 (인코딩 저장, 탐색기 열기용)</summary>
|
||||
public ServiceEntry LocalEntry { get; init; }
|
||||
|
||||
/// <summary>인코딩 (내 PC는 설정에 저장, 원격은 창을 연 동안만)</summary>
|
||||
public string Encoding
|
||||
{
|
||||
get => LocalEntry?.LogEncoding ?? _encoding;
|
||||
set { if (LocalEntry != null) LocalEntry.LogEncoding = value; else _encoding = value; }
|
||||
}
|
||||
private string _encoding = "auto";
|
||||
|
||||
/// <summary>로그 공급원 만들기 (출력 콜백을 받아서)</summary>
|
||||
public Func<LogTarget, Action<IList<string>, LogLevel>, ILogSource> CreateSource { get; init; }
|
||||
|
||||
public override string ToString() => Title;
|
||||
|
||||
public static LogTarget ForLocal(ServiceEntry e) => new LogTarget
|
||||
{
|
||||
Key = "local|" + e.ServiceName,
|
||||
Title = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName,
|
||||
PathText = () => (e.LogIsFolder ? "폴더: " : "파일: ") + e.LogPath,
|
||||
IsLocal = true,
|
||||
LocalEntry = e,
|
||||
CreateSource = (t, output) => new LogTailer(e, output)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>로그 보기 하나 (상단 정보줄 + 로그 목록)</summary>
|
||||
public sealed class LogTailView : DockPanel, IDisposable
|
||||
{
|
||||
private readonly LogTailer _tailer;
|
||||
private ILogSource _source;
|
||||
private readonly TextBlock _path;
|
||||
private readonly LogPanel _panel;
|
||||
|
||||
public ServiceEntry Entry { get; }
|
||||
public LogTarget Target { get; }
|
||||
|
||||
/// <summary>인코딩을 바꾸면 설정 저장용으로 호출됨</summary>
|
||||
public event Action EntryChanged;
|
||||
public event Action TargetChanged;
|
||||
|
||||
public LogTailView(ServiceEntry entry)
|
||||
public LogTailView(LogTarget target)
|
||||
{
|
||||
Entry = entry;
|
||||
Target = target;
|
||||
LastChildFill = true;
|
||||
|
||||
// ── 상단 정보줄
|
||||
var bar = new Border
|
||||
{
|
||||
Background = Brushes2.Make("#F1F8FB"),
|
||||
Background = Brushes2.Make(target.IsLocal ? "#F1F8FB" : "#F3E5F5"),
|
||||
BorderBrush = Brushes2.Make("#B0BEC5"),
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(6, 4, 6, 4),
|
||||
|
|
@ -46,18 +89,18 @@ namespace JJServiceManager
|
|||
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = entry.DisplayName,
|
||||
Text = target.Title,
|
||||
FontWeight = FontWeights.Bold,
|
||||
FontSize = 14,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
Foreground = Brushes2.Make("#155E75")
|
||||
Foreground = Brushes2.Make(target.IsLocal ? "#155E75" : "#6A1B9A")
|
||||
};
|
||||
Grid.SetColumn(title, 0);
|
||||
|
||||
_path = new TextBlock
|
||||
{
|
||||
Text = (entry.LogIsFolder ? "폴더: " : "파일: ") + entry.LogPath,
|
||||
Text = target.PathText(),
|
||||
Foreground = Brushes.Gray,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
|
|
@ -71,18 +114,21 @@ namespace JJServiceManager
|
|||
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 };
|
||||
enc.SelectedIndex = (target.Encoding ?? "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);
|
||||
|
||||
if (target.IsLocal)
|
||||
{
|
||||
var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) };
|
||||
Grid.SetColumn(open, 4);
|
||||
open.Click += (s, e) => OpenInExplorer();
|
||||
grid.Children.Add(open);
|
||||
}
|
||||
bar.Child = grid;
|
||||
Children.Add(bar);
|
||||
|
||||
|
|
@ -90,48 +136,57 @@ namespace JJServiceManager
|
|||
_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;
|
||||
}));
|
||||
CreateSource();
|
||||
|
||||
enc.SelectionChanged += (s, e) =>
|
||||
{
|
||||
if (enc.SelectedItem is ComboBoxItem item)
|
||||
{
|
||||
entry.LogEncoding = (string)item.Tag;
|
||||
EntryChanged?.Invoke();
|
||||
target.Encoding = (string)item.Tag;
|
||||
TargetChanged?.Invoke();
|
||||
_panel.Add("──────── 인코딩 변경: " + item.Content + " ────────", LogLevel.Notice);
|
||||
_tailer.Reload();
|
||||
_source.Reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_tailer.Start();
|
||||
private void CreateSource()
|
||||
{
|
||||
_source = Target.CreateSource(Target, (lines, level) => _panel.AddMany(lines, level));
|
||||
_source.FileChanged += f => Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var e = Target.LocalEntry;
|
||||
_path.Text = e != null && e.LogIsFolder
|
||||
? $"폴더: {e.LogPath} → {Path.GetFileName(f)}"
|
||||
: (Target.IsLocal ? "파일: " : "대상 PC 파일: ") + f;
|
||||
_path.ToolTip = f;
|
||||
}));
|
||||
_source.Start();
|
||||
}
|
||||
|
||||
/// <summary>로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음</summary>
|
||||
public void PathChanged()
|
||||
{
|
||||
_path.Text = (Entry.LogIsFolder ? "폴더: " : "파일: ") + Entry.LogPath;
|
||||
_path.Text = Target.PathText();
|
||||
_path.ToolTip = null;
|
||||
_panel.Add("──────── 로그 경로 변경: " + Entry.LogPath + " ────────", LogLevel.Notice);
|
||||
_tailer.Reload();
|
||||
_panel.Add("──────── 로그 경로 변경: " + Target.PathText() + " ────────", LogLevel.Notice);
|
||||
_source.Reload();
|
||||
}
|
||||
|
||||
private void OpenInExplorer()
|
||||
{
|
||||
var entry = Target.LocalEntry;
|
||||
if (entry == null) return;
|
||||
try
|
||||
{
|
||||
var f = _tailer.CurrentFile;
|
||||
var f = _source.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 if (Directory.Exists(entry.LogPath))
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{entry.LogPath}\"") { UseShellExecute = true });
|
||||
else
|
||||
{
|
||||
var dir = Path.GetDirectoryName(Entry.LogPath);
|
||||
var dir = Path.GetDirectoryName(entry.LogPath);
|
||||
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true });
|
||||
}
|
||||
|
|
@ -139,7 +194,7 @@ namespace JJServiceManager
|
|||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose() => _tailer.Dispose();
|
||||
public void Dispose() => _source?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -150,6 +205,7 @@ namespace JJServiceManager
|
|||
{
|
||||
private readonly Func<AppSettings> _settings;
|
||||
private readonly Action _save;
|
||||
private readonly Func<List<LogTarget>> _choices;
|
||||
|
||||
// 개별 창
|
||||
private readonly Dictionary<string, Window> _individual = new Dictionary<string, Window>(StringComparer.OrdinalIgnoreCase);
|
||||
|
|
@ -162,52 +218,54 @@ namespace JJServiceManager
|
|||
private Window _splitWindow;
|
||||
private SplitPane _left, _right;
|
||||
|
||||
public LogWindowManager(Func<AppSettings> settings, Action save)
|
||||
/// <param name="choices">좌우 분할 창에서 고를 수 있는 로그 목록</param>
|
||||
public LogWindowManager(Func<AppSettings> settings, Action save, Func<List<LogTarget>> choices)
|
||||
{
|
||||
_settings = settings;
|
||||
_save = save;
|
||||
_choices = choices;
|
||||
}
|
||||
|
||||
public void Open(ServiceEntry entry)
|
||||
public void Open(LogTarget target)
|
||||
{
|
||||
switch (_settings().LogWindowMode)
|
||||
{
|
||||
case LogWindowMode.Tabs: OpenTab(entry); break;
|
||||
case LogWindowMode.Split: OpenSplit(entry); break;
|
||||
default: OpenIndividual(entry); break;
|
||||
case LogWindowMode.Tabs: OpenTab(target); break;
|
||||
case LogWindowMode.Split: OpenSplit(target); break;
|
||||
default: OpenIndividual(target); break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서비스 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신)</summary>
|
||||
public void ServicesChanged()
|
||||
/// <summary>목록이 바뀌었을 때 (분할 창의 선택 목록 갱신)</summary>
|
||||
public void ChoicesChanged()
|
||||
{
|
||||
_left?.RefreshChoices(LogServices());
|
||||
_right?.RefreshChoices(LogServices());
|
||||
if (_left == null) return;
|
||||
var list = _choices();
|
||||
_left.RefreshChoices(list);
|
||||
_right.RefreshChoices(list);
|
||||
}
|
||||
|
||||
/// <summary>서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 서비스의 로그창을 닫음</summary>
|
||||
public void EntryPathChanged(ServiceEntry entry)
|
||||
/// <summary>내 PC 서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 로그창을 닫음</summary>
|
||||
public void LocalPathChanged(ServiceEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.LogPath))
|
||||
{
|
||||
Close(entry.ServiceName);
|
||||
ServicesChanged();
|
||||
return;
|
||||
}
|
||||
foreach (var v in OpenViews(entry.ServiceName)) v.PathChanged();
|
||||
ServicesChanged();
|
||||
var key = "local|" + entry.ServiceName;
|
||||
if (string.IsNullOrWhiteSpace(entry.LogPath)) Close(key);
|
||||
else foreach (var v in OpenViews(key)) v.PathChanged();
|
||||
ChoicesChanged();
|
||||
}
|
||||
|
||||
/// <summary>서비스가 목록에서 빠졌을 때 그 서비스의 로그창을 닫음</summary>
|
||||
public void Close(string serviceName)
|
||||
/// <summary>키가 같은 로그창을 닫음. prefix=true면 키가 그 문자열로 시작하는 것 모두</summary>
|
||||
public void Close(string key, bool prefix = false)
|
||||
{
|
||||
if (_individual.TryGetValue(serviceName, out var w)) w.Close();
|
||||
bool Match(string k) => prefix ? k.StartsWith(key, StringComparison.OrdinalIgnoreCase) : Same(k, key);
|
||||
|
||||
foreach (var k in _individual.Keys.Where(Match).ToList()) _individual[k].Close();
|
||||
|
||||
if (_tabs != null)
|
||||
{
|
||||
foreach (var t in _tabs.Items.Cast<TabItem>().ToList())
|
||||
{
|
||||
if (t.Content is LogTailView v && Same(v.Entry.ServiceName, serviceName))
|
||||
if (t.Content is LogTailView v && Match(v.Target.Key))
|
||||
{
|
||||
v.Dispose();
|
||||
_tabs.Items.Remove(t);
|
||||
|
|
@ -216,19 +274,19 @@ namespace JJServiceManager
|
|||
if (_tabs.Items.Count == 0) _tabWindow?.Close();
|
||||
}
|
||||
|
||||
if (_left != null && _left.ShowsName(serviceName)) _left.Clear();
|
||||
if (_right != null && _right.ShowsName(serviceName)) _right.Clear();
|
||||
if (_left?.View != null && Match(_left.View.Target.Key)) _left.Clear();
|
||||
if (_right?.View != null && Match(_right.View.Target.Key)) _right.Clear();
|
||||
}
|
||||
|
||||
private IEnumerable<LogTailView> OpenViews(string serviceName)
|
||||
private IEnumerable<LogTailView> OpenViews(string key)
|
||||
{
|
||||
if (_individual.TryGetValue(serviceName, out var w) && w.Content is Border b && b.Child is LogTailView iv)
|
||||
if (_individual.TryGetValue(key, 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;
|
||||
if (t.Content is LogTailView tv && Same(tv.Target.Key, key)) yield return tv;
|
||||
if (_left?.View != null && Same(_left.View.Target.Key, key)) yield return _left.View;
|
||||
if (_right?.View != null && Same(_right.View.Target.Key, key)) yield return _right.View;
|
||||
}
|
||||
|
||||
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
|
|
@ -240,13 +298,10 @@ namespace JJServiceManager
|
|||
_splitWindow?.Close();
|
||||
}
|
||||
|
||||
private List<ServiceEntry> LogServices()
|
||||
=> _settings().Services.Where(s => !string.IsNullOrWhiteSpace(s.LogPath)).ToList();
|
||||
|
||||
private LogTailView NewView(ServiceEntry entry)
|
||||
private LogTailView NewView(LogTarget target)
|
||||
{
|
||||
var v = new LogTailView(entry);
|
||||
v.EntryChanged += _save;
|
||||
var v = new LogTailView(target);
|
||||
if (target.IsLocal) v.TargetChanged += _save;
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +309,7 @@ namespace JJServiceManager
|
|||
{
|
||||
var win = new Window
|
||||
{
|
||||
Title = title,
|
||||
Title = title + AppInstance.TitleTag,
|
||||
Content = content,
|
||||
Width = w,
|
||||
Height = h,
|
||||
|
|
@ -276,32 +331,32 @@ namespace JJServiceManager
|
|||
|
||||
// ───────────────────────── 1. 개별 창 ─────────────────────────
|
||||
|
||||
private void OpenIndividual(ServiceEntry entry)
|
||||
private void OpenIndividual(LogTarget target)
|
||||
{
|
||||
if (_individual.TryGetValue(entry.ServiceName, out var existing))
|
||||
if (_individual.TryGetValue(target.Key, out var existing))
|
||||
{
|
||||
Activate(existing);
|
||||
return;
|
||||
}
|
||||
var view = NewView(entry);
|
||||
var win = NewWindow($"로그 - {entry.DisplayName}", new Border { Padding = new Thickness(6), Child = view });
|
||||
var view = NewView(target);
|
||||
var win = NewWindow($"로그 - {target.Title}", new Border { Padding = new Thickness(6), Child = view });
|
||||
win.Closed += (s, e) =>
|
||||
{
|
||||
view.Dispose();
|
||||
_individual.Remove(entry.ServiceName);
|
||||
_individual.Remove(target.Key);
|
||||
};
|
||||
_individual[entry.ServiceName] = win;
|
||||
_individual[target.Key] = 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);
|
||||
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)
|
||||
private void OpenTab(LogTarget target)
|
||||
{
|
||||
if (_tabWindow == null)
|
||||
{
|
||||
|
|
@ -318,7 +373,7 @@ namespace JJServiceManager
|
|||
|
||||
foreach (TabItem t in _tabs.Items)
|
||||
{
|
||||
if (t.Content is LogTailView v && string.Equals(v.Entry.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase))
|
||||
if (t.Content is LogTailView v && Same(v.Target.Key, target.Key))
|
||||
{
|
||||
_tabs.SelectedItem = t;
|
||||
Activate(_tabWindow);
|
||||
|
|
@ -326,11 +381,26 @@ namespace JJServiceManager
|
|||
}
|
||||
}
|
||||
|
||||
var view = NewView(entry);
|
||||
var view = NewView(target);
|
||||
var tab = new TabItem { Content = view };
|
||||
tab.Header = TabHeader(target.Title, () =>
|
||||
{
|
||||
view.Dispose();
|
||||
_tabs.Items.Remove(tab);
|
||||
if (_tabs.Items.Count == 0) _tabWindow.Close();
|
||||
});
|
||||
|
||||
_tabs.Items.Add(tab);
|
||||
_tabs.SelectedItem = tab;
|
||||
Activate(_tabWindow);
|
||||
}
|
||||
|
||||
/// <summary>제목 + ✕ 닫기 버튼이 있는 탭 머리글</summary>
|
||||
public static StackPanel TabHeader(object title, Action onClose)
|
||||
{
|
||||
var header = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
header.Children.Add(new TextBlock { Text = entry.DisplayName, VerticalAlignment = VerticalAlignment.Center });
|
||||
if (title is UIElement el) header.Children.Add(el);
|
||||
else header.Children.Add(new TextBlock { Text = title?.ToString() ?? "", VerticalAlignment = VerticalAlignment.Center });
|
||||
var close = new Button
|
||||
{
|
||||
Content = "✕",
|
||||
|
|
@ -343,23 +413,14 @@ namespace JJServiceManager
|
|||
Background = Brushes.Transparent,
|
||||
ToolTip = "탭 닫기"
|
||||
};
|
||||
close.Click += (s, e) =>
|
||||
{
|
||||
view.Dispose();
|
||||
_tabs.Items.Remove(tab);
|
||||
if (_tabs.Items.Count == 0) _tabWindow.Close();
|
||||
};
|
||||
close.Click += (s, e) => onClose();
|
||||
header.Children.Add(close);
|
||||
tab.Header = header;
|
||||
|
||||
_tabs.Items.Add(tab);
|
||||
_tabs.SelectedItem = tab;
|
||||
Activate(_tabWindow);
|
||||
return header;
|
||||
}
|
||||
|
||||
// ───────────────────────── 3. 좌우 분할 ─────────────────────────
|
||||
|
||||
private void OpenSplit(ServiceEntry entry)
|
||||
private void OpenSplit(LogTarget target)
|
||||
{
|
||||
if (_splitWindow == null)
|
||||
{
|
||||
|
|
@ -370,8 +431,9 @@ namespace JJServiceManager
|
|||
|
||||
_left = new SplitPane(NewView, "왼쪽");
|
||||
_right = new SplitPane(NewView, "오른쪽");
|
||||
_left.RefreshChoices(LogServices());
|
||||
_right.RefreshChoices(LogServices());
|
||||
var list = _choices();
|
||||
_left.RefreshChoices(list);
|
||||
_right.RefreshChoices(list);
|
||||
|
||||
var splitter = new GridSplitter
|
||||
{
|
||||
|
|
@ -398,11 +460,11 @@ namespace JJServiceManager
|
|||
};
|
||||
}
|
||||
|
||||
if (!_left.Shows(entry) && !_right.Shows(entry))
|
||||
if (!_left.Shows(target.Key) && !_right.Shows(target.Key))
|
||||
{
|
||||
if (_left.IsEmpty) _left.Show(entry);
|
||||
else if (_right.IsEmpty) _right.Show(entry);
|
||||
else _right.Show(entry);
|
||||
if (_left.IsEmpty) _left.Show(target);
|
||||
else if (_right.IsEmpty) _right.Show(target);
|
||||
else _right.Show(target);
|
||||
}
|
||||
Activate(_splitWindow);
|
||||
}
|
||||
|
|
@ -410,18 +472,17 @@ namespace JJServiceManager
|
|||
/// <summary>좌우 분할 창의 한쪽</summary>
|
||||
private sealed class SplitPane : DockPanel, IDisposable
|
||||
{
|
||||
private readonly Func<ServiceEntry, LogTailView> _factory;
|
||||
private readonly Func<LogTarget, LogTailView> _factory;
|
||||
private readonly ComboBox _combo;
|
||||
private readonly ContentControl _host;
|
||||
private readonly UIElement _placeholder;
|
||||
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)
|
||||
public SplitPane(Func<LogTarget, LogTailView> factory, string side)
|
||||
{
|
||||
_factory = factory;
|
||||
|
||||
|
|
@ -429,7 +490,7 @@ namespace JJServiceManager
|
|||
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 };
|
||||
_combo = new ComboBox { DisplayMemberPath = nameof(LogTarget.Title), MinWidth = 200 };
|
||||
top.Children.Add(lbl);
|
||||
top.Children.Add(_combo);
|
||||
Children.Add(top);
|
||||
|
|
@ -447,14 +508,12 @@ namespace JJServiceManager
|
|||
_combo.SelectionChanged += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
if (_combo.SelectedItem is ServiceEntry se) Show(se);
|
||||
if (_combo.SelectedItem is LogTarget t) Show(t);
|
||||
};
|
||||
}
|
||||
|
||||
public bool Shows(ServiceEntry e) => ShowsName(e.ServiceName);
|
||||
|
||||
public bool ShowsName(string serviceName)
|
||||
=> _view != null && string.Equals(_view.Entry.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase);
|
||||
public bool Shows(string key)
|
||||
=> _view != null && string.Equals(_view.Target.Key, key, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
|
|
@ -466,27 +525,27 @@ namespace JJServiceManager
|
|||
_updating = false;
|
||||
}
|
||||
|
||||
public void Show(ServiceEntry entry)
|
||||
public void Show(LogTarget target)
|
||||
{
|
||||
_view?.Dispose();
|
||||
_view = _factory(entry);
|
||||
_view = _factory(target);
|
||||
_host.Content = _view;
|
||||
SelectInCombo(entry);
|
||||
SelectInCombo(target.Key);
|
||||
}
|
||||
|
||||
public void RefreshChoices(List<ServiceEntry> services)
|
||||
public void RefreshChoices(List<LogTarget> list)
|
||||
{
|
||||
_updating = true;
|
||||
_combo.ItemsSource = services;
|
||||
_combo.ItemsSource = list;
|
||||
_updating = false;
|
||||
if (_view != null) SelectInCombo(_view.Entry);
|
||||
if (_view != null) SelectInCombo(_view.Target.Key);
|
||||
}
|
||||
|
||||
private void SelectInCombo(ServiceEntry entry)
|
||||
private void SelectInCombo(string key)
|
||||
{
|
||||
_updating = true;
|
||||
_combo.SelectedItem = (_combo.ItemsSource as IEnumerable<ServiceEntry>)?
|
||||
.FirstOrDefault(x => string.Equals(x.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase));
|
||||
_combo.SelectedItem = (_combo.ItemsSource as IEnumerable<LogTarget>)?
|
||||
.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
_updating = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,60 @@ namespace JJServiceManager
|
|||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// [테스트용 중복 실행] --instance N 옵션 — 정식 릴리즈 시 이 클래스와 사용하는 곳을 삭제
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
public static class AppInstance
|
||||
{
|
||||
/// <summary>1 = 기본 실행, 2 이상 = 테스트용 추가 실행</summary>
|
||||
public static int Number { get; private set; } = 1;
|
||||
|
||||
public static bool IsTest => Number > 1;
|
||||
|
||||
/// <summary>이름 뒤에 붙는 꼬리표 ("" 또는 "_instance2")</summary>
|
||||
public static string Suffix => IsTest ? "_instance" + Number : "";
|
||||
|
||||
/// <summary>창 제목에 붙는 표시</summary>
|
||||
public static string TitleTag => IsTest ? $" [테스트 인스턴스 {Number}]" : "";
|
||||
|
||||
public static void Parse(string[] args)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "--instance", StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(args[i + 1], out int n) && n >= 1 && n <= 9)
|
||||
{
|
||||
Number = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원격 통신 방식</summary>
|
||||
public enum RemoteMode
|
||||
{
|
||||
Ssh = 0, // 내장 SSH (기본 2222)
|
||||
Port = 1 // 지정 포트 + TLS (기본 9988)
|
||||
}
|
||||
|
||||
/// <summary>관리 대상 PC</summary>
|
||||
public class RemoteTarget
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Address { get; set; } = "";
|
||||
/// <summary>접속할 때 자동으로 채워짐</summary>
|
||||
public string Hostname { get; set; } = "";
|
||||
public RemoteMode Mode { get; set; } = RemoteMode.Port;
|
||||
public int Port { get; set; } = 9988;
|
||||
/// <summary>접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64)</summary>
|
||||
public string ProtectedPassword { get; set; } = "";
|
||||
|
||||
[JsonIgnore]
|
||||
public string Label => string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}";
|
||||
|
||||
public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>로그창 띄우기 방식</summary>
|
||||
public enum LogWindowMode
|
||||
{
|
||||
|
|
@ -85,6 +139,35 @@ namespace JJServiceManager
|
|||
/// <summary>Windows 강제종료 / 재시작 버튼 표시</summary>
|
||||
public bool ShowShutdownButtons { get; set; } = true;
|
||||
|
||||
// ───────── 숫자 6자리 확인 ─────────
|
||||
|
||||
/// <summary>내 PC 서비스 Start/Stop 시 숫자 확인</summary>
|
||||
public bool ConfirmLocalStartStop { get; set; } = false;
|
||||
|
||||
/// <summary>원격 서비스 Start/Stop 시 숫자 확인</summary>
|
||||
public bool ConfirmRemoteStartStop { get; set; } = true;
|
||||
|
||||
// ───────── 원격 접속 허용 (대상 역할) ─────────
|
||||
|
||||
public bool RemoteServerEnabled { get; set; }
|
||||
public RemoteMode RemoteServerMode { get; set; } = RemoteMode.Port;
|
||||
public int RemoteServerTcpPort { get; set; } = 9988 + AppInstance.Number - 1;
|
||||
public int RemoteServerSshPort { get; set; } = 2222 + AppInstance.Number - 1;
|
||||
/// <summary>접속 비밀번호 해시 (PBKDF2)</summary>
|
||||
public string RemotePasswordHash { get; set; } = "";
|
||||
public string RemotePasswordSalt { get; set; } = "";
|
||||
|
||||
[JsonIgnore]
|
||||
public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort;
|
||||
|
||||
// ───────── 다른 PC 관리 (관리 역할) ─────────
|
||||
|
||||
public bool RemoteManageEnabled { get; set; }
|
||||
public List<RemoteTarget> Targets { get; set; } = new List<RemoteTarget>();
|
||||
|
||||
/// <summary>초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id)</summary>
|
||||
public List<string> OpenHostTabs { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>창 크기·위치</summary>
|
||||
public double WindowLeft { get; set; } = double.NaN;
|
||||
public double WindowTop { get; set; } = double.NaN;
|
||||
|
|
@ -95,8 +178,9 @@ namespace JJServiceManager
|
|||
/// <summary>설정 파일 저장/불러오기</summary>
|
||||
public static class SettingsStore
|
||||
{
|
||||
public static string DataFolder { get; } =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager");
|
||||
// [테스트용 중복 실행] 인스턴스마다 설정 폴더를 따로 씀 (정식 릴리즈 시 Suffix 제거)
|
||||
public static string DataFolder =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager" + AppInstance.Suffix);
|
||||
|
||||
public static string SettingsPath => Path.Combine(DataFolder, "settings.json");
|
||||
|
||||
|
|
@ -118,6 +202,8 @@ namespace JJServiceManager
|
|||
if (s != null)
|
||||
{
|
||||
s.Services ??= new List<ServiceEntry>();
|
||||
s.Targets ??= new List<RemoteTarget>();
|
||||
s.OpenHostTabs ??= new List<string>();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,486 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.DevTunnels.Ssh;
|
||||
using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
|
||||
|
||||
namespace JJServiceManager.Remote
|
||||
{
|
||||
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
|
||||
|
||||
/// <summary>
|
||||
/// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴.
|
||||
/// 끊기면 자동으로 다시 접속함.
|
||||
/// </summary>
|
||||
public sealed class RemoteClient : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly RemoteTarget _target;
|
||||
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonObject>> _pending = new ConcurrentDictionary<int, TaskCompletionSource<JsonObject>>();
|
||||
private readonly ConcurrentDictionary<int, LogSubscriber> _logs = new ConcurrentDictionary<int, LogSubscriber>();
|
||||
private JsonLineConnection _conn;
|
||||
private int _nextId;
|
||||
|
||||
private sealed class LogSubscriber
|
||||
{
|
||||
public Action<IList<string>, LogLevel> OnLines;
|
||||
public Action<string> OnFile;
|
||||
}
|
||||
|
||||
public RemoteTarget Target => _target;
|
||||
public RemoteState State { get; private set; } = RemoteState.Connecting;
|
||||
public string StateMessage { get; private set; } = "연결 중…";
|
||||
public string Hostname { get; private set; } = "";
|
||||
public List<RemoteServiceInfo> Services { get; private set; } = new List<RemoteServiceInfo>();
|
||||
|
||||
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
|
||||
public event Action<RemoteClient> StateChanged;
|
||||
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
|
||||
public event Action<RemoteClient> ServicesChanged;
|
||||
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
|
||||
public event Action<RemoteClient, string> HostnameDiscovered;
|
||||
|
||||
private readonly Func<string> _password;
|
||||
|
||||
public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null)
|
||||
{
|
||||
_target = target.Clone();
|
||||
Hostname = target.Hostname ?? "";
|
||||
var prot = _target.ProtectedPassword;
|
||||
_password = passwordProvider ?? (() => RemoteSecurity.Unprotect(prot));
|
||||
}
|
||||
|
||||
public void Start() => _ = Task.Run(RunAsync);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _cts.Cancel(); } catch { }
|
||||
_conn?.Dispose();
|
||||
}
|
||||
|
||||
// ───────────────────────── 접속 유지 ─────────────────────────
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
SetState(RemoteState.Connecting, "연결 중…");
|
||||
TimeSpan retry = TimeSpan.FromSeconds(5);
|
||||
try
|
||||
{
|
||||
var password = _password();
|
||||
var (conn, hello) = await ConnectAsync(_target, password, ct).ConfigureAwait(false);
|
||||
_conn = conn;
|
||||
|
||||
var host = Json.Str(hello, "hostname");
|
||||
if (!string.IsNullOrEmpty(host) && host != Hostname)
|
||||
{
|
||||
Hostname = host;
|
||||
HostnameDiscovered?.Invoke(this, host);
|
||||
}
|
||||
SetState(RemoteState.Connected, "연결됨");
|
||||
|
||||
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
|
||||
// 목록 주기적으로 받기
|
||||
while (!ct.IsCancellationRequested && !reader.IsCompleted)
|
||||
{
|
||||
var res = await RequestAsync("list", null, TimeSpan.FromSeconds(15)).ConfigureAwait(false);
|
||||
Services = ParseServices(res);
|
||||
ServicesChanged?.Invoke(this);
|
||||
await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false);
|
||||
}
|
||||
if (reader.IsFaulted) throw reader.Exception.GetBaseException();
|
||||
throw new RemoteException("연결이 끊겼습니다.");
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (RemoteAuthException ex)
|
||||
{
|
||||
SetState(RemoteState.AuthFailed, ex.Message);
|
||||
retry = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetState(RemoteState.Disconnected, Friendly(ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
var c = _conn;
|
||||
_conn = null;
|
||||
c?.Dispose();
|
||||
FailPending("연결이 끊겼습니다.");
|
||||
_logs.Clear();
|
||||
}
|
||||
|
||||
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var msg = await conn.ReadAsync(ct).ConfigureAwait(false);
|
||||
if (msg == null) return;
|
||||
|
||||
if (msg.ContainsKey("event"))
|
||||
{
|
||||
var ev = Json.Str(msg, "event");
|
||||
int sid = Json.Int(msg, "stream");
|
||||
if (!_logs.TryGetValue(sid, out var sub)) continue;
|
||||
if (ev == "log")
|
||||
{
|
||||
var lines = (msg["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List<string>();
|
||||
Enum.TryParse<LogLevel>(Json.Str(msg, "level"), out var level);
|
||||
sub.OnLines?.Invoke(lines, level);
|
||||
}
|
||||
else if (ev == "logfile")
|
||||
{
|
||||
sub.OnFile?.Invoke(Json.Str(msg, "path"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
int id = Json.Int(msg, "id");
|
||||
if (_pending.TryRemove(id, out var tcs)) tcs.TrySetResult(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetState(RemoteState state, string message)
|
||||
{
|
||||
bool changed = State != state || StateMessage != message;
|
||||
State = state;
|
||||
StateMessage = message;
|
||||
if (changed) StateChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
private void FailPending(string message)
|
||||
{
|
||||
foreach (var kv in _pending.ToArray())
|
||||
if (_pending.TryRemove(kv.Key, out var tcs))
|
||||
tcs.TrySetException(new RemoteException(message));
|
||||
}
|
||||
|
||||
// ───────────────────────── 요청 ─────────────────────────
|
||||
|
||||
/// <summary>요청을 보내고 결과(result)를 받음. 실패하면 RemoteException</summary>
|
||||
public async Task<JsonObject> RequestAsync(string op, JsonObject args, TimeSpan timeout)
|
||||
{
|
||||
var conn = _conn;
|
||||
if (conn == null || State != RemoteState.Connected && op != "list")
|
||||
throw new RemoteException("대상 PC에 연결되어 있지 않습니다.");
|
||||
|
||||
int id = Interlocked.Increment(ref _nextId);
|
||||
var msg = args != null ? (JsonObject)args.DeepClone() : new JsonObject();
|
||||
msg["id"] = id;
|
||||
msg["op"] = op;
|
||||
|
||||
var tcs = new TaskCompletionSource<JsonObject>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = tcs;
|
||||
try
|
||||
{
|
||||
await conn.SendAsync(msg).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
throw new RemoteException("보내기 실패: " + ex.Message);
|
||||
}
|
||||
|
||||
var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (done != tcs.Task)
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
throw new RemoteException("대상 PC의 응답이 없습니다.");
|
||||
}
|
||||
|
||||
var reply = await tcs.Task.ConfigureAwait(false);
|
||||
if (!Json.Bool(reply, "ok"))
|
||||
throw new RemoteException(Json.Str(reply, "error"), Json.Bool(reply, "info"));
|
||||
return reply["result"] as JsonObject ?? new JsonObject();
|
||||
}
|
||||
|
||||
public Task<JsonObject> StartServiceAsync(string service)
|
||||
=> RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
|
||||
|
||||
public Task<JsonObject> StopServiceAsync(string service)
|
||||
=> RequestAsync("stop", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
|
||||
|
||||
public async Task<List<string>> GetDetailsAsync(string service)
|
||||
{
|
||||
var r = await RequestAsync("details", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(20)).ConfigureAwait(false);
|
||||
return (r["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>대상 PC의 서비스 로그를 실시간으로 받기 시작. 돌려받은 번호로 CloseLog</summary>
|
||||
public async Task<int> OpenLogAsync(string service, string encoding, Action<IList<string>, LogLevel> onLines, Action<string> onFile, bool newOnly = false)
|
||||
{
|
||||
var r = await RequestAsync("logOpen", new JsonObject { ["service"] = service, ["encoding"] = encoding ?? "auto", ["newOnly"] = newOnly }, TimeSpan.FromSeconds(20)).ConfigureAwait(false);
|
||||
int sid = Json.Int(r, "stream");
|
||||
_logs[sid] = new LogSubscriber { OnLines = onLines, OnFile = onFile };
|
||||
return sid;
|
||||
}
|
||||
|
||||
public void CloseLog(int stream)
|
||||
{
|
||||
if (stream <= 0) return;
|
||||
_logs.TryRemove(stream, out _);
|
||||
if (State == RemoteState.Connected)
|
||||
_ = RequestAsync("logClose", new JsonObject { ["stream"] = stream }, TimeSpan.FromSeconds(10)).ContinueWith(t => { _ = t.Exception; });
|
||||
}
|
||||
|
||||
// ───────────────────────── 연결 ─────────────────────────
|
||||
|
||||
/// <summary>접속 + 인증 + hello. 성공하면 연결과 hello 결과</summary>
|
||||
public static async Task<(JsonLineConnection conn, JsonObject hello)> ConnectAsync(RemoteTarget t, string password, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(t.Address)) throw new RemoteException("대상 IP가 비어 있습니다.");
|
||||
if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("접속 비밀번호가 저장되어 있지 않습니다.");
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(ConnectTimeout);
|
||||
var tok = timeout.Token;
|
||||
|
||||
JsonLineConnection conn;
|
||||
bool sendPassword;
|
||||
|
||||
try
|
||||
{
|
||||
if (t.Mode == RemoteMode.Port)
|
||||
{
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await tcp.ConnectAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false);
|
||||
var ssl = new SslStream(tcp.GetStream(), false); // 암호화만, 서버 지문 확인 안 함 (아래 옵션)
|
||||
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
|
||||
{
|
||||
TargetHost = "JJServiceManager",
|
||||
RemoteCertificateValidationCallback = (a, b, c, d) => true
|
||||
}, tok).ConfigureAwait(false);
|
||||
conn = new JsonLineConnection(ssl, tcp);
|
||||
}
|
||||
catch
|
||||
{
|
||||
tcp.Dispose();
|
||||
throw;
|
||||
}
|
||||
sendPassword = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient"));
|
||||
try
|
||||
{
|
||||
var session = await client.OpenSessionAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false);
|
||||
// 서버 지문 확인 안 함 (설정: 확인 없이 암호화만)
|
||||
session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal());
|
||||
bool ok = await session.AuthenticateAsync(new SshClientCredentials(RemoteServer.SshUser, password), tok).ConfigureAwait(false);
|
||||
if (!ok)
|
||||
{
|
||||
session.Dispose();
|
||||
throw new RemoteAuthException("접속 비밀번호가 맞지 않습니다.");
|
||||
}
|
||||
var channel = await session.OpenChannelAsync(tok).ConfigureAwait(false);
|
||||
conn = new JsonLineConnection(new SshStream(channel), session, client);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
sendPassword = false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hello = new JsonObject
|
||||
{
|
||||
["id"] = 0,
|
||||
["op"] = "hello",
|
||||
["client"] = Environment.MachineName,
|
||||
["version"] = RemoteServer.Version
|
||||
};
|
||||
if (sendPassword) hello["password"] = password;
|
||||
await conn.SendAsync(hello, tok).ConfigureAwait(false);
|
||||
|
||||
var reply = await conn.ReadAsync(tok).ConfigureAwait(false);
|
||||
if (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다.");
|
||||
if (!Json.Bool(reply, "ok"))
|
||||
{
|
||||
var err = Json.Str(reply, "error");
|
||||
if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err);
|
||||
throw new RemoteException(err);
|
||||
}
|
||||
return (conn, reply["result"] as JsonObject ?? new JsonObject());
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
conn.Dispose();
|
||||
throw new RemoteException("대상 PC의 응답이 없습니다. (방식(SSH/지정 포트)이 맞는지 확인)");
|
||||
}
|
||||
catch
|
||||
{
|
||||
conn.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>설정 화면의 [연결 테스트]: 성공하면 hostname</summary>
|
||||
public static async Task<string> TestAsync(RemoteTarget t, string password)
|
||||
{
|
||||
var (conn, hello) = await ConnectAsync(t, password, CancellationToken.None).ConfigureAwait(false);
|
||||
conn.Dispose();
|
||||
return Json.Str(hello, "hostname");
|
||||
}
|
||||
|
||||
private static List<RemoteServiceInfo> ParseServices(JsonObject res)
|
||||
{
|
||||
var list = new List<RemoteServiceInfo>();
|
||||
if (res?["services"] is not JsonArray arr) return list;
|
||||
foreach (var n in arr.OfType<JsonObject>())
|
||||
{
|
||||
int? st = null;
|
||||
try { if (n["status"] != null) st = n["status"].GetValue<int>(); } catch { }
|
||||
list.Add(new RemoteServiceInfo
|
||||
{
|
||||
ServiceName = Json.Str(n, "name"),
|
||||
DisplayName = Json.Str(n, "display"),
|
||||
Status = st,
|
||||
LogPath = Json.Str(n, "logPath"),
|
||||
LogIsFolder = Json.Bool(n, "logIsFolder")
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static string Friendly(Exception ex)
|
||||
{
|
||||
ex = ex is AggregateException ae ? ae.GetBaseException() : ex;
|
||||
return ex switch
|
||||
{
|
||||
RemoteException re => re.Message,
|
||||
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
|
||||
=> "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)",
|
||||
SocketException se when se.SocketErrorCode == SocketError.HostNotFound
|
||||
=> "대상 PC를 찾을 수 없습니다. (IP 확인)",
|
||||
SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable
|
||||
=> "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
|
||||
System.Security.Authentication.AuthenticationException
|
||||
=> "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
|
||||
SshConnectionException
|
||||
=> "SSH 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
|
||||
System.IO.IOException io => "연결이 끊겼습니다. " + io.Message,
|
||||
_ => ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>인증 실패 (비밀번호 틀림 등)</summary>
|
||||
public sealed class RemoteAuthException : Exception
|
||||
{
|
||||
public RemoteAuthException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용)</summary>
|
||||
public sealed class RemoteLogSource : ILogSource
|
||||
{
|
||||
private readonly RemoteClient _client;
|
||||
private readonly string _service;
|
||||
private readonly Func<string> _encoding;
|
||||
private readonly Action<IList<string>, LogLevel> _output;
|
||||
private int _stream;
|
||||
private bool _disposed;
|
||||
private bool _wasConnected;
|
||||
|
||||
public event Action<string> FileChanged;
|
||||
public string CurrentFile { get; private set; }
|
||||
|
||||
public RemoteLogSource(RemoteClient client, string service, Func<string> encoding, Action<IList<string>, LogLevel> output)
|
||||
{
|
||||
_client = client;
|
||||
_service = service;
|
||||
_encoding = encoding;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_client.StateChanged += OnState;
|
||||
_wasConnected = _client.State == RemoteState.Connected;
|
||||
if (_wasConnected) _ = OpenAsync();
|
||||
else _output(new[] { "대상 PC에 연결되어 있지 않습니다. 연결되면 자동으로 표시합니다." }, LogLevel.Warning);
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
var s = Interlocked.Exchange(ref _stream, 0);
|
||||
_client.CloseLog(s);
|
||||
_ = OpenAsync();
|
||||
}
|
||||
|
||||
private async Task OpenAsync(bool newOnly = false)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
int sid = await _client.OpenLogAsync(_service, _encoding(), (l, lv) => { if (!_disposed) _output(l, lv); }, f =>
|
||||
{
|
||||
CurrentFile = f;
|
||||
FileChanged?.Invoke(f);
|
||||
}, newOnly).ConfigureAwait(false);
|
||||
if (_disposed) { _client.CloseLog(sid); return; }
|
||||
_stream = sid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_output(new[] { "원격 로그를 열 수 없습니다: " + RemoteClient.Friendly(ex) }, LogLevel.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnState(RemoteClient c)
|
||||
{
|
||||
if (_disposed) return;
|
||||
bool now = c.State == RemoteState.Connected;
|
||||
if (now && !_wasConnected)
|
||||
{
|
||||
_output(new[] { "──────── 대상 PC에 다시 연결됨 (이후 새 로그만 표시) ────────" }, LogLevel.Notice);
|
||||
_ = OpenAsync(newOnly: true);
|
||||
}
|
||||
else if (!now && _wasConnected)
|
||||
{
|
||||
_stream = 0;
|
||||
_output(new[] { "──────── 대상 PC 연결 끊김: " + c.StateMessage + " ────────" }, LogLevel.Warning);
|
||||
}
|
||||
_wasConnected = now;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_client.StateChanged -= OnState;
|
||||
var s = Interlocked.Exchange(ref _stream, 0);
|
||||
_client.CloseLog(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JJServiceManager.Remote
|
||||
{
|
||||
/// <summary>비밀번호 처리</summary>
|
||||
public static class RemoteSecurity
|
||||
{
|
||||
private const int Iterations = 120_000;
|
||||
|
||||
/// <summary>대상 PC: 접속 비밀번호를 해시로 저장</summary>
|
||||
public static (string hash, string salt) HashPassword(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32);
|
||||
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string hashB64, string saltB64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashB64) || string.IsNullOrEmpty(saltB64)) return false;
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(saltB64);
|
||||
var expected = Convert.FromBase64String(hashB64);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, expected.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>관리 PC: 대상 PC 비밀번호를 현재 Windows 사용자로 암호화해 저장</summary>
|
||||
public static string Protect(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password)) return "";
|
||||
var data = ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.CurrentUser);
|
||||
return Convert.ToBase64String(data);
|
||||
}
|
||||
|
||||
public static string Unprotect(string protectedB64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(protectedB64)) return "";
|
||||
try
|
||||
{
|
||||
var data = ProtectedData.Unprotect(Convert.FromBase64String(protectedB64), null, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 한 줄에 JSON 하나씩 주고받는 연결 (SSH 채널 / TLS 스트림 공통)
|
||||
/// </summary>
|
||||
public sealed class JsonLineConnection : IDisposable
|
||||
{
|
||||
private const int MaxLineChars = 4 * 1024 * 1024;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly StreamReader _reader;
|
||||
private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
|
||||
private readonly IDisposable[] _owned;
|
||||
private int _disposed;
|
||||
|
||||
public JsonLineConnection(Stream stream, params IDisposable[] owned)
|
||||
{
|
||||
_stream = stream;
|
||||
_reader = new StreamReader(stream, new UTF8Encoding(false), false, 16 * 1024, leaveOpen: true);
|
||||
_owned = owned ?? Array.Empty<IDisposable>();
|
||||
}
|
||||
|
||||
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
|
||||
|
||||
/// <summary>다음 메시지. 연결이 끊기면 null</summary>
|
||||
public async Task<JsonObject> ReadAsync(CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new char[1];
|
||||
while (true)
|
||||
{
|
||||
int n = await _reader.ReadAsync(buf.AsMemory(0, 1), ct).ConfigureAwait(false);
|
||||
if (n == 0) return null;
|
||||
char c = buf[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
var line = sb.ToString().Trim();
|
||||
sb.Clear();
|
||||
if (line.Length == 0) continue;
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(line) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new IOException("잘못된 메시지를 받았습니다.");
|
||||
}
|
||||
}
|
||||
sb.Append(c);
|
||||
if (sb.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAsync(JsonObject msg, CancellationToken ct = default)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(msg.ToJsonString() + "\n");
|
||||
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _reader.Dispose(); } catch { }
|
||||
try { _stream.Dispose(); } catch { }
|
||||
foreach (var d in _owned)
|
||||
{
|
||||
try { d?.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원격으로 받은 서비스 정보</summary>
|
||||
public sealed class RemoteServiceInfo
|
||||
{
|
||||
public string ServiceName { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
/// <summary>System.ServiceProcess.ServiceControllerStatus 값, 서비스가 없으면 null</summary>
|
||||
public int? Status { get; set; }
|
||||
public string LogPath { get; set; } = "";
|
||||
public bool LogIsFolder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>원격 명령 실패</summary>
|
||||
public sealed class RemoteException : Exception
|
||||
{
|
||||
/// <summary>오류가 아닌 안내 (이미 실행 중 등)</summary>
|
||||
public bool IsInfo { get; }
|
||||
public RemoteException(string message, bool isInfo = false) : base(message) { IsInfo = isInfo; }
|
||||
}
|
||||
|
||||
public static class NetInfo
|
||||
{
|
||||
/// <summary>이 PC의 IPv4 주소 목록 (설정 화면 안내용)</summary>
|
||||
public static List<string> LocalIPv4()
|
||||
{
|
||||
var list = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
if (ua.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
list.Add(ua.Address.ToString());
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return list.Distinct().ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows 방화벽 규칙 (원격 접속 허용 포트)
|
||||
/// 규칙 이름: "JJServiceManager Remote" (+ 테스트 인스턴스 꼬리표)
|
||||
/// </summary>
|
||||
public static class FirewallManager
|
||||
{
|
||||
private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix;
|
||||
|
||||
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
|
||||
public static (bool ok, string message) Apply(bool enabled, int port)
|
||||
{
|
||||
// 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음)
|
||||
Run($"advfirewall firewall delete rule name=\"{RuleName}\"");
|
||||
if (!enabled) return (true, "방화벽 규칙 삭제");
|
||||
|
||||
var r = Run($"advfirewall firewall add rule name=\"{RuleName}\" dir=in action=allow protocol=TCP localport={port} profile=any");
|
||||
return r.exit == 0
|
||||
? (true, $"방화벽 TCP {port} 허용")
|
||||
: (false, "방화벽 규칙 추가 실패: " + r.output);
|
||||
}
|
||||
|
||||
private static (int exit, string output) Run(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("netsh.exe", args)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = OemEncoding(),
|
||||
StandardErrorEncoding = OemEncoding()
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
|
||||
p.WaitForExit(15000);
|
||||
return (p.ExitCode, o.Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (-1, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding OemEncoding()
|
||||
{
|
||||
try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
|
||||
catch { return Encoding.UTF8; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class Json
|
||||
{
|
||||
public static string Str(JsonObject o, string key)
|
||||
{
|
||||
try { return o?[key]?.GetValue<string>() ?? ""; }
|
||||
catch { return o?[key]?.ToString() ?? ""; }
|
||||
}
|
||||
|
||||
public static int Int(JsonObject o, string key, int def = 0)
|
||||
{
|
||||
try { return o?[key]?.GetValue<int>() ?? def; }
|
||||
catch { return def; }
|
||||
}
|
||||
|
||||
public static bool Bool(JsonObject o, string key)
|
||||
{
|
||||
try { return o?[key]?.GetValue<bool>() ?? false; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.DevTunnels.Ssh;
|
||||
using Microsoft.DevTunnels.Ssh.Algorithms;
|
||||
using Microsoft.DevTunnels.Ssh.Events;
|
||||
using Microsoft.DevTunnels.Ssh.Messages;
|
||||
using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer;
|
||||
|
||||
namespace JJServiceManager.Remote
|
||||
{
|
||||
/// <summary>
|
||||
/// 원격 접속 허용 (대상 역할).
|
||||
/// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만
|
||||
/// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함.
|
||||
/// </summary>
|
||||
public sealed class RemoteServer : IDisposable
|
||||
{
|
||||
public const string Version = "1.1";
|
||||
public const string SshUser = "jjsm";
|
||||
|
||||
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly Func<IReadOnlyList<ServiceEntry>> _services;
|
||||
private readonly Action<string, LogLevel> _log;
|
||||
private readonly Action _changed;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private CancellationTokenSource _cts;
|
||||
private TcpListener _listener;
|
||||
private X509Certificate2 _cert;
|
||||
private SshServerTcp _ssh;
|
||||
private string _hash = "", _salt = "";
|
||||
private readonly Queue<DateTime> _failures = new Queue<DateTime>();
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
public RemoteMode Mode { get; private set; }
|
||||
public int Port { get; private set; }
|
||||
|
||||
/// <param name="services">등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)</param>
|
||||
/// <param name="log">작업 로그 기록</param>
|
||||
/// <param name="changed">원격에서 Start/Stop 했을 때 (상태 갱신용)</param>
|
||||
public RemoteServer(Func<IReadOnlyList<ServiceEntry>> services, Action<string, LogLevel> log, Action changed)
|
||||
{
|
||||
_services = services;
|
||||
_log = log;
|
||||
_changed = changed;
|
||||
}
|
||||
|
||||
/// <summary>비밀번호만 바뀐 경우 (재시작 없이)</summary>
|
||||
public void UpdatePassword(string hash, string salt)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_hash = hash ?? "";
|
||||
_salt = salt ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(RemoteMode mode, int port, string hash, string salt)
|
||||
{
|
||||
Stop();
|
||||
UpdatePassword(hash, salt);
|
||||
Mode = mode;
|
||||
Port = port;
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
if (mode == RemoteMode.Port) StartTls(port, _cts.Token);
|
||||
else StartSsh(port);
|
||||
IsRunning = true;
|
||||
_log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsRunning = false;
|
||||
_log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error);
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
bool wasRunning = IsRunning;
|
||||
IsRunning = false;
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
try { _ssh?.Dispose(); } catch { }
|
||||
_listener = null;
|
||||
_ssh = null;
|
||||
_cts = null;
|
||||
if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// ───────────────────────── 지정 포트 + TLS ─────────────────────────
|
||||
|
||||
private void StartTls(int port, CancellationToken ct)
|
||||
{
|
||||
_cert ??= CreateCertificate();
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
_listener.Start();
|
||||
var listener = _listener;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient tcp;
|
||||
try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
|
||||
catch { break; }
|
||||
_ = Task.Run(() => HandleTcpAsync(tcp, ct));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct)
|
||||
{
|
||||
string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
|
||||
try
|
||||
{
|
||||
tcp.NoDelay = true;
|
||||
var ssl = new SslStream(tcp.GetStream(), false);
|
||||
using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
hs.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
|
||||
{
|
||||
ServerCertificate = _cert,
|
||||
ClientCertificateRequired = false
|
||||
}, hs.Token).ConfigureAwait(false);
|
||||
}
|
||||
var conn = new JsonLineConnection(ssl, tcp);
|
||||
await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { tcp.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
|
||||
private static X509Certificate2 CreateCertificate()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
|
||||
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
|
||||
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
|
||||
// Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함
|
||||
return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
|
||||
}
|
||||
|
||||
// ───────────────────────── 내장 SSH ─────────────────────────
|
||||
|
||||
private void StartSsh(int port)
|
||||
{
|
||||
var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
|
||||
var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
|
||||
ssh.Credentials = new SshServerCredentials(new[] { hostKey });
|
||||
|
||||
ssh.SessionAuthenticating += (s, e) =>
|
||||
{
|
||||
if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
|
||||
e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password);
|
||||
// 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
|
||||
};
|
||||
|
||||
ssh.SessionOpened += (s, session) =>
|
||||
{
|
||||
session.ChannelOpening += (s2, e) =>
|
||||
{
|
||||
if (!e.IsRemoteRequest) return;
|
||||
if (session.Principal == null)
|
||||
{
|
||||
e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited;
|
||||
e.FailureDescription = "인증되지 않았습니다.";
|
||||
return;
|
||||
}
|
||||
var channel = e.Channel;
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
_ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token));
|
||||
};
|
||||
};
|
||||
|
||||
ssh.ExceptionRaised += (s, ex) =>
|
||||
{
|
||||
if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse)
|
||||
_log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error);
|
||||
};
|
||||
|
||||
_ssh = ssh;
|
||||
_ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && IsRunning)
|
||||
{
|
||||
IsRunning = false;
|
||||
_log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error);
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private async Task<ClaimsPrincipal> CheckSshPasswordAsync(string user, string password)
|
||||
{
|
||||
if (IsThrottled()) return null;
|
||||
if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password"));
|
||||
RecordFailure();
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
_log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ───────────────────────── 인증 ─────────────────────────
|
||||
|
||||
private bool CheckPassword(string password)
|
||||
{
|
||||
string h, s;
|
||||
lock (_sync) { h = _hash; s = _salt; }
|
||||
return RemoteSecurity.Verify(password, h, s);
|
||||
}
|
||||
|
||||
/// <summary>5분 동안 10번 넘게 틀리면 잠시 거부</summary>
|
||||
private bool IsThrottled()
|
||||
{
|
||||
lock (_failures)
|
||||
{
|
||||
while (_failures.Count > 0 && DateTime.UtcNow - _failures.Peek() > TimeSpan.FromMinutes(5)) _failures.Dequeue();
|
||||
return _failures.Count >= 10;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFailure()
|
||||
{
|
||||
lock (_failures) _failures.Enqueue(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// ───────────────────────── 요청 처리 ─────────────────────────
|
||||
|
||||
private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct)
|
||||
{
|
||||
bool authed = !requireHelloPassword;
|
||||
string who = peer;
|
||||
var streams = new Dictionary<int, LogTailer>();
|
||||
int nextStream = 1;
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var msg = await conn.ReadAsync(ct).ConfigureAwait(false);
|
||||
if (msg == null) break;
|
||||
|
||||
var op = Json.Str(msg, "op");
|
||||
int id = Json.Int(msg, "id");
|
||||
|
||||
if (op == "hello")
|
||||
{
|
||||
var client = Json.Str(msg, "client");
|
||||
who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})";
|
||||
if (!authed)
|
||||
{
|
||||
if (IsThrottled())
|
||||
{
|
||||
await Reply(conn, id, error: "비밀번호를 여러 번 틀려 잠시 접속이 막혔습니다. 몇 분 뒤 다시 시도하세요.", extra: new JsonObject { ["authFailed"] = true });
|
||||
break;
|
||||
}
|
||||
if (!CheckPassword(Json.Str(msg, "password")))
|
||||
{
|
||||
RecordFailure();
|
||||
await Task.Delay(1000, ct).ConfigureAwait(false);
|
||||
_log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning);
|
||||
await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true });
|
||||
break;
|
||||
}
|
||||
authed = true;
|
||||
}
|
||||
_log($"원격 접속: {who}", LogLevel.Notice);
|
||||
await Reply(conn, id, result: new JsonObject
|
||||
{
|
||||
["hostname"] = Environment.MachineName,
|
||||
["version"] = Version,
|
||||
["instance"] = AppInstance.Number
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!authed)
|
||||
{
|
||||
await Reply(conn, id, error: "먼저 인증해야 합니다.");
|
||||
break;
|
||||
}
|
||||
|
||||
switch (op)
|
||||
{
|
||||
case "ping":
|
||||
await Reply(conn, id, result: new JsonObject());
|
||||
break;
|
||||
|
||||
case "list":
|
||||
_ = Task.Run(() => SafeReply(conn, id, () => ListServices()));
|
||||
break;
|
||||
|
||||
case "start":
|
||||
case "stop":
|
||||
_ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who)));
|
||||
break;
|
||||
|
||||
case "details":
|
||||
_ = Task.Run(() => SafeReply(conn, id, () =>
|
||||
{
|
||||
var e = Registered(Json.Str(msg, "service"));
|
||||
var arr = new JsonArray();
|
||||
foreach (var l in ServiceOps.GetDetails(e.ServiceName)) arr.Add(l);
|
||||
return new JsonObject { ["lines"] = arr };
|
||||
}));
|
||||
break;
|
||||
|
||||
case "logOpen":
|
||||
{
|
||||
try
|
||||
{
|
||||
var e = Registered(Json.Str(msg, "service"));
|
||||
if (string.IsNullOrWhiteSpace(e.LogPath))
|
||||
throw new RemoteException("대상 PC에서 이 서비스의 로그 경로가 지정되지 않았습니다.");
|
||||
var enc = Json.Str(msg, "encoding");
|
||||
var copy = new ServiceEntry
|
||||
{
|
||||
ServiceName = e.ServiceName,
|
||||
DisplayName = e.DisplayName,
|
||||
LogPath = e.LogPath,
|
||||
LogIsFolder = e.LogIsFolder,
|
||||
LogEncoding = string.IsNullOrEmpty(enc) ? e.LogEncoding : enc
|
||||
};
|
||||
int sid = nextStream++;
|
||||
var tailer = new LogTailer(copy, (lines, level) => SendLog(conn, sid, lines, level))
|
||||
{
|
||||
SkipInitial = Json.Bool(msg, "newOnly")
|
||||
};
|
||||
tailer.FileChanged += f => Send(conn, new JsonObject { ["event"] = "logfile", ["stream"] = sid, ["path"] = f });
|
||||
lock (streams) streams[sid] = tailer;
|
||||
await Reply(conn, id, result: new JsonObject
|
||||
{
|
||||
["stream"] = sid,
|
||||
["path"] = e.LogPath,
|
||||
["isFolder"] = e.LogIsFolder
|
||||
});
|
||||
tailer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Reply(conn, id, error: ex.Message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "logClose":
|
||||
{
|
||||
int sid = Json.Int(msg, "stream");
|
||||
lock (streams)
|
||||
{
|
||||
if (streams.Remove(sid, out var t)) t.Dispose();
|
||||
}
|
||||
await Reply(conn, id, result: new JsonObject());
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
await Reply(conn, id, error: "알 수 없는 요청: " + op);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 연결 끊김
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (streams)
|
||||
{
|
||||
foreach (var t in streams.Values) t.Dispose();
|
||||
streams.Clear();
|
||||
}
|
||||
conn.Dispose();
|
||||
if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceEntry Registered(string serviceName)
|
||||
{
|
||||
var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
|
||||
if (e == null) throw new RemoteException("대상 PC에 등록되지 않은 서비스입니다: " + serviceName);
|
||||
return e;
|
||||
}
|
||||
|
||||
private JsonObject ListServices()
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var e in _services())
|
||||
{
|
||||
var st = ServiceOps.GetStatus(e.ServiceName);
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["name"] = e.ServiceName,
|
||||
["display"] = e.DisplayName,
|
||||
["status"] = st.HasValue ? (int)st.Value : null,
|
||||
["logPath"] = e.LogPath ?? "",
|
||||
["logIsFolder"] = e.LogIsFolder
|
||||
});
|
||||
}
|
||||
return new JsonObject { ["services"] = arr };
|
||||
}
|
||||
|
||||
private JsonObject StartStop(bool start, string serviceName, string who)
|
||||
{
|
||||
var e = Registered(serviceName);
|
||||
var name = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName;
|
||||
var what = start ? "시작" : "중지";
|
||||
_log($"[원격 {who}] [{name}] {what} 요청", LogLevel.Notice);
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
if (start) ServiceOps.Start(e.ServiceName, OpTimeout);
|
||||
else ServiceOps.Stop(e.ServiceName, OpTimeout);
|
||||
_log($"[원격 {who}] [{name}] {what} 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success);
|
||||
return new JsonObject { ["seconds"] = Math.Round(sw.Elapsed.TotalSeconds, 1) };
|
||||
}
|
||||
catch (InfoException ex)
|
||||
{
|
||||
throw new RemoteException(ex.Message, isInfo: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = ServiceOps.Describe(ex);
|
||||
_log($"[원격 {who}] [{name}] {what} 실패: {msg}", LogLevel.Error);
|
||||
throw new RemoteException(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SafeReply(JsonLineConnection conn, int id, Func<JsonObject> work)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Reply(conn, id, result: work());
|
||||
}
|
||||
catch (RemoteException ex)
|
||||
{
|
||||
await Reply(conn, id, error: ex.Message, extra: ex.IsInfo ? new JsonObject { ["info"] = true } : null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Reply(conn, id, error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Reply(JsonLineConnection conn, int id, JsonObject result = null, string error = null, JsonObject extra = null)
|
||||
{
|
||||
var msg = new JsonObject { ["id"] = id, ["ok"] = error == null };
|
||||
if (error == null) msg["result"] = result ?? new JsonObject();
|
||||
else msg["error"] = error;
|
||||
if (extra != null)
|
||||
foreach (var kv in extra.ToList())
|
||||
{
|
||||
extra.Remove(kv.Key);
|
||||
msg[kv.Key] = kv.Value;
|
||||
}
|
||||
try { await conn.SendAsync(msg).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
|
||||
private static void SendLog(JsonLineConnection conn, int sid, IList<string> lines, LogLevel level)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var l in lines) arr.Add(l);
|
||||
Send(conn, new JsonObject { ["event"] = "log", ["stream"] = sid, ["level"] = level.ToString(), ["lines"] = arr });
|
||||
}
|
||||
|
||||
/// <summary>로그 이벤트는 순서가 중요하므로 보낼 때까지 기다림 (로그 타이머 스레드에서 호출)</summary>
|
||||
private static void Send(JsonLineConnection conn, JsonObject msg)
|
||||
{
|
||||
if (conn.IsDisposed) return;
|
||||
try { conn.SendAsync(msg).GetAwaiter().GetResult(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ namespace JJServiceManager
|
|||
/// </summary>
|
||||
public static class StartupManager
|
||||
{
|
||||
private const string TaskName = "JJServiceManager";
|
||||
// [테스트용 중복 실행] 인스턴스마다 작업 이름을 따로 (정식 릴리즈 시 Suffix 제거)
|
||||
private static string TaskName => "JJServiceManager" + AppInstance.Suffix;
|
||||
|
||||
public static bool IsEnabled()
|
||||
{
|
||||
|
|
@ -29,6 +30,7 @@ namespace JJServiceManager
|
|||
{
|
||||
var exe = Environment.ProcessPath ?? "";
|
||||
var user = WindowsIdentity.GetCurrent().Name;
|
||||
var taskArgs = AppInstance.IsTest ? "--tray --instance " + AppInstance.Number : "--tray";
|
||||
var xml =
|
||||
$@"<?xml version=""1.0"" encoding=""UTF-16""?>
|
||||
<Task version=""1.2"" xmlns=""http://schemas.microsoft.com/windows/2004/02/mit/task"">
|
||||
|
|
@ -64,7 +66,7 @@ $@"<?xml version=""1.0"" encoding=""UTF-16""?>
|
|||
<Actions Context=""Author"">
|
||||
<Exec>
|
||||
<Command>{SecurityElement.Escape(exe)}</Command>
|
||||
<Arguments>--tray</Arguments>
|
||||
<Arguments>{taskArgs}</Arguments>
|
||||
<WorkingDirectory>{SecurityElement.Escape(Path.GetDirectoryName(exe) ?? "")}</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.1.0</Version>
|
||||
<Product>JJServiceManager</Product>
|
||||
<Description>Windows 서비스 기동/종료 관리</Description>
|
||||
<SatelliteResourceLanguages>ko</SatelliteResourceLanguages>
|
||||
|
|
@ -22,6 +22,11 @@
|
|||
<!-- WPF + WinForms(트레이 아이콘) -->
|
||||
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
|
||||
<!-- 원격: 내장 SSH 서버/클라이언트 (MIT) -->
|
||||
<PackageReference Include="Microsoft.DevTunnels.Ssh" Version="3.12.42" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Ssh.Tcp" Version="3.12.42" />
|
||||
<!-- 원격: 대상 PC 비밀번호를 Windows 사용자 계정으로 암호화해 저장 -->
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ namespace JJServiceManager
|
|||
{
|
||||
public static class Program
|
||||
{
|
||||
private const string MutexName = "JJServiceManager_SingleInstance_Mutex";
|
||||
private const string ShowEventName = "JJServiceManager_SingleInstance_Show";
|
||||
// [테스트용 중복 실행] 인스턴스 번호를 이름에 붙여 같은 PC에서 따로 실행 (정식 릴리즈 시 Suffix 제거)
|
||||
private static string MutexName => "JJServiceManager_SingleInstance_Mutex" + AppInstance.Suffix;
|
||||
private static string ShowEventName => "JJServiceManager_SingleInstance_Show" + AppInstance.Suffix;
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
|
|
@ -18,6 +19,9 @@ namespace JJServiceManager
|
|||
// CP949(한국어 ANSI) 로그 파일 읽기용
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
|
||||
// [테스트용 중복 실행] --instance N (정식 릴리즈 시 삭제)
|
||||
AppInstance.Parse(args);
|
||||
|
||||
// 한 번만 실행: 이미 실행 중이면 기존 창을 띄우고 종료
|
||||
using var mutex = new Mutex(true, MutexName, out bool createdNew);
|
||||
if (!createdNew)
|
||||
|
|
|
|||
86
README.md
86
README.md
|
|
@ -1,12 +1,13 @@
|
|||
# JJServiceManager
|
||||
# JJServiceManager (Ent)
|
||||
|
||||
Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
|
||||
v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다.
|
||||
|
||||
## 실행 파일
|
||||
|
||||
| 파일 | 크기 | 필요 조건 |
|
||||
|---|---|---|
|
||||
| `JJServiceManager.exe` (런타임 필요판) | 약 1.4MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 |
|
||||
| `JJServiceManager.exe` (런타임 필요판) | 약 2MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 |
|
||||
| `JJServiceManager.exe` (단독 실행판) | 약 65MB | 없음 |
|
||||
|
||||
항상 **관리자 권한**으로 실행됩니다 (실행할 때 UAC 창이 한 번 뜹니다).
|
||||
|
|
@ -14,14 +15,17 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
|
|||
## 화면
|
||||
|
||||
### 초기화면
|
||||
- **추가**: Windows 서비스 전체 목록에서 관리할 서비스 선택
|
||||
- **삭제**: 체크한 서비스를 목록에서 제외 (Windows 서비스 자체는 건드리지 않음)
|
||||
- **Start / Stop**: 체크한 서비스를 차례로 시작/중지
|
||||
- **추가**: 내 PC의 Windows 서비스 전체 목록에서 관리할 서비스 선택
|
||||
- **삭제**: 체크한 내 PC 서비스를 목록에서 제외 (Windows 서비스 자체는 건드리지 않음, 원격 서비스는 삭제 불가)
|
||||
- **Start / Stop**: 체크한 서비스(원격 포함)를 차례로 시작/중지
|
||||
- **Windows 강제종료 / Windows 재시작**: 확인창에 표시된 무작위 숫자 6자리를 그대로 입력해야만 실행 (붙여넣기 불가, 설정에서 버튼 숨김 가능)
|
||||
- **설정**
|
||||
- **종료**: JJServiceManager 완전 종료 (트레이에도 남지 않음, 실행 중인 서비스는 그대로 유지)
|
||||
- 서비스마다 **Start / Stop / Status / Log** 버튼, 상태는 2초마다 자동 갱신 (초록=실행 중, 빨강=중지됨)
|
||||
- 서비스 줄 **우클릭**: 로그 파일 지정 / 로그 폴더 지정 / 경로 해제
|
||||
- **설정**, **종료**(JJServiceManager 완전 종료, 서비스는 그대로 유지)
|
||||
- 서비스 목록
|
||||
- **전체** 탭: PC별 묶음 ("내 PC", "192.168.0.10 / SERVER01" …), PC별 접기/펼치기
|
||||
- **PC 탭 추가** 콤보박스: 고른 PC만 보여주는 탭을 옆에 추가, ✕로 닫기 (다음 실행 때도 유지)
|
||||
- 원격 서비스 앞에는 `IP/hostname` 표시
|
||||
- 서비스마다 **Start / Stop / Status / Log**, 상태 자동 갱신 (초록=실행 중, 빨강=중지됨)
|
||||
- 서비스 줄 **우클릭**(내 PC): 로그 파일 지정 / 로그 폴더 지정 / 경로 해제
|
||||
- 하단 **로그**: 작업 기록 (번호 + 체크박스)
|
||||
- 전체선택 / 전체취소 / 복사(체크한 줄)
|
||||
- **화면클리어**: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
|
||||
|
|
@ -33,22 +37,57 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
|
|||
- 설명 / 상태 / 이름 / 시작유형 열, 열 제목을 누르면 정렬(▲▼)
|
||||
- 전체선택 / 전체취소 / 선택완료, 검색
|
||||
- 이미 등록된 서비스는 노란색 + 체크된 상태로 표시
|
||||
- 체크를 해제하고 선택완료하면 초기화면에서 빠짐
|
||||
|
||||
### 로그창 (서비스 로그 파일)
|
||||
- 로그 **파일** 지정 또는 로그 **폴더** 지정(폴더 안의 가장 최근 수정 파일을 자동 추적)
|
||||
- 실시간 표시 (0.5초 간격), 인코딩 자동/UTF-8/CP949
|
||||
- 띄우기 방식: 1. 개별 창 / 2. 하나의 창에 탭 / 3. 좌우 분할 비교
|
||||
### 로그창
|
||||
- 내 PC: 로그 **파일** 또는 **폴더**(가장 최근 수정 파일 자동 추적) 지정
|
||||
- 원격: 대상 PC에서 지정한 로그를 실시간으로 받아서 표시 (연결이 끊겼다 다시 붙으면 새 로그부터 이어서)
|
||||
- 인코딩 자동/UTF-8/CP949, 띄우기 방식: 개별 창 / 탭 / 좌우 분할 비교
|
||||
|
||||
### 설정
|
||||
| 항목 | 내용 |
|
||||
## 원격 모니터링
|
||||
|
||||
모든 PC에 같은 프로그램을 설치하고, 설정에서 역할을 각각 켜고 끕니다. 한 PC가 두 역할을 동시에 할 수 있습니다.
|
||||
|
||||
| 역할 | 설정 탭 | 하는 일 |
|
||||
|---|---|---|
|
||||
| 대상 | **원격 접속 허용** | 다른 PC의 접속을 받아 **이 PC에 등록된 서비스만** 보여주고 Start/Stop 실행 |
|
||||
| 관리 | **관리 대상 PC** | 등록한 PC에 접속해 그 PC의 서비스를 초기화면에 표시 |
|
||||
|
||||
### 통신 방식
|
||||
| 방식 | 기본 포트 | 설명 |
|
||||
|---|---|---|
|
||||
| SSH (프로그램 내장) | 2222 | OpenSSH 설치 없이 프로그램 안의 SSH 서버 사용 |
|
||||
| 지정 포트 (TLS) | 9988 | 프로그램끼리 TLS로 암호화해 통신 |
|
||||
|
||||
- 인증: 대상 PC에서 정한 **접속 비밀번호** (대상 PC에는 해시로만 저장, 관리 PC에는 Windows 계정으로 암호화해 저장)
|
||||
- 서버 지문(인증서) 확인은 하지 않음 (암호화만)
|
||||
- 비밀번호를 5분 동안 10번 넘게 틀리면 잠시 접속 거부
|
||||
- 원격 접속 허용을 켜면 Windows 방화벽에 TCP 포트를 자동으로 열고, 끄면 규칙(`JJServiceManager Remote`)을 지움
|
||||
|
||||
### 사용 순서
|
||||
1. **대상 PC**: 설정 → 원격 접속 허용 → 켜기, 방식·포트·접속 비밀번호 입력 → 확인
|
||||
2. **관리 PC**: 설정 → 관리 대상 PC → 다른 PC 관리 켜기 → 대상 IP, 같은 방식·포트, 비밀번호 입력 → [연결 테스트] → [추가] → 확인
|
||||
3. 초기화면에 대상 PC 묶음이 나타나고, hostname은 자동으로 채워짐
|
||||
|
||||
### 숫자 6자리 확인
|
||||
설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬).
|
||||
여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다.
|
||||
|
||||
## 테스트용 중복 실행 (정식 릴리즈 시 삭제 예정)
|
||||
같은 PC에서 두 개를 띄워 원격 기능을 시험할 때:
|
||||
```
|
||||
JJServiceManager.exe --instance 2
|
||||
```
|
||||
- 창 제목에 `[테스트 인스턴스 2]` 표시
|
||||
- 설정 폴더를 따로 씀: `%AppData%\JJServiceManager_instance2`
|
||||
- 기본 포트: 지정 포트 9989, SSH 2223
|
||||
- 코드에서 `[테스트용 중복 실행]` 주석이 붙은 부분을 지우면 기능이 사라집니다.
|
||||
|
||||
## 설정
|
||||
| 탭 | 항목 |
|
||||
|---|---|
|
||||
| 컴퓨터 시작시 기동 | 로그인 시 트레이로 자동 실행 (작업 스케줄러 `JJServiceManager` 등록) |
|
||||
| 단축키 | 직접 눌러서 지정 (기본 Ctrl + Shift + A), Enable 토글. 누르면 창 보이기/숨기기 |
|
||||
| 서비스 시작시 로그보기 | 작업 로그를 초기화면 하단에 표시할지 여부 |
|
||||
| 로그창 띄우기 | 개별 창 / 탭 / 좌우 분할 |
|
||||
| Esc | 트레이로 (백그라운드) / 완전 종료 |
|
||||
| Windows 종료키 | Windows 강제종료 / 재시작 버튼 표시 여부 |
|
||||
| 일반 | 컴퓨터 시작시 기동, 단축키(기본 Ctrl+Shift+A), 서비스 시작시 로그보기, 로그창 띄우기, Esc, Windows 종료키, 숫자 확인(내 PC/원격) |
|
||||
| 원격 접속 허용 | 켜기/끄기, 통신 방식, 포트, 접속 비밀번호, 이 PC의 IP 안내 |
|
||||
| 관리 대상 PC | 다른 PC 관리 켜기/끄기, 대상 IP·방식·포트·비밀번호, hostname 자동, 연결 테스트 |
|
||||
|
||||
## 저장 위치
|
||||
- 설정: `%AppData%\JJServiceManager\settings.json`
|
||||
|
|
@ -57,4 +96,5 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
|
|||
## 빌드
|
||||
[.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 후 `build.bat` 실행 → `publish` 폴더에 두 가지 exe 생성
|
||||
|
||||
화면 정의는 `Views/*.xaml`이며 실행 시 읽어들입니다 (XAML 컴파일 없음).
|
||||
- 화면 정의는 `Views/*.xaml`이며 실행 시 읽어들입니다 (XAML 컴파일 없음).
|
||||
- 원격 통신: `Core/Remote/` (SSH 라이브러리: Microsoft.DevTunnels.Ssh, MIT)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,62 @@
|
|||
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="8">
|
||||
|
||||
<Grid.Resources>
|
||||
<!-- 서비스 한 줄 -->
|
||||
<DataTemplate x:Key="RowTemplate">
|
||||
<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"/>
|
||||
<Border Visibility="{Binding HostPrefixVisibility}" Background="#EDE7F6" CornerRadius="3" Padding="5,1" Margin="0,0,6,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding HostPrefix}" Foreground="#5E35B1" FontSize="12"/>
|
||||
</Border>
|
||||
<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>
|
||||
|
||||
<!-- PC 하나 (제목 줄 + 서비스 목록, 접기/펼치기) -->
|
||||
<DataTemplate x:Key="GroupTemplate">
|
||||
<Expander IsExpanded="{Binding IsExpanded, Mode=TwoWay}" Margin="0,0,0,2" BorderThickness="0">
|
||||
<Expander.Header>
|
||||
<Border Background="{Binding HeaderBrush}" CornerRadius="3" Padding="8,4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="13.5" VerticalAlignment="Center"/>
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding StatusBrush}" Margin="12,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander.Header>
|
||||
<StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Rows}" ItemTemplate="{StaticResource RowTemplate}"/>
|
||||
<TextBlock Text="{Binding EmptyText}" Visibility="{Binding EmptyVisibility}"
|
||||
Foreground="#90A4AE" Margin="40,8,8,10"/>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</DataTemplate>
|
||||
</Grid.Resources>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*" MinHeight="120"/>
|
||||
<RowDefinition Height="*" MinHeight="140"/>
|
||||
<RowDefinition Height="8"/>
|
||||
<RowDefinition Height="*" MinHeight="140"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
|
@ -18,67 +71,46 @@
|
|||
<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 서비스는 그대로 유지)"/>
|
||||
<Button x:Name="btnAdd" Content="추가" ToolTip="내 PC에서 관리할 Windows 서비스를 선택합니다."/>
|
||||
<Button x:Name="btnDelete" Content="삭제" ToolTip="체크한 내 PC 서비스를 목록에서 뺍니다. (Windows 서비스는 그대로 유지)"/>
|
||||
<Rectangle Width="1" Fill="#CFD8DC" Margin="8,4"/>
|
||||
<Button x:Name="btnStartChecked" Content="Start" ToolTip="체크한 서비스를 차례로 시작합니다."/>
|
||||
<Button x:Name="btnStopChecked" Content="Stop" ToolTip="체크한 서비스를 차례로 중지합니다."/>
|
||||
<Button x:Name="btnStartChecked" Content="Start" ToolTip="체크한 서비스(원격 포함)를 차례로 시작합니다."/>
|
||||
<Button x:Name="btnStopChecked" Content="Stop" ToolTip="체크한 서비스(원격 포함)를 차례로 중지합니다."/>
|
||||
</WrapPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ───────── 등록된 서비스 목록 ───────── -->
|
||||
<!-- ───────── 서비스 목록 (전체 탭 + PC별 탭) ───────── -->
|
||||
<Border Grid.Row="1" BorderBrush="#B0BEC5" BorderThickness="1" Background="White">
|
||||
<DockPanel>
|
||||
<!-- 열 제목 + PC 탭 추가 -->
|
||||
<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"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox x:Name="chkAll" VerticalAlignment="Center" ToolTip="전체 체크 / 해제"/>
|
||||
<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"/>
|
||||
<TextBlock x:Name="txtCount" Grid.Column="2" Foreground="#607D8B" VerticalAlignment="Center" Margin="0,0,16,0"/>
|
||||
<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}"/>
|
||||
<TextBlock Text="PC 탭 추가" VerticalAlignment="Center" Margin="0,0,6,0" Foreground="#455A64"/>
|
||||
<ComboBox x:Name="cmbAddTab" Width="230" Height="26" VerticalContentAlignment="Center"
|
||||
DisplayMemberPath="Title"
|
||||
ToolTip="고른 PC만 보여주는 탭을 옆에 추가합니다."/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TabControl x:Name="tabHosts" BorderThickness="0" Padding="0">
|
||||
<TabItem x:Name="tabAll" Header="전체">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="icGroups" ItemTemplate="{StaticResource GroupTemplate}" Margin="4"/>
|
||||
</ScrollViewer>
|
||||
<TextBlock x:Name="txtEmpty" Text="[추가] 버튼을 눌러 관리할 서비스를 등록하세요."
|
||||
Foreground="#90A4AE" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
<Button x:Name="btnCancel" Content="취소" MinWidth="80"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="#B0BEC5" BorderThickness="1" Background="White" Padding="12">
|
||||
<TabControl x:Name="tabs" Padding="0">
|
||||
|
||||
<!-- ═════════════ 1. 일반 ═════════════ -->
|
||||
<TabItem Header=" 일반 ">
|
||||
<Border Background="White" Padding="14,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="210"/>
|
||||
|
|
@ -20,10 +24,12 @@
|
|||
<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"/>
|
||||
<DockPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkStartup" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"
|
||||
|
|
@ -31,7 +37,7 @@
|
|||
<TextBlock Text="로그인 시 트레이로 자동 실행" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 2. 단축키 -->
|
||||
<!-- 단축키 -->
|
||||
<TextBlock Grid.Row="1" Text="단축키" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" LastChildFill="False">
|
||||
<CheckBox x:Name="chkHotkey" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
|
|
@ -42,14 +48,14 @@
|
|||
ToolTip="여기를 클릭한 뒤 원하는 키 조합을 직접 누르세요. (Ctrl / Alt / Win 중 하나 이상 포함)"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 3. 서비스 시작시 로그보기 -->
|
||||
<!-- 서비스 시작시 로그보기 -->
|
||||
<TextBlock Grid.Row="2" Text="서비스 시작시 로그보기" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="2" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkOpLog" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="Start / Stop 등 작업 로그를 초기화면 하단에 표시" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 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. 개별 창"/>
|
||||
|
|
@ -57,21 +63,162 @@
|
|||
<ComboBoxItem Content="3. 창을 좌우로 나누어 비교"/>
|
||||
</ComboBox>
|
||||
|
||||
<!-- 5. Esc -->
|
||||
<!-- 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 종료키 -->
|
||||
<!-- Windows 종료키 -->
|
||||
<TextBlock Grid.Row="5" Text="Windows 종료키" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="5" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkShutdownButtons" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="초기화면에 Windows 강제종료 / 재시작 버튼 표시" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.ColumnSpan="2" x:Name="txtDataPath" Foreground="#90A4AE" Margin="0,14,0,0" TextWrapping="Wrap"/>
|
||||
<!-- 숫자 확인: 내 PC -->
|
||||
<TextBlock Grid.Row="6" Text="숫자 확인 (내 PC)" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="6" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkConfirmLocal" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="내 PC 서비스 Start / Stop 전에 숫자 6자리 입력" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 숫자 확인: 원격 -->
|
||||
<TextBlock Grid.Row="7" Text="숫자 확인 (원격)" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="7" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkConfirmRemote" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="원격 PC 서비스 Start / Stop 전에 숫자 6자리 입력" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Grid.Row="8" Grid.ColumnSpan="2" x:Name="txtDataPath" Foreground="#90A4AE" Margin="0,14,0,0" TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</TabItem>
|
||||
|
||||
<!-- ═════════════ 2. 원격 접속 허용 (대상 역할) ═════════════ -->
|
||||
<TabItem Header=" 원격 접속 허용 ">
|
||||
<Border Background="White" Padding="14,10">
|
||||
<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="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="원격 접속 허용" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<DockPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
||||
<CheckBox x:Name="chkServer" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="다른 PC가 이 PC의 등록된 서비스를 보고 Start / Stop 할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="통신 방식" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="cmbServerMode" Grid.Row="1" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
|
||||
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="포트" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBox x:Name="txtServerPort" Width="100" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
|
||||
<TextBlock x:Name="txtServerPortHint" Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="접속 비밀번호" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<PasswordBox x:Name="pwdServer" Width="180" Height="28" VerticalContentAlignment="Center"/>
|
||||
<TextBlock Text="확인" VerticalAlignment="Center" Margin="10,0,6,0"/>
|
||||
<PasswordBox x:Name="pwdServer2" Width="180" Height="28" VerticalContentAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="txtPwState" VerticalAlignment="Center" Foreground="#78909C"/>
|
||||
|
||||
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F1F8FB" BorderBrush="#B0BEC5" BorderThickness="1" Padding="10" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="txtServerInfo" TextWrapping="Wrap" Foreground="#37474F" LineHeight="20"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</TabItem>
|
||||
|
||||
<!-- ═════════════ 3. 관리 대상 PC (관리 역할) ═════════════ -->
|
||||
<TabItem Header=" 관리 대상 PC ">
|
||||
<Border Background="White" Padding="14,10">
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Height="44" LastChildFill="True">
|
||||
<TextBlock Text="다른 PC 관리" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||
<CheckBox x:Name="chkManage" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||
<TextBlock Text="아래 PC에 접속해 등록된 서비스를 초기화면에 표시" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 입력 -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#FAFAFA" BorderBrush="#CFD8DC" BorderThickness="1" Padding="10" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="90"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="80"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="36"/>
|
||||
<RowDefinition Height="36"/>
|
||||
<RowDefinition Height="36"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="대상 IP" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="txtAddr" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||
ToolTip="예: 192.168.0.10 (컴퓨터 이름도 가능)"/>
|
||||
<TextBlock Grid.Column="2" Text="hostname" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="txtHostname" Grid.Column="3" Height="28" VerticalContentAlignment="Center" IsReadOnly="True"
|
||||
Background="#ECEFF1" ToolTip="연결 테스트 또는 접속할 때 자동으로 채워집니다."/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="통신 방식" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="cmbMode" Grid.Row="1" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
|
||||
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="포트" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="txtPort" Grid.Row="1" Grid.Column="3" Width="100" HorizontalAlignment="Left" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="비밀번호" VerticalAlignment="Center"/>
|
||||
<PasswordBox x:Name="pwdTarget" Grid.Row="2" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||
ToolTip="대상 PC의 [원격 접속 허용] 접속 비밀번호"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Grid.ColumnSpan="2" x:Name="txtPwTarget" VerticalAlignment="Center" Foreground="#78909C"/>
|
||||
|
||||
<DockPanel Grid.Row="3" Grid.ColumnSpan="4" Margin="0,6,0,0">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button x:Name="btnTest" Content="연결 테스트"/>
|
||||
<Button x:Name="btnNew" Content="새로 입력"/>
|
||||
<Button x:Name="btnAddTarget" Content="추가" FontWeight="Bold"/>
|
||||
<Button x:Name="btnUpdateTarget" Content="수정"/>
|
||||
<Button x:Name="btnDeleteTarget" Content="삭제"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="txtTest" VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,10,0"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 목록 -->
|
||||
<ListBox x:Name="lstTargets" BorderBrush="#B0BEC5" MinHeight="120">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="4,3">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding ModeText}" Foreground="#607D8B"/>
|
||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ using System.Threading.Tasks;
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using JJServiceManager.Remote;
|
||||
using Forms = System.Windows.Forms;
|
||||
|
||||
namespace JJServiceManager
|
||||
|
|
@ -24,16 +26,27 @@ namespace JJServiceManager
|
|||
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly ObservableCollection<ServiceRow> _rows = new ObservableCollection<ServiceRow>();
|
||||
private readonly FrameworkElement _root;
|
||||
private readonly ObservableCollection<HostGroup> _groups = new ObservableCollection<HostGroup>();
|
||||
private readonly HostGroup _local;
|
||||
private readonly LogPanel _log;
|
||||
private readonly LogWindowManager _logWindows;
|
||||
private readonly DispatcherTimer _statusTimer;
|
||||
private readonly RemoteServer _remoteServer;
|
||||
|
||||
private readonly ItemsControl _list;
|
||||
private readonly TabControl _tabs;
|
||||
private readonly TabItem _tabAll;
|
||||
private readonly ComboBox _cmbAddTab;
|
||||
private readonly CheckBox _chkAll;
|
||||
private readonly TextBlock _txtCount, _txtEmpty, _txtLogHidden;
|
||||
private readonly TextBlock _txtCount, _txtLogHidden;
|
||||
private readonly ContentControl _ccLog;
|
||||
private readonly FrameworkElement _pnlShutdown;
|
||||
private readonly Dictionary<string, TabItem> _hostTabs = new Dictionary<string, TabItem>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>원격 접속 허용 쪽에 넘겨줄 등록 서비스 목록 (다른 스레드에서 읽음)</summary>
|
||||
private volatile IReadOnlyList<ServiceEntry> _servicesSnapshot = Array.Empty<ServiceEntry>();
|
||||
private string _serverKey = "";
|
||||
private string _targetsKey = "";
|
||||
|
||||
private Forms.NotifyIcon _tray;
|
||||
private HotkeyManager _hotkey;
|
||||
|
|
@ -46,58 +59,72 @@ namespace JJServiceManager
|
|||
{
|
||||
_settings = settings;
|
||||
|
||||
Title = "JJServiceManager - 서비스 관리";
|
||||
Title = "JJServiceManager - 서비스 관리" + AppInstance.TitleTag;
|
||||
FontFamily = new FontFamily("Malgun Gothic");
|
||||
FontSize = 13;
|
||||
Background = Brushes2.Make("#F5F7F8");
|
||||
MinWidth = 760;
|
||||
MinHeight = 520;
|
||||
MinWidth = 860;
|
||||
MinHeight = 560;
|
||||
AppIcon.Apply(this);
|
||||
RestoreWindowBounds(settings);
|
||||
|
||||
var root = XamlLoader.LoadElement("MainWindow.xaml");
|
||||
Content = root;
|
||||
_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");
|
||||
_tabs = _root.Find<TabControl>("tabHosts");
|
||||
_tabAll = _root.Find<TabItem>("tabAll");
|
||||
_cmbAddTab = _root.Find<ComboBox>("cmbAddTab");
|
||||
_chkAll = _root.Find<CheckBox>("chkAll");
|
||||
_txtCount = _root.Find<TextBlock>("txtCount");
|
||||
_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);
|
||||
_logWindows = new LogWindowManager(() => _settings, SaveSettings, LogChoices);
|
||||
|
||||
foreach (var e in settings.Services) _rows.Add(new ServiceRow(e));
|
||||
_list.ItemsSource = _rows;
|
||||
_rows.CollectionChanged += (s, e) => UpdateCounts();
|
||||
// 내 PC
|
||||
_local = new HostGroup
|
||||
{
|
||||
Id = HostGroup.LocalId,
|
||||
Title = $"내 PC ({Environment.MachineName})",
|
||||
EmptyText = "[추가] 버튼을 눌러 관리할 서비스를 등록하세요."
|
||||
};
|
||||
foreach (var e in settings.Services) _local.Rows.Add(new ServiceRow(e, _local));
|
||||
_local.Rows.CollectionChanged += (s, e) => UpdateCounts();
|
||||
_groups.Add(_local);
|
||||
UpdateSnapshot();
|
||||
|
||||
_root.Find<ItemsControl>("icGroups").ItemsSource = _groups;
|
||||
_cmbAddTab.ItemsSource = _groups;
|
||||
_cmbAddTab.SelectionChanged += OnAddTabSelected;
|
||||
_tabs.SelectionChanged += (s, e) => { if (e.Source == _tabs) 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();
|
||||
root.Find<Button>("btnExit").Click += (s, e) => ExitApp();
|
||||
_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();
|
||||
_root.Find<Button>("btnExit").Click += (s, e) => ExitApp();
|
||||
_chkAll.Click += (s, e) =>
|
||||
{
|
||||
bool v = _chkAll.IsChecked == true;
|
||||
foreach (var r in _rows) r.IsChecked = v;
|
||||
foreach (var r in VisibleRows()) r.IsChecked = v;
|
||||
};
|
||||
|
||||
// 서비스 줄의 Start / Stop / Status / Log 버튼
|
||||
_list.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OnRowButton));
|
||||
// 서비스 줄의 Start / Stop / Status / Log 버튼 (모든 탭)
|
||||
_tabs.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OnRowButton));
|
||||
// 우클릭: 로그 경로 지정 메뉴
|
||||
_list.AddHandler(UIElement.PreviewMouseRightButtonUpEvent, new MouseButtonEventHandler(OnRowRightClick), true);
|
||||
_tabs.AddHandler(UIElement.PreviewMouseRightButtonUpEvent, new MouseButtonEventHandler(OnRowRightClick), true);
|
||||
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
|
||||
// 상태 자동 갱신 (2초)
|
||||
// 내 PC 상태 자동 갱신 (2초)
|
||||
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
|
||||
|
||||
|
|
@ -106,13 +133,21 @@ namespace JJServiceManager
|
|||
_hotkey = new HotkeyManager(new WindowInteropHelper(this).Handle);
|
||||
_hotkey.Pressed += ToggleVisibility;
|
||||
|
||||
CreateTray();
|
||||
ApplySettings(startup: true);
|
||||
UpdateCounts();
|
||||
// 원격 접속 허용 (대상 역할)
|
||||
_remoteServer = new RemoteServer(
|
||||
() => _servicesSnapshot,
|
||||
(m, l) => Log(m, l),
|
||||
() => Dispatcher.BeginInvoke(new Action(() => _ = RefreshStatuses(true))));
|
||||
|
||||
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : ""), LogLevel.Notice);
|
||||
CreateTray();
|
||||
|
||||
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : "") + AppInstance.TitleTag, LogLevel.Notice);
|
||||
if (!IsAdmin()) Log("관리자 권한이 아닙니다. 서비스 Start/Stop이 실패할 수 있습니다.", LogLevel.Warning);
|
||||
if (_rows.Count == 0) Log("[추가] 버튼을 눌러 관리할 서비스를 등록하세요.");
|
||||
if (_local.Rows.Count == 0) Log("[추가] 버튼을 눌러 관리할 서비스를 등록하세요.");
|
||||
|
||||
ApplySettings(startup: true);
|
||||
RestoreHostTabs();
|
||||
UpdateCounts();
|
||||
|
||||
_ = RefreshStatuses(true);
|
||||
_statusTimer.Start();
|
||||
|
|
@ -134,26 +169,106 @@ namespace JJServiceManager
|
|||
if (_settings.ShowOperationLog) _log.Add(text, level);
|
||||
}
|
||||
|
||||
// ───────────────────────── 서비스 목록 ─────────────────────────
|
||||
// ───────────────────────── 목록 / 탭 ─────────────────────────
|
||||
|
||||
private IEnumerable<ServiceRow> AllRows() => _groups.SelectMany(g => g.Rows);
|
||||
|
||||
/// <summary>지금 보고 있는 탭의 서비스</summary>
|
||||
private IEnumerable<ServiceRow> VisibleRows()
|
||||
{
|
||||
if (_tabs.SelectedItem is TabItem t && t != _tabAll && t.Tag is string id)
|
||||
return _groups.FirstOrDefault(g => g.Id == id)?.Rows ?? Enumerable.Empty<ServiceRow>();
|
||||
return AllRows();
|
||||
}
|
||||
|
||||
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;
|
||||
var rows = VisibleRows().ToList();
|
||||
int running = rows.Count(r => r.Status == ServiceControllerStatus.Running);
|
||||
_txtCount.Text = rows.Count == 0 ? "" : $"{rows.Count}개 · 실행 중 {running}";
|
||||
}
|
||||
|
||||
private void UpdateSnapshot()
|
||||
=> _servicesSnapshot = _local.Rows.Select(r => r.Entry).ToList();
|
||||
|
||||
private void SyncEntriesFromRows()
|
||||
{
|
||||
_settings.Services = _rows.Select(r => r.Entry).ToList();
|
||||
_settings.Services = _local.Rows.Select(r => r.Entry).ToList();
|
||||
UpdateSnapshot();
|
||||
SaveSettings();
|
||||
_logWindows.ServicesChanged();
|
||||
_logWindows.ChoicesChanged();
|
||||
}
|
||||
|
||||
private void OnAddTabSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_cmbAddTab.SelectedItem is not HostGroup g) return;
|
||||
OpenHostTab(g.Id, select: true);
|
||||
// 다시 고를 수 있도록 선택 해제
|
||||
Dispatcher.BeginInvoke(new Action(() => _cmbAddTab.SelectedItem = null));
|
||||
}
|
||||
|
||||
/// <summary>PC 하나만 보여주는 탭 열기</summary>
|
||||
private void OpenHostTab(string id, bool select)
|
||||
{
|
||||
var group = _groups.FirstOrDefault(g => g.Id == id);
|
||||
if (group == null) return;
|
||||
|
||||
if (_hostTabs.TryGetValue(id, out var existing))
|
||||
{
|
||||
if (select) _tabs.SelectedItem = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var title = new TextBlock { DataContext = group, VerticalAlignment = VerticalAlignment.Center };
|
||||
title.SetBinding(TextBlock.TextProperty, new Binding(nameof(HostGroup.Title)));
|
||||
|
||||
var list = new ItemsControl
|
||||
{
|
||||
ItemTemplate = (DataTemplate)_root.FindResource("GroupTemplate"),
|
||||
ItemsSource = new[] { group },
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
var tab = new TabItem
|
||||
{
|
||||
Tag = id,
|
||||
Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = list }
|
||||
};
|
||||
tab.Header = LogWindowManager.TabHeader(title, () => CloseHostTab(id));
|
||||
|
||||
_hostTabs[id] = tab;
|
||||
_tabs.Items.Add(tab);
|
||||
if (select) _tabs.SelectedItem = tab;
|
||||
SaveOpenTabs();
|
||||
}
|
||||
|
||||
private void CloseHostTab(string id, bool save = true)
|
||||
{
|
||||
if (!_hostTabs.TryGetValue(id, out var tab)) return;
|
||||
if (_tabs.SelectedItem == tab) _tabs.SelectedItem = _tabAll;
|
||||
_tabs.Items.Remove(tab);
|
||||
_hostTabs.Remove(id);
|
||||
if (save) SaveOpenTabs();
|
||||
}
|
||||
|
||||
private void SaveOpenTabs()
|
||||
{
|
||||
_settings.OpenHostTabs = _hostTabs.Keys.ToList();
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void RestoreHostTabs()
|
||||
{
|
||||
foreach (var id in _settings.OpenHostTabs.ToList()) OpenHostTab(id, select: false);
|
||||
// 없어진 PC 탭은 정리
|
||||
_settings.OpenHostTabs = _hostTabs.Keys.ToList();
|
||||
}
|
||||
|
||||
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
|
||||
|
||||
private void AddServices()
|
||||
{
|
||||
if (_modalOpen) return;
|
||||
var picker = new ServicePickerWindow(_rows.Select(r => r.ServiceName)) { Owner = this };
|
||||
var picker = new ServicePickerWindow(_local.Rows.Select(r => r.ServiceName)) { Owner = this };
|
||||
bool? ok;
|
||||
_modalOpen = true;
|
||||
try { ok = picker.ShowDialog(); }
|
||||
|
|
@ -164,21 +279,20 @@ namespace JJServiceManager
|
|||
var chosenNames = new HashSet<string>(chosen.Select(c => c.ServiceName), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 체크 해제된 기존 항목은 목록에서 뺌
|
||||
var removed = _rows.Where(r => !chosenNames.Contains(r.ServiceName)).ToList();
|
||||
var removed = _local.Rows.Where(r => !chosenNames.Contains(r.ServiceName)).ToList();
|
||||
foreach (var r in removed)
|
||||
{
|
||||
_rows.Remove(r);
|
||||
_logWindows.Close(r.ServiceName);
|
||||
_local.Rows.Remove(r);
|
||||
_logWindows.Close("local|" + r.ServiceName);
|
||||
}
|
||||
|
||||
// 새로 체크된 항목 추가 (기존 순서 유지, 새 항목은 뒤에)
|
||||
var existing = new HashSet<string>(_rows.Select(r => r.ServiceName), StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(_local.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);
|
||||
_local.Rows.Add(new ServiceRow(new ServiceEntry { ServiceName = c.ServiceName, DisplayName = c.DisplayName }, _local));
|
||||
added.Add(c.DisplayName);
|
||||
}
|
||||
|
||||
|
|
@ -192,26 +306,56 @@ namespace JJServiceManager
|
|||
|
||||
private void DeleteChecked()
|
||||
{
|
||||
var targets = _rows.Where(r => r.IsChecked).ToList();
|
||||
var checkedRows = AllRows().Where(r => r.IsChecked).ToList();
|
||||
var targets = checkedRows.Where(r => !r.IsRemote).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
Dialogs.Info(this, "삭제할 서비스를 체크하세요.");
|
||||
Dialogs.Info(this, checkedRows.Count > 0
|
||||
? "원격 PC의 서비스는 여기서 삭제할 수 없습니다. (대상 PC에서 관리)\n삭제할 내 PC 서비스를 체크하세요."
|
||||
: "삭제할 서비스를 체크하세요.");
|
||||
return;
|
||||
}
|
||||
var names = string.Join("\n", targets.Select(t => " · " + t.DisplayName));
|
||||
if (!Dialogs.Confirm(this, $"아래 {targets.Count}개 서비스를 목록에서 삭제할까요?\n\n{names}\n\n(Windows 서비스 자체는 그대로 유지됩니다)", "삭제"))
|
||||
var note = checkedRows.Count > targets.Count ? "\n\n체크한 원격 서비스는 삭제 대상에서 제외됩니다." : "";
|
||||
if (!Dialogs.Confirm(this, $"아래 {targets.Count}개 서비스를 목록에서 삭제할까요?\n\n{names}\n\n(Windows 서비스 자체는 그대로 유지됩니다){note}", "삭제"))
|
||||
return;
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
_rows.Remove(t);
|
||||
_logWindows.Close(t.ServiceName);
|
||||
_local.Rows.Remove(t);
|
||||
_logWindows.Close("local|" + t.ServiceName);
|
||||
}
|
||||
_chkAll.IsChecked = false;
|
||||
Log($"목록에서 삭제: {string.Join(", ", targets.Select(t => t.DisplayName))}");
|
||||
SyncEntriesFromRows();
|
||||
}
|
||||
|
||||
// ───────────────────────── 숫자 6자리 확인 ─────────────────────────
|
||||
|
||||
private bool NeedsCode(IEnumerable<ServiceRow> rows)
|
||||
=> rows.Any(r => r.IsRemote ? _settings.ConfirmRemoteStartStop : _settings.ConfirmLocalStartStop);
|
||||
|
||||
/// <summary>설정에 따라 숫자 확인창을 띄움. 실행해도 되면 true</summary>
|
||||
private bool ConfirmStartStop(bool start, IList<ServiceRow> rows)
|
||||
{
|
||||
if (!NeedsCode(rows)) return true;
|
||||
if (_modalOpen) return false;
|
||||
|
||||
var what = start ? "시작" : "중지";
|
||||
var list = string.Join("\n", rows.Take(8).Select(r => " · " + (r.IsRemote ? $"[{r.HostPrefix}] " : "") + r.DisplayName));
|
||||
if (rows.Count > 8) list += $"\n · 외 {rows.Count - 8}개";
|
||||
|
||||
bool ok;
|
||||
_modalOpen = true;
|
||||
try
|
||||
{
|
||||
ok = CodeConfirmWindow.Ask(this, "서비스 " + what, $"아래 서비스를 {what}합니다.\n\n{list}", "서비스 " + what);
|
||||
}
|
||||
finally { _modalOpen = false; }
|
||||
if (!ok) Log($"서비스 {what} 취소", LogLevel.Info);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ───────────────────────── Start / Stop / Status / Log ─────────────────────────
|
||||
|
||||
private async void OnRowButton(object sender, RoutedEventArgs e)
|
||||
|
|
@ -219,26 +363,38 @@ namespace JJServiceManager
|
|||
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 "start":
|
||||
if (ConfirmStartStop(true, new[] { row })) await StartService(row);
|
||||
break;
|
||||
case "stop":
|
||||
if (ConfirmStartStop(false, new[] { row })) await StopService(row);
|
||||
break;
|
||||
case "status": await ShowStatus(row); break;
|
||||
case "log": OpenLog(row, b); break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartService(ServiceRow row)
|
||||
private Task StartService(ServiceRow row) => row.IsRemote ? RemoteStartStop(row, true) : LocalStartStop(row, true);
|
||||
private Task StopService(ServiceRow row) => row.IsRemote ? RemoteStartStop(row, false) : LocalStartStop(row, false);
|
||||
|
||||
private async Task LocalStartStop(ServiceRow row, bool start)
|
||||
{
|
||||
if (row.IsBusy) return;
|
||||
var what = start ? "시작" : "중지";
|
||||
row.IsBusy = true;
|
||||
Log($"[{row.DisplayName}] 시작 요청");
|
||||
Log($"{row.LogName} {what} 요청");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await Task.Run(() => ServiceOps.Start(row.ServiceName, OpTimeout));
|
||||
Log($"[{row.DisplayName}] 시작 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success);
|
||||
await Task.Run(() =>
|
||||
{
|
||||
if (start) ServiceOps.Start(row.ServiceName, OpTimeout);
|
||||
else ServiceOps.Stop(row.ServiceName, OpTimeout);
|
||||
});
|
||||
Log($"{row.LogName} {what} 완료 ({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); }
|
||||
catch (InfoException ex) { Log($"{row.LogName} {ex.Message}", LogLevel.Warning); }
|
||||
catch (Exception ex) { Log($"{row.LogName} {what} 실패: {ServiceOps.Describe(ex)}", LogLevel.Error); }
|
||||
finally
|
||||
{
|
||||
row.IsBusy = false;
|
||||
|
|
@ -246,35 +402,41 @@ namespace JJServiceManager
|
|||
}
|
||||
}
|
||||
|
||||
private async Task StopService(ServiceRow row)
|
||||
private async Task RemoteStartStop(ServiceRow row, bool start)
|
||||
{
|
||||
if (row.IsBusy) return;
|
||||
var client = row.Host.Client;
|
||||
var what = start ? "시작" : "중지";
|
||||
row.IsBusy = true;
|
||||
Log($"[{row.DisplayName}] 중지 요청");
|
||||
var sw = Stopwatch.StartNew();
|
||||
Log($"{row.LogName} {what} 요청 (원격)");
|
||||
try
|
||||
{
|
||||
await Task.Run(() => ServiceOps.Stop(row.ServiceName, OpTimeout));
|
||||
Log($"[{row.DisplayName}] 중지 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success);
|
||||
var r = start ? await client.StartServiceAsync(row.ServiceName) : await client.StopServiceAsync(row.ServiceName);
|
||||
double sec = 0;
|
||||
try { sec = r["seconds"]?.GetValue<double>() ?? 0; } catch { }
|
||||
Log($"{row.LogName} {what} 완료 ({sec: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); }
|
||||
catch (RemoteException ex) when (ex.IsInfo) { Log($"{row.LogName} {ex.Message}", LogLevel.Warning); }
|
||||
catch (Exception ex) { Log($"{row.LogName} {what} 실패: {RemoteClient.Friendly(ex)}", LogLevel.Error); }
|
||||
finally
|
||||
{
|
||||
row.IsBusy = false;
|
||||
await RefreshStatuses(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunChecked(bool start)
|
||||
{
|
||||
var targets = _rows.Where(r => r.IsChecked).ToList();
|
||||
var targets = AllRows().Where(r => r.IsChecked).ToList();
|
||||
var what = start ? "시작" : "중지";
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
Dialogs.Info(this, $"{(start ? "시작" : "중지")}할 서비스를 체크하세요.");
|
||||
Dialogs.Info(this, $"{what}할 서비스를 체크하세요.");
|
||||
return;
|
||||
}
|
||||
Log($"선택한 {targets.Count}개 서비스 {(start ? "시작" : "중지")}", LogLevel.Notice);
|
||||
// 여러 개를 한꺼번에 할 때는 숫자를 한 번만 입력
|
||||
if (!ConfirmStartStop(start, targets)) return;
|
||||
|
||||
Log($"선택한 {targets.Count}개 서비스 {what}", LogLevel.Notice);
|
||||
foreach (var r in targets)
|
||||
{
|
||||
if (start) await StartService(r);
|
||||
|
|
@ -286,26 +448,65 @@ namespace JJServiceManager
|
|||
{
|
||||
try
|
||||
{
|
||||
var lines = await Task.Run(() => ServiceOps.GetDetails(row.ServiceName));
|
||||
Log($"[{row.DisplayName}] 상태 정보", LogLevel.Notice);
|
||||
var lines = row.IsRemote
|
||||
? await row.Host.Client.GetDetailsAsync(row.ServiceName)
|
||||
: await Task.Run(() => ServiceOps.GetDetails(row.ServiceName));
|
||||
Log($"{row.LogName} 상태 정보", LogLevel.Notice);
|
||||
foreach (var l in lines) Log(" " + l);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"[{row.DisplayName}] 상태 조회 실패: {ServiceOps.Describe(ex)}", LogLevel.Error);
|
||||
var msg = row.IsRemote ? RemoteClient.Friendly(ex) : ServiceOps.Describe(ex);
|
||||
Log($"{row.LogName} 상태 조회 실패: {msg}", LogLevel.Error);
|
||||
}
|
||||
await RefreshStatuses(true);
|
||||
if (!row.IsRemote) await RefreshStatuses(true);
|
||||
}
|
||||
|
||||
private void OpenLog(ServiceRow row, FrameworkElement anchor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Entry.LogPath))
|
||||
if (row.IsRemote)
|
||||
{
|
||||
if (!row.HasLog)
|
||||
{
|
||||
Dialogs.Info(this, $"{row.Host.Title} 에서 [{row.DisplayName}]의 로그 경로가 지정되지 않았습니다.\n대상 PC의 JJServiceManager에서 Log 버튼으로 경로를 지정하세요.");
|
||||
return;
|
||||
}
|
||||
_logWindows.Open(RemoteLogTarget(row));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row.HasLog)
|
||||
{
|
||||
// 경로가 없으면 파일/폴더 중 선택
|
||||
ShowLogMenu(row, anchor, includeView: false);
|
||||
return;
|
||||
}
|
||||
_logWindows.Open(row.Entry);
|
||||
_logWindows.Open(LogTarget.ForLocal(row.Entry));
|
||||
}
|
||||
|
||||
private static LogTarget RemoteLogTarget(ServiceRow row)
|
||||
{
|
||||
var client = row.Host.Client;
|
||||
var entry = row.Entry;
|
||||
var svc = row.ServiceName;
|
||||
return new LogTarget
|
||||
{
|
||||
Key = $"remote|{row.Host.Id}|{svc}",
|
||||
Title = $"{row.HostPrefix} - {row.DisplayName}",
|
||||
PathText = () => "대상 PC " + (entry.LogIsFolder ? "폴더: " : "파일: ") + entry.LogPath,
|
||||
IsLocal = false,
|
||||
CreateSource = (t, output) => new RemoteLogSource(client, svc, () => t.Encoding, output)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>좌우 분할 창에서 고를 수 있는 로그 (내 PC + 원격)</summary>
|
||||
private List<LogTarget> LogChoices()
|
||||
{
|
||||
var list = new List<LogTarget>();
|
||||
foreach (var r in _local.Rows.Where(r => r.HasLog)) list.Add(LogTarget.ForLocal(r.Entry));
|
||||
foreach (var g in _groups.Where(g => !g.IsLocal))
|
||||
foreach (var r in g.Rows.Where(r => r.HasLog)) list.Add(RemoteLogTarget(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
private void OnRowRightClick(object sender, MouseButtonEventArgs e)
|
||||
|
|
@ -313,8 +514,24 @@ namespace JJServiceManager
|
|||
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)
|
||||
{
|
||||
if (row.IsRemote)
|
||||
{
|
||||
// 원격 서비스: 로그 경로는 대상 PC에서 지정
|
||||
if (row.HasLog)
|
||||
{
|
||||
var menu = new ContextMenu { PlacementTarget = fe, Placement = PlacementMode.MousePoint };
|
||||
var view = new MenuItem { Header = "로그 보기", FontWeight = FontWeights.Bold };
|
||||
view.Click += (s, a) => _logWindows.Open(RemoteLogTarget(row));
|
||||
menu.Items.Add(view);
|
||||
menu.Items.Add(new MenuItem { Header = "대상 PC 로그: " + row.Entry.LogPath, IsEnabled = false });
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowLogMenu(row, e.OriginalSource as FrameworkElement ?? fe, includeView: true);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -323,10 +540,10 @@ namespace JJServiceManager
|
|||
{
|
||||
var menu = new ContextMenu { PlacementTarget = anchor, Placement = PlacementMode.MousePoint };
|
||||
|
||||
if (includeView && !string.IsNullOrWhiteSpace(row.Entry.LogPath))
|
||||
if (includeView && row.HasLog)
|
||||
{
|
||||
var view = new MenuItem { Header = "로그 보기", FontWeight = FontWeights.Bold };
|
||||
view.Click += (s, e) => _logWindows.Open(row.Entry);
|
||||
view.Click += (s, e) => _logWindows.Open(LogTarget.ForLocal(row.Entry));
|
||||
menu.Items.Add(view);
|
||||
menu.Items.Add(new Separator());
|
||||
}
|
||||
|
|
@ -339,7 +556,7 @@ namespace JJServiceManager
|
|||
folder.Click += (s, e) => PickLogFolder(row);
|
||||
menu.Items.Add(folder);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
|
||||
if (row.HasLog)
|
||||
{
|
||||
menu.Items.Add(new Separator());
|
||||
var cur = new MenuItem { Header = (row.Entry.LogIsFolder ? "현재 폴더: " : "현재 파일: ") + row.Entry.LogPath, IsEnabled = false };
|
||||
|
|
@ -350,9 +567,9 @@ namespace JJServiceManager
|
|||
row.Entry.LogPath = "";
|
||||
row.Entry.LogIsFolder = false;
|
||||
row.LogPathChanged();
|
||||
Log($"[{row.DisplayName}] 로그 경로 해제");
|
||||
Log($"{row.LogName} 로그 경로 해제");
|
||||
SyncEntriesFromRows();
|
||||
_logWindows.EntryPathChanged(row.Entry);
|
||||
_logWindows.LocalPathChanged(row.Entry);
|
||||
};
|
||||
menu.Items.Add(clear);
|
||||
}
|
||||
|
|
@ -368,7 +585,7 @@ namespace JJServiceManager
|
|||
Filter = "로그 파일 (*.log;*.txt)|*.log;*.txt|모든 파일 (*.*)|*.*",
|
||||
CheckFileExists = true
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
|
||||
if (row.HasLog)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -383,10 +600,10 @@ namespace JJServiceManager
|
|||
row.Entry.LogPath = dlg.FileName;
|
||||
row.Entry.LogIsFolder = false;
|
||||
row.LogPathChanged();
|
||||
Log($"[{row.DisplayName}] 로그 파일 지정: {dlg.FileName}");
|
||||
Log($"{row.LogName} 로그 파일 지정: {dlg.FileName}");
|
||||
SyncEntriesFromRows();
|
||||
_logWindows.EntryPathChanged(row.Entry);
|
||||
_logWindows.Open(row.Entry);
|
||||
_logWindows.LocalPathChanged(row.Entry);
|
||||
_logWindows.Open(LogTarget.ForLocal(row.Entry));
|
||||
}
|
||||
|
||||
private void PickLogFolder(ServiceRow row)
|
||||
|
|
@ -395,7 +612,7 @@ namespace JJServiceManager
|
|||
{
|
||||
Title = $"{row.DisplayName} - 로그 폴더 선택 (가장 최근 파일을 자동으로 표시)"
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(row.Entry.LogPath))
|
||||
if (row.HasLog)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -410,13 +627,13 @@ namespace JJServiceManager
|
|||
row.Entry.LogPath = dlg.FolderName;
|
||||
row.Entry.LogIsFolder = true;
|
||||
row.LogPathChanged();
|
||||
Log($"[{row.DisplayName}] 로그 폴더 지정: {dlg.FolderName}");
|
||||
Log($"{row.LogName} 로그 폴더 지정: {dlg.FolderName}");
|
||||
SyncEntriesFromRows();
|
||||
_logWindows.EntryPathChanged(row.Entry);
|
||||
_logWindows.Open(row.Entry);
|
||||
_logWindows.LocalPathChanged(row.Entry);
|
||||
_logWindows.Open(LogTarget.ForLocal(row.Entry));
|
||||
}
|
||||
|
||||
// ───────────────────────── 상태 자동 갱신 ─────────────────────────
|
||||
// ───────────────────────── 내 PC 상태 자동 갱신 ─────────────────────────
|
||||
|
||||
private async Task RefreshStatuses(bool force)
|
||||
{
|
||||
|
|
@ -424,18 +641,14 @@ namespace JJServiceManager
|
|||
_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));
|
||||
var names = _local.Rows.Select(r => r.ServiceName).ToList();
|
||||
var result = await Task.Run(() => names.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(n => n, n => ServiceOps.GetStatus(n), StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var r in _rows)
|
||||
foreach (var r in _local.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);
|
||||
}
|
||||
if (r.StatusKnown && r.Status != st && !r.IsBusy) LogStatusChange(r, r.Status, st);
|
||||
r.Status = st;
|
||||
}
|
||||
UpdateCounts();
|
||||
|
|
@ -450,6 +663,206 @@ namespace JJServiceManager
|
|||
}
|
||||
}
|
||||
|
||||
private void LogStatusChange(ServiceRow r, ServiceControllerStatus? from, ServiceControllerStatus? to)
|
||||
{
|
||||
Log($"{r.LogName} 상태 변경: {ServiceOps.StatusText(from)} → {ServiceOps.StatusText(to)}",
|
||||
to == ServiceControllerStatus.Running ? LogLevel.Success
|
||||
: to == ServiceControllerStatus.Stopped ? LogLevel.Warning : LogLevel.Info);
|
||||
}
|
||||
|
||||
// ───────────────────────── 원격: 다른 PC 관리 (관리 역할) ─────────────────────────
|
||||
|
||||
private static string TargetsKey(AppSettings s)
|
||||
=> (s.RemoteManageEnabled ? "on;" : "off;")
|
||||
+ string.Join(";", s.Targets.Select(t => $"{t.Id}|{t.Address}|{t.Mode}|{t.Port}|{t.ProtectedPassword}"));
|
||||
|
||||
/// <summary>관리 대상 PC 목록이 바뀌면 접속을 새로 만듦</summary>
|
||||
private void ApplyRemoteManage()
|
||||
{
|
||||
var key = TargetsKey(_settings);
|
||||
if (key == _targetsKey) return;
|
||||
_targetsKey = key;
|
||||
|
||||
// 기존 원격 PC 정리
|
||||
foreach (var g in _groups.Where(g => !g.IsLocal).ToList())
|
||||
{
|
||||
g.Client?.Dispose();
|
||||
_logWindows.Close($"remote|{g.Id}|", prefix: true);
|
||||
_groups.Remove(g);
|
||||
}
|
||||
|
||||
if (_settings.RemoteManageEnabled)
|
||||
{
|
||||
foreach (var t in _settings.Targets)
|
||||
{
|
||||
var client = new RemoteClient(t);
|
||||
var g = new HostGroup
|
||||
{
|
||||
Id = t.Id,
|
||||
Client = client,
|
||||
Title = t.Label,
|
||||
StatusText = "연결 중…",
|
||||
StatusBrush = Brushes2.Pending,
|
||||
EmptyText = "연결 중…"
|
||||
};
|
||||
client.StateChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, c)));
|
||||
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
|
||||
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
|
||||
_groups.Add(g);
|
||||
client.Start();
|
||||
}
|
||||
}
|
||||
|
||||
// 열어둔 PC 탭: 없어진 PC는 닫고, 남은 PC는 새 목록으로 다시 엶
|
||||
var open = _hostTabs.Keys.ToList();
|
||||
foreach (var id in open) CloseHostTab(id, save: false);
|
||||
foreach (var id in open) OpenHostTab(id, select: false);
|
||||
SaveOpenTabs();
|
||||
|
||||
_logWindows.ChoicesChanged();
|
||||
UpdateCounts();
|
||||
}
|
||||
|
||||
private void OnRemoteState(HostGroup g, RemoteClient c)
|
||||
{
|
||||
if (!_groups.Contains(g)) return;
|
||||
switch (c.State)
|
||||
{
|
||||
case RemoteState.Connected:
|
||||
g.StatusText = "연결됨";
|
||||
g.StatusBrush = Brushes2.Running;
|
||||
g.EmptyText = "대상 PC에 등록된 서비스가 없습니다.";
|
||||
Log($"[{g.Prefix}] 연결됨", LogLevel.Success);
|
||||
break;
|
||||
case RemoteState.Connecting:
|
||||
g.StatusText = "연결 중…";
|
||||
g.StatusBrush = Brushes2.Pending;
|
||||
break;
|
||||
default:
|
||||
bool wasConnected = g.Rows.Any(r => r.StatusKnown);
|
||||
g.StatusText = (c.State == RemoteState.AuthFailed ? "인증 실패: " : "연결 안 됨: ") + c.StateMessage;
|
||||
g.StatusBrush = Brushes2.Stopped;
|
||||
g.EmptyText = g.StatusText;
|
||||
foreach (var r in g.Rows) r.MarkUnknown();
|
||||
// 같은 실패 메시지는 처음 한 번만 기록
|
||||
if (wasConnected || g.Tag != c.StateMessage)
|
||||
Log($"[{g.Prefix}] {g.StatusText}", c.State == RemoteState.AuthFailed ? LogLevel.Error : LogLevel.Warning);
|
||||
g.Tag = c.StateMessage;
|
||||
break;
|
||||
}
|
||||
if (c.State == RemoteState.Connected) g.Tag = null;
|
||||
UpdateCounts();
|
||||
}
|
||||
|
||||
private void OnRemoteServices(HostGroup g, RemoteClient c)
|
||||
{
|
||||
if (!_groups.Contains(g)) return;
|
||||
var list = c.Services;
|
||||
var names = new HashSet<string>(list.Select(s => s.ServiceName), StringComparer.OrdinalIgnoreCase);
|
||||
bool changed = false;
|
||||
|
||||
// 대상 PC에서 빠진 서비스
|
||||
foreach (var r in g.Rows.Where(r => !names.Contains(r.ServiceName)).ToList())
|
||||
{
|
||||
g.Rows.Remove(r);
|
||||
_logWindows.Close($"remote|{g.Id}|{r.ServiceName}");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 새로 생기거나 바뀐 서비스
|
||||
int index = 0;
|
||||
foreach (var info in list)
|
||||
{
|
||||
var row = g.Rows.FirstOrDefault(r => string.Equals(r.ServiceName, info.ServiceName, StringComparison.OrdinalIgnoreCase));
|
||||
if (row == null)
|
||||
{
|
||||
row = new ServiceRow(new ServiceEntry { ServiceName = info.ServiceName, DisplayName = info.DisplayName }, g);
|
||||
g.Rows.Insert(Math.Min(index, g.Rows.Count), row);
|
||||
row.UpdateFromRemote(info);
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var before = row.StatusKnown ? row.Status : null;
|
||||
bool known = row.StatusKnown;
|
||||
bool hadLog = row.HasLog;
|
||||
row.UpdateFromRemote(info);
|
||||
if (known && before != row.Status && !row.IsBusy) LogStatusChange(row, before, row.Status);
|
||||
if (hadLog != row.HasLog) changed = true;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
if (changed) _logWindows.ChoicesChanged();
|
||||
UpdateCounts();
|
||||
}
|
||||
|
||||
/// <summary>hostname 자동 등록</summary>
|
||||
private void OnHostname(HostGroup g, RemoteClient c, string host)
|
||||
{
|
||||
var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id);
|
||||
if (t == null) return;
|
||||
if (t.Hostname != host)
|
||||
{
|
||||
Log($"[{t.Address}] hostname 자동 등록: {host}");
|
||||
t.Hostname = host;
|
||||
SaveSettings();
|
||||
}
|
||||
g.Title = t.Label;
|
||||
g.TitleChanged();
|
||||
_logWindows.ChoicesChanged();
|
||||
}
|
||||
|
||||
// ───────────────────────── 원격: 원격 접속 허용 (대상 역할) ─────────────────────────
|
||||
|
||||
private void ApplyRemoteServer(bool startup)
|
||||
{
|
||||
var s = _settings;
|
||||
var key = s.RemoteServerEnabled ? $"{s.RemoteServerMode}|{s.RemoteServerPort}" : "off";
|
||||
if (key != _serverKey)
|
||||
{
|
||||
bool wasOn = _serverKey != "" && _serverKey != "off";
|
||||
_serverKey = key;
|
||||
if (s.RemoteServerEnabled)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s.RemotePasswordHash))
|
||||
{
|
||||
Log("원격 접속 허용: 접속 비밀번호가 없어 시작하지 않았습니다. (설정 → 원격 접속 허용)", LogLevel.Warning);
|
||||
_serverKey = "nopw";
|
||||
}
|
||||
else
|
||||
{
|
||||
_remoteServer.Start(s.RemoteServerMode, s.RemoteServerPort, s.RemotePasswordHash, s.RemotePasswordSalt);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_remoteServer.Stop();
|
||||
}
|
||||
|
||||
// 방화벽: 켤 때 포트 열기, 끌 때 규칙 삭제 (처음 실행 시 꺼져 있으면 건드리지 않음)
|
||||
if (s.RemoteServerEnabled || wasOn || !startup)
|
||||
{
|
||||
bool on = s.RemoteServerEnabled && _remoteServer.IsRunning;
|
||||
int port = s.RemoteServerPort;
|
||||
Task.Run(() =>
|
||||
{
|
||||
var (ok, msg) = FirewallManager.Apply(on, port);
|
||||
if (!startup || !ok) Log(msg, ok ? LogLevel.Info : LogLevel.Warning);
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_remoteServer.UpdatePassword(s.RemotePasswordHash, s.RemotePasswordSalt);
|
||||
}
|
||||
|
||||
_local.StatusText = _remoteServer.IsRunning
|
||||
? $"원격 접속 허용 중 · {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}"
|
||||
: "";
|
||||
_local.StatusBrush = Brushes2.Running;
|
||||
}
|
||||
|
||||
// ───────────────────────── Windows 종료 ─────────────────────────
|
||||
|
||||
private void Shutdown(bool restart)
|
||||
|
|
@ -524,6 +937,8 @@ namespace JJServiceManager
|
|||
_txtLogHidden.Visibility = _settings.ShowOperationLog ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
ApplyHotkey(quiet: startup);
|
||||
ApplyRemoteServer(startup);
|
||||
ApplyRemoteManage();
|
||||
}
|
||||
|
||||
private void ApplyHotkey(bool quiet)
|
||||
|
|
@ -550,9 +965,9 @@ namespace JJServiceManager
|
|||
}
|
||||
|
||||
if (_tray != null)
|
||||
_tray.Text = _settings.HotkeyEnabled
|
||||
? Trim63($"JJServiceManager ({HotkeyManager.Format(mods, key)})")
|
||||
: "JJServiceManager";
|
||||
_tray.Text = Trim63(_settings.HotkeyEnabled
|
||||
? $"JJServiceManager{AppInstance.TitleTag} ({HotkeyManager.Format(mods, key)})"
|
||||
: "JJServiceManager" + AppInstance.TitleTag);
|
||||
}
|
||||
|
||||
private static string Trim63(string s) => s.Length > 63 ? s.Substring(0, 63) : s;
|
||||
|
|
@ -603,7 +1018,7 @@ namespace JJServiceManager
|
|||
_tray = new Forms.NotifyIcon
|
||||
{
|
||||
Icon = AppIcon.Icon,
|
||||
Text = "JJServiceManager",
|
||||
Text = Trim63("JJServiceManager" + AppInstance.TitleTag),
|
||||
ContextMenuStrip = menu,
|
||||
Visible = true
|
||||
};
|
||||
|
|
@ -631,7 +1046,7 @@ namespace JJServiceManager
|
|||
{
|
||||
_trayTipShown = true;
|
||||
var hk = _settings.HotkeyEnabled ? $" 단축키 {HotkeyManager.Format((ModifierKeys)_settings.HotkeyModifiers, (Key)_settings.HotkeyKey)}로 다시 열 수 있습니다." : "";
|
||||
_tray.ShowBalloonTip(2500, "JJServiceManager", "트레이에서 계속 실행 중입니다." + hk, Forms.ToolTipIcon.Info);
|
||||
_tray.ShowBalloonTip(2500, "JJServiceManager" + AppInstance.TitleTag, "트레이에서 계속 실행 중입니다." + hk, Forms.ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -696,6 +1111,8 @@ namespace JJServiceManager
|
|||
_statusTimer.Stop();
|
||||
SaveSettings();
|
||||
_logWindows.CloseAll();
|
||||
foreach (var g in _groups) g.Client?.Dispose();
|
||||
_remoteServer.Dispose();
|
||||
_hotkey?.Dispose();
|
||||
if (_tray != null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,70 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.ServiceProcess;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using JJServiceManager.Remote;
|
||||
|
||||
namespace JJServiceManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 초기화면의 PC 하나 (내 PC 또는 관리 대상 PC)와 그 PC의 서비스 목록
|
||||
/// </summary>
|
||||
public sealed class HostGroup : ObservableObject
|
||||
{
|
||||
public const string LocalId = "local";
|
||||
|
||||
private string _title = "";
|
||||
private string _statusText = "";
|
||||
private Brush _statusBrush = Brushes2.Unknown;
|
||||
private bool _isExpanded = true;
|
||||
private string _emptyText = "";
|
||||
|
||||
/// <summary>"local" 또는 대상 PC Id</summary>
|
||||
public string Id { get; init; } = LocalId;
|
||||
public bool IsLocal => Id == LocalId;
|
||||
|
||||
/// <summary>원격 PC일 때 접속</summary>
|
||||
public RemoteClient Client { get; init; }
|
||||
|
||||
public ObservableCollection<ServiceRow> Rows { get; } = new ObservableCollection<ServiceRow>();
|
||||
|
||||
/// <summary>제목: "내 PC (HOSTNAME)" 또는 "192.168.0.10 / SERVER01"</summary>
|
||||
public string Title { get => _title; set => Set(ref _title, value); }
|
||||
|
||||
/// <summary>서비스 앞에 붙는 표시 (원격: "IP/hostname")</summary>
|
||||
public string Prefix => IsLocal ? "" : Title.Replace(" / ", "/");
|
||||
|
||||
public string StatusText { get => _statusText; set => Set(ref _statusText, value); }
|
||||
public Brush StatusBrush { get => _statusBrush; set => Set(ref _statusBrush, value); }
|
||||
public bool IsExpanded { get => _isExpanded; set => Set(ref _isExpanded, value); }
|
||||
|
||||
public string EmptyText
|
||||
{
|
||||
get => _emptyText;
|
||||
set { if (Set(ref _emptyText, value)) OnPropertyChanged(nameof(EmptyVisibility)); }
|
||||
}
|
||||
|
||||
public Visibility EmptyVisibility => Rows.Count == 0 && !string.IsNullOrEmpty(_emptyText) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
/// <summary>마지막으로 기록한 연결 실패 메시지 (같은 메시지 반복 기록 방지)</summary>
|
||||
public string Tag { get; set; }
|
||||
|
||||
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
|
||||
|
||||
public HostGroup()
|
||||
{
|
||||
Rows.CollectionChanged += (s, e) => OnPropertyChanged(nameof(EmptyVisibility));
|
||||
}
|
||||
|
||||
public void TitleChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(Prefix));
|
||||
foreach (var r in Rows) r.HostChanged();
|
||||
}
|
||||
|
||||
public override string ToString() => Title;
|
||||
}
|
||||
|
||||
/// <summary>초기화면 서비스 목록의 한 줄</summary>
|
||||
public sealed class ServiceRow : ObservableObject
|
||||
{
|
||||
|
|
@ -13,13 +74,29 @@ namespace JJServiceManager
|
|||
private bool _isBusy;
|
||||
|
||||
public ServiceEntry Entry { get; }
|
||||
public HostGroup Host { get; }
|
||||
public bool IsRemote => Host != null && !Host.IsLocal;
|
||||
|
||||
public ServiceRow(ServiceEntry entry) => Entry = entry;
|
||||
public ServiceRow(ServiceEntry entry, HostGroup host)
|
||||
{
|
||||
Entry = entry;
|
||||
Host = host;
|
||||
}
|
||||
|
||||
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우클릭: 로그 경로 지정";
|
||||
|
||||
/// <summary>서비스 앞에 붙는 "IP/hostname" (내 PC는 없음)</summary>
|
||||
public string HostPrefix => IsRemote ? Host.Prefix : "";
|
||||
public Visibility HostPrefixVisibility => IsRemote ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
/// <summary>로그에 쓰는 이름: "[IP/hostname] [서비스]" 또는 "[서비스]"</summary>
|
||||
public string LogName => IsRemote ? $"[{Host.Prefix}] [{DisplayName}]" : $"[{DisplayName}]";
|
||||
|
||||
public string Tooltip => IsRemote
|
||||
? $"대상 PC: {Host.Title}\n서비스명: {Entry.ServiceName}"
|
||||
: $"서비스명: {Entry.ServiceName}\n우클릭: 로그 경로 지정";
|
||||
|
||||
public bool IsChecked { get => _isChecked; set => Set(ref _isChecked, value); }
|
||||
|
||||
|
|
@ -47,7 +124,17 @@ namespace JJServiceManager
|
|||
|
||||
public bool StatusKnown => _statusKnown;
|
||||
|
||||
public string StatusText => _statusKnown ? ServiceOps.StatusText(_status) : "확인 중…";
|
||||
/// <summary>원격 PC 연결이 끊긴 경우 등 상태를 모를 때</summary>
|
||||
public void MarkUnknown()
|
||||
{
|
||||
if (!_statusKnown) return;
|
||||
_statusKnown = false;
|
||||
OnPropertyChanged(nameof(Status));
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
|
||||
public string StatusText => _statusKnown ? ServiceOps.StatusText(_status) : (IsRemote ? "연결 안 됨" : "확인 중…");
|
||||
|
||||
public Brush StatusBrush => !_statusKnown ? Brushes2.Unknown : _status switch
|
||||
{
|
||||
|
|
@ -58,16 +145,54 @@ namespace JJServiceManager
|
|||
_ => Brushes2.Pending
|
||||
};
|
||||
|
||||
public string LogTooltip => string.IsNullOrWhiteSpace(Entry.LogPath)
|
||||
? "로그 경로가 없습니다. 누르면 지정합니다. (우클릭: 변경)"
|
||||
: (Entry.LogIsFolder ? "폴더(최신 파일): " : "파일: ") + Entry.LogPath + "\n우클릭: 변경";
|
||||
public bool HasLog => !string.IsNullOrWhiteSpace(Entry.LogPath);
|
||||
|
||||
public FontWeight LogFontWeight => string.IsNullOrWhiteSpace(Entry.LogPath) ? FontWeights.Normal : FontWeights.Bold;
|
||||
public string LogTooltip
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsRemote)
|
||||
return HasLog
|
||||
? "대상 PC 로그: " + (Entry.LogIsFolder ? "폴더(최신 파일) " : "") + Entry.LogPath
|
||||
: "대상 PC에서 로그 경로가 지정되지 않았습니다.";
|
||||
return HasLog
|
||||
? (Entry.LogIsFolder ? "폴더(최신 파일): " : "파일: ") + Entry.LogPath + "\n우클릭: 변경"
|
||||
: "로그 경로가 없습니다. 누르면 지정합니다. (우클릭: 변경)";
|
||||
}
|
||||
}
|
||||
|
||||
public FontWeight LogFontWeight => HasLog ? FontWeights.Bold : FontWeights.Normal;
|
||||
|
||||
public void LogPathChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasLog));
|
||||
OnPropertyChanged(nameof(LogTooltip));
|
||||
OnPropertyChanged(nameof(LogFontWeight));
|
||||
}
|
||||
|
||||
public void HostChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(HostPrefix));
|
||||
OnPropertyChanged(nameof(Tooltip));
|
||||
OnPropertyChanged(nameof(LogName));
|
||||
}
|
||||
|
||||
/// <summary>원격 목록을 새로 받았을 때 이름/로그 경로 갱신</summary>
|
||||
public void UpdateFromRemote(RemoteServiceInfo info)
|
||||
{
|
||||
bool nameChanged = Entry.DisplayName != info.DisplayName;
|
||||
bool logChanged = Entry.LogPath != info.LogPath || Entry.LogIsFolder != info.LogIsFolder;
|
||||
Entry.DisplayName = info.DisplayName;
|
||||
Entry.LogPath = info.LogPath;
|
||||
Entry.LogIsFolder = info.LogIsFolder;
|
||||
if (nameChanged)
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
OnPropertyChanged(nameof(ServiceNameText));
|
||||
OnPropertyChanged(nameof(LogName));
|
||||
}
|
||||
if (logChanged) LogPathChanged();
|
||||
Status = info.Status.HasValue ? (ServiceControllerStatus?)info.Status.Value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,68 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using JJServiceManager.Remote;
|
||||
|
||||
namespace JJServiceManager
|
||||
{
|
||||
/// <summary>설정 화면</summary>
|
||||
/// <summary>설정 화면 (탭: 일반 / 원격 접속 허용 / 관리 대상 PC)</summary>
|
||||
public sealed class SettingsWindow : Window
|
||||
{
|
||||
private readonly AppSettings _s;
|
||||
private readonly CheckBox _startup, _hotkey, _opLog, _shutdownButtons;
|
||||
|
||||
// 일반
|
||||
private readonly CheckBox _startup, _hotkey, _opLog, _shutdownButtons, _confirmLocal, _confirmRemote;
|
||||
private readonly TextBox _hotkeyBox;
|
||||
private readonly ComboBox _logMode, _esc;
|
||||
private ModifierKeys _mods;
|
||||
private Key _key;
|
||||
|
||||
// 원격 접속 허용
|
||||
private readonly CheckBox _server;
|
||||
private readonly ComboBox _serverMode;
|
||||
private readonly TextBox _serverPort;
|
||||
private readonly TextBlock _serverPortHint, _pwState, _serverInfo;
|
||||
private readonly PasswordBox _pwd1, _pwd2;
|
||||
private int _sshPort, _tcpPort;
|
||||
private int _shownMode = -1;
|
||||
|
||||
// 관리 대상 PC
|
||||
private readonly CheckBox _manage;
|
||||
private readonly ListBox _targets;
|
||||
private readonly TextBox _addr, _hostname, _port;
|
||||
private readonly ComboBox _mode;
|
||||
private readonly PasswordBox _pwdTarget;
|
||||
private readonly TextBlock _pwTargetState, _test;
|
||||
private readonly Button _btnTest;
|
||||
private readonly ObservableCollection<TargetItem> _items = new ObservableCollection<TargetItem>();
|
||||
|
||||
/// <summary>목록 표시용</summary>
|
||||
private sealed class TargetItem : ObservableObject
|
||||
{
|
||||
public RemoteTarget T { get; }
|
||||
public TargetItem(RemoteTarget t) => T = t;
|
||||
public string Label => T.Label;
|
||||
public string ModeText => (T.Mode == RemoteMode.Ssh ? "SSH" : "지정 포트") + " " + T.Port;
|
||||
public void Changed()
|
||||
{
|
||||
OnPropertyChanged(nameof(Label));
|
||||
OnPropertyChanged(nameof(ModeText));
|
||||
}
|
||||
}
|
||||
|
||||
public SettingsWindow(AppSettings current)
|
||||
{
|
||||
_s = current;
|
||||
|
||||
Title = "설정";
|
||||
Width = 720;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
Title = "설정" + AppInstance.TitleTag;
|
||||
Width = 780;
|
||||
Height = 640;
|
||||
MinHeight = 560;
|
||||
ResizeMode = ResizeMode.CanResize;
|
||||
FontFamily = new FontFamily("Malgun Gothic");
|
||||
FontSize = 13;
|
||||
Background = Brushes2.Make("#FAFAFA");
|
||||
|
|
@ -33,21 +73,25 @@ namespace JJServiceManager
|
|||
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");
|
||||
_confirmLocal = root.Find<CheckBox>("chkConfirmLocal");
|
||||
_confirmRemote = root.Find<CheckBox>("chkConfirmRemote");
|
||||
_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;
|
||||
_confirmLocal.IsChecked = current.ConfirmLocalStartStop;
|
||||
_confirmRemote.IsChecked = current.ConfirmRemoteStartStop;
|
||||
_logMode.SelectedIndex = (int)current.LogWindowMode;
|
||||
_esc.SelectedIndex = (int)current.EscAction;
|
||||
_mods = (ModifierKeys)current.HotkeyModifiers;
|
||||
|
|
@ -59,6 +103,57 @@ namespace JJServiceManager
|
|||
_hotkeyBox.LostKeyboardFocus += (s, e) => { _hotkeyBox.Background = Brushes2.Make("#FAFAFA"); ShowHotkey(); };
|
||||
_hotkeyBox.PreviewKeyDown += OnHotkeyKeyDown;
|
||||
|
||||
// ───────── 원격 접속 허용 ─────────
|
||||
_server = root.Find<CheckBox>("chkServer");
|
||||
_serverMode = root.Find<ComboBox>("cmbServerMode");
|
||||
_serverPort = root.Find<TextBox>("txtServerPort");
|
||||
_serverPortHint = root.Find<TextBlock>("txtServerPortHint");
|
||||
_pwd1 = root.Find<PasswordBox>("pwdServer");
|
||||
_pwd2 = root.Find<PasswordBox>("pwdServer2");
|
||||
_pwState = root.Find<TextBlock>("txtPwState");
|
||||
_serverInfo = root.Find<TextBlock>("txtServerInfo");
|
||||
|
||||
_sshPort = current.RemoteServerSshPort;
|
||||
_tcpPort = current.RemoteServerTcpPort;
|
||||
_server.IsChecked = current.RemoteServerEnabled;
|
||||
_serverMode.SelectionChanged += (s, e) => OnServerModeChanged();
|
||||
_serverMode.SelectedIndex = (int)current.RemoteServerMode;
|
||||
_pwState.Text = string.IsNullOrEmpty(current.RemotePasswordHash)
|
||||
? "비밀번호가 설정되어 있지 않습니다. 원격 접속을 허용하려면 입력하세요."
|
||||
: "비밀번호가 설정되어 있습니다. 바꿀 때만 입력하세요.";
|
||||
UpdateServerInfo();
|
||||
|
||||
// ───────── 관리 대상 PC ─────────
|
||||
_manage = root.Find<CheckBox>("chkManage");
|
||||
_targets = root.Find<ListBox>("lstTargets");
|
||||
_addr = root.Find<TextBox>("txtAddr");
|
||||
_hostname = root.Find<TextBox>("txtHostname");
|
||||
_port = root.Find<TextBox>("txtPort");
|
||||
_mode = root.Find<ComboBox>("cmbMode");
|
||||
_pwdTarget = root.Find<PasswordBox>("pwdTarget");
|
||||
_pwTargetState = root.Find<TextBlock>("txtPwTarget");
|
||||
_test = root.Find<TextBlock>("txtTest");
|
||||
_btnTest = root.Find<Button>("btnTest");
|
||||
|
||||
_manage.IsChecked = current.RemoteManageEnabled;
|
||||
foreach (var t in current.Targets) _items.Add(new TargetItem(t.Clone()));
|
||||
_targets.ItemsSource = _items;
|
||||
_targets.SelectionChanged += (s, e) => ShowTarget(_targets.SelectedItem as TargetItem);
|
||||
_mode.SelectionChanged += (s, e) =>
|
||||
{
|
||||
// 방식을 바꾸면 기본 포트로 (직접 바꾼 포트가 아닐 때)
|
||||
if (_port.Text == "" || _port.Text == "9988" || _port.Text == "2222")
|
||||
_port.Text = _mode.SelectedIndex == 0 ? "2222" : "9988";
|
||||
};
|
||||
ClearForm();
|
||||
|
||||
root.Find<Button>("btnNew").Click += (s, e) => { _targets.SelectedItem = null; ClearForm(); };
|
||||
root.Find<Button>("btnAddTarget").Click += (s, e) => AddTarget();
|
||||
root.Find<Button>("btnUpdateTarget").Click += (s, e) => UpdateTarget();
|
||||
root.Find<Button>("btnDeleteTarget").Click += (s, e) => DeleteTarget();
|
||||
_btnTest.Click += async (s, e) => await TestTarget();
|
||||
|
||||
// ───────── 확인 / 취소 ─────────
|
||||
root.Find<Button>("btnOk").Click += (s, e) => Save();
|
||||
root.Find<Button>("btnCancel").Click += (s, e) => Close();
|
||||
PreviewKeyDown += (s, e) =>
|
||||
|
|
@ -67,6 +162,8 @@ namespace JJServiceManager
|
|||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── 단축키 ─────────────────────────
|
||||
|
||||
private void OnHotkeyKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
|
@ -102,17 +199,216 @@ namespace JJServiceManager
|
|||
|
||||
private void ShowHotkey() => _hotkeyBox.Text = HotkeyManager.Format(_mods, _key);
|
||||
|
||||
// ───────────────────────── 원격 접속 허용 ─────────────────────────
|
||||
|
||||
private void OnServerModeChanged()
|
||||
{
|
||||
// 방식마다 포트를 따로 기억
|
||||
if (_shownMode == 0 && int.TryParse(_serverPort.Text, out var a)) _sshPort = a;
|
||||
if (_shownMode == 1 && int.TryParse(_serverPort.Text, out var b)) _tcpPort = b;
|
||||
_shownMode = _serverMode.SelectedIndex;
|
||||
_serverPort.Text = (_shownMode == 0 ? _sshPort : _tcpPort).ToString();
|
||||
_serverPortHint.Text = _shownMode == 0 ? "기본 2222 (Windows OpenSSH의 22번과 겹치지 않게)" : "기본 9988";
|
||||
UpdateServerInfo();
|
||||
}
|
||||
|
||||
private void UpdateServerInfo()
|
||||
{
|
||||
var ips = NetInfo.LocalIPv4();
|
||||
_serverInfo.Text =
|
||||
$"이 PC: {Environment.MachineName} IP: {(ips.Count == 0 ? "(확인 안 됨)" : string.Join(", ", ips))}\n" +
|
||||
"관리 PC의 [관리 대상 PC]에 위 IP, 같은 통신 방식과 포트, 접속 비밀번호를 입력하면 됩니다.\n" +
|
||||
"원격 접속 허용을 켜면 Windows 방화벽에 해당 TCP 포트를 자동으로 열고, 끄면 규칙을 지웁니다.\n" +
|
||||
"원격에서는 이 PC에 등록된 서비스만 보고 Start / Stop 할 수 있습니다.";
|
||||
}
|
||||
|
||||
// ───────────────────────── 관리 대상 PC ─────────────────────────
|
||||
|
||||
private void ClearForm()
|
||||
{
|
||||
_addr.Text = "";
|
||||
_hostname.Text = "";
|
||||
_mode.SelectedIndex = 1;
|
||||
_port.Text = "9988";
|
||||
_pwdTarget.Password = "";
|
||||
_pwTargetState.Text = "대상 PC의 접속 비밀번호";
|
||||
_test.Text = "";
|
||||
}
|
||||
|
||||
private void ShowTarget(TargetItem item)
|
||||
{
|
||||
if (item == null) return;
|
||||
var t = item.T;
|
||||
_addr.Text = t.Address;
|
||||
_hostname.Text = t.Hostname;
|
||||
_mode.SelectedIndex = (int)t.Mode;
|
||||
_port.Text = t.Port.ToString();
|
||||
_pwdTarget.Password = "";
|
||||
_pwTargetState.Text = string.IsNullOrEmpty(t.ProtectedPassword) ? "비밀번호 없음 - 입력하세요" : "저장됨 (바꿀 때만 입력)";
|
||||
_test.Text = "";
|
||||
}
|
||||
|
||||
/// <summary>입력값 검사. 문제가 있으면 메시지</summary>
|
||||
private string ReadForm(out string addr, out RemoteMode mode, out int port)
|
||||
{
|
||||
addr = _addr.Text.Trim();
|
||||
mode = (RemoteMode)Math.Max(0, _mode.SelectedIndex);
|
||||
port = 0;
|
||||
if (addr.Length == 0) return "대상 IP를 입력하세요.";
|
||||
if (addr.Contains(' ')) return "대상 IP에 공백이 있습니다.";
|
||||
if (!int.TryParse(_port.Text.Trim(), out port) || port < 1 || port > 65535) return "포트는 1~65535 사이 숫자로 입력하세요.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private void AddTarget()
|
||||
{
|
||||
var err = ReadForm(out var addr, out var mode, out var port);
|
||||
if (err == null && _pwdTarget.Password.Length == 0) err = "비밀번호를 입력하세요.";
|
||||
if (err == null && _items.Any(i => string.Equals(i.T.Address, addr, StringComparison.OrdinalIgnoreCase) && i.T.Port == port))
|
||||
err = "같은 IP와 포트가 이미 등록되어 있습니다.";
|
||||
if (err != null) { ShowTest(err, false); return; }
|
||||
|
||||
var t = new RemoteTarget
|
||||
{
|
||||
Address = addr,
|
||||
Mode = mode,
|
||||
Port = port,
|
||||
Hostname = _hostname.Text.Trim(),
|
||||
ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password)
|
||||
};
|
||||
var item = new TargetItem(t);
|
||||
_items.Add(item);
|
||||
_targets.SelectedItem = item;
|
||||
ShowTest("추가했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||
}
|
||||
|
||||
private void UpdateTarget()
|
||||
{
|
||||
if (_targets.SelectedItem is not TargetItem item) { ShowTest("수정할 PC를 목록에서 고르세요.", false); return; }
|
||||
var err = ReadForm(out var addr, out var mode, out var port);
|
||||
if (err != null) { ShowTest(err, false); return; }
|
||||
|
||||
var t = item.T;
|
||||
if (!string.Equals(t.Address, addr, StringComparison.OrdinalIgnoreCase)) t.Hostname = ""; // IP가 바뀌면 hostname 다시 확인
|
||||
t.Address = addr;
|
||||
t.Mode = mode;
|
||||
t.Port = port;
|
||||
if (_hostname.Text.Trim().Length > 0 && t.Hostname.Length == 0) t.Hostname = _hostname.Text.Trim();
|
||||
if (_pwdTarget.Password.Length > 0) t.ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password);
|
||||
item.Changed();
|
||||
ShowTarget(item);
|
||||
ShowTest("수정했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||
}
|
||||
|
||||
private void DeleteTarget()
|
||||
{
|
||||
if (_targets.SelectedItem is not TargetItem item) { ShowTest("삭제할 PC를 목록에서 고르세요.", false); return; }
|
||||
if (!Dialogs.Confirm(this, $"{item.Label} 을(를) 관리 대상에서 삭제할까요?", "삭제")) return;
|
||||
_items.Remove(item);
|
||||
ClearForm();
|
||||
ShowTest("삭제했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task TestTarget()
|
||||
{
|
||||
var err = ReadForm(out var addr, out var mode, out var port);
|
||||
if (err != null) { ShowTest(err, false); return; }
|
||||
|
||||
// 입력한 비밀번호가 없으면 저장된 비밀번호 사용
|
||||
var pw = _pwdTarget.Password;
|
||||
if (pw.Length == 0 && _targets.SelectedItem is TargetItem sel) pw = RemoteSecurity.Unprotect(sel.T.ProtectedPassword);
|
||||
if (pw.Length == 0) { ShowTest("비밀번호를 입력하세요.", false); return; }
|
||||
|
||||
_btnTest.IsEnabled = false;
|
||||
ShowTest($"{addr}:{port} 연결 중…", true);
|
||||
try
|
||||
{
|
||||
var host = await RemoteClient.TestAsync(new RemoteTarget { Address = addr, Mode = mode, Port = port }, pw);
|
||||
_hostname.Text = host;
|
||||
if (_targets.SelectedItem is TargetItem item
|
||||
&& string.Equals(item.T.Address, addr, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.T.Hostname = host;
|
||||
item.Changed();
|
||||
}
|
||||
ShowTest($"연결 성공: {host}", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowTest("연결 실패: " + RemoteClient.Friendly(ex), false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_btnTest.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowTest(string msg, bool ok)
|
||||
{
|
||||
_test.Text = msg;
|
||||
_test.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
|
||||
}
|
||||
|
||||
// ───────────────────────── 저장 ─────────────────────────
|
||||
|
||||
private void Save()
|
||||
{
|
||||
// 원격 접속 허용 검사
|
||||
if (!int.TryParse(_serverPort.Text.Trim(), out var sp) || sp < 1 || sp > 65535)
|
||||
{
|
||||
Fail(1, "원격 접속 허용 포트는 1~65535 사이 숫자로 입력하세요.");
|
||||
return;
|
||||
}
|
||||
if (_serverMode.SelectedIndex == 0) _sshPort = sp; else _tcpPort = sp;
|
||||
|
||||
string newHash = null, newSalt = null;
|
||||
if (_pwd1.Password.Length > 0 || _pwd2.Password.Length > 0)
|
||||
{
|
||||
if (_pwd1.Password != _pwd2.Password) { Fail(1, "접속 비밀번호와 확인이 다릅니다."); return; }
|
||||
if (_pwd1.Password.Length < 4) { Fail(1, "접속 비밀번호는 4자 이상으로 입력하세요."); return; }
|
||||
(newHash, newSalt) = RemoteSecurity.HashPassword(_pwd1.Password);
|
||||
}
|
||||
bool hasPassword = newHash != null || !string.IsNullOrEmpty(_s.RemotePasswordHash);
|
||||
if (_server.IsChecked == true && !hasPassword)
|
||||
{
|
||||
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반
|
||||
_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);
|
||||
_s.ConfirmLocalStartStop = _confirmLocal.IsChecked == true;
|
||||
_s.ConfirmRemoteStartStop = _confirmRemote.IsChecked == true;
|
||||
_s.LogWindowMode = (LogWindowMode)Math.Max(0, _logMode.SelectedIndex);
|
||||
_s.EscAction = (EscAction)Math.Max(0, _esc.SelectedIndex);
|
||||
|
||||
// 원격 접속 허용
|
||||
_s.RemoteServerEnabled = _server.IsChecked == true;
|
||||
_s.RemoteServerMode = (RemoteMode)Math.Max(0, _serverMode.SelectedIndex);
|
||||
_s.RemoteServerSshPort = _sshPort;
|
||||
_s.RemoteServerTcpPort = _tcpPort;
|
||||
if (newHash != null)
|
||||
{
|
||||
_s.RemotePasswordHash = newHash;
|
||||
_s.RemotePasswordSalt = newSalt;
|
||||
}
|
||||
|
||||
// 관리 대상 PC
|
||||
_s.RemoteManageEnabled = _manage.IsChecked == true;
|
||||
_s.Targets = _items.Select(i => i.T.Clone()).ToList();
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void Fail(int tabIndex, string msg)
|
||||
{
|
||||
if (Content is FrameworkElement root && root.FindName("tabs") is TabControl tc) tc.SelectedIndex = tabIndex;
|
||||
Dialogs.Error(this, msg, "설정");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue