JJServiceManagerEnt/Windows/MainWindow.cs

1532 lines
67 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Security.Principal;
using System.Text.Json.Nodes;
using System.ServiceProcess;
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
{
/// <summary>초기화면</summary>
public sealed class MainWindow : Window
{
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
private readonly AppSettings _settings;
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 PassiveHub _passiveHub;
private readonly RelayAgent _relayAgent;
private readonly RelayManager _relayManager;
private string _relayAgentKey = "", _relayManageKey = "";
private string _relayLoginNotice;
/// <summary>중계 기기 그룹의 표시용 대상 정보 (그룹 Id → 대상)</summary>
private readonly Dictionary<string, RemoteTarget> _relayTargets = new Dictionary<string, RemoteTarget>(StringComparer.OrdinalIgnoreCase);
/// <summary>시작할 때 열려 있던 중계 기기 탭 (로그인 뒤 기기 목록을 받으면 다시 엶)</summary>
private readonly HashSet<string> _pendingRelayTabs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private RemoteIndicatorWindow _indicator;
private readonly Dictionary<string, RemoteViewerWindow> _viewers = new Dictionary<string, RemoteViewerWindow>(StringComparer.OrdinalIgnoreCase);
private string _passiveKey = "";
private string _passiveListenKey = "";
private readonly TabControl _tabs;
private readonly TabItem _tabAll;
private readonly ComboBox _cmbAddTab;
private readonly CheckBox _chkAll;
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;
private bool _exiting;
private bool _refreshing;
private bool _trayTipShown;
private bool _modalOpen;
public MainWindow(AppSettings settings)
{
_settings = settings;
Title = "JJServiceManager - 서비스 관리" + AppInstance.TitleTag;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#F5F7F8");
MinWidth = 860;
MinHeight = 560;
AppIcon.Apply(this);
RestoreWindowBounds(settings);
_root = XamlLoader.LoadElement("MainWindow.xaml");
Content = _root;
_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, LogChoices);
// 내 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();
_chkAll.Click += (s, e) =>
{
bool v = _chkAll.IsChecked == true;
foreach (var r in VisibleRows()) r.IsChecked = v;
};
// 서비스 줄의 Start / Stop / Status / Log 버튼 (모든 탭)
_tabs.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OnRowButton));
// 우클릭: 로그 경로 지정 메뉴
_tabs.AddHandler(UIElement.PreviewMouseRightButtonUpEvent, new MouseButtonEventHandler(OnRowRightClick), true);
PreviewKeyDown += OnPreviewKeyDown;
// 처음 뜰 때 다른 창 뒤에 가려지지 않도록 맨 앞으로
ContentRendered += (s, e) =>
{
Activate();
Topmost = true;
Topmost = false;
};
// 내 PC 상태 자동 갱신 (2초)
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
// 창 핸들을 미리 만들어 트레이 시작 상태에서도 단축키가 동작하게 함
new WindowInteropHelper(this).EnsureHandle();
_hotkey = new HotkeyManager(new WindowInteropHelper(this).Handle);
_hotkey.Pressed += ToggleVisibility;
// 원격 접속 허용 (대상 역할)
_remoteServer = new RemoteServer(
() => _servicesSnapshot,
(m, l) => Log(m, l),
() => Dispatcher.BeginInvoke(new Action(() => _ = RefreshStatuses(true))),
() => new ScreenPolicy(_settings.RemoteAllowView, _settings.RemoteAllowView && _settings.RemoteAllowControl, _settings.RemoteRequireApproval),
(who, control) => Dispatcher.InvokeAsync(() => ApprovalWindow.AskAsync(who, control)).Task.Unwrap());
_remoteServer.ScreenViewersChanged += list => Dispatcher.BeginInvoke(new Action(() =>
{
if (_indicator == null)
{
_indicator = new RemoteIndicatorWindow();
_indicator.DisconnectRequested += () =>
{
Log("[원격 화면] 이 PC에서 연결 끊기", LogLevel.Warning);
_remoteServer.StopAllScreens();
};
}
_indicator.Update(list);
}));
// 관리 PC: Passive 대상 PC의 접속을 받는 곳
_passiveHub = new PassiveHub((m, l) => Log(m, l));
// 중계 서버: 대상 PC 역할 (이 PC를 서버에 등록해 두고 연결 요청을 받음)
_relayAgent = new RelayAgent(_remoteServer, (m, l) => Log(m, l));
_relayAgent.StateChanged += a => Dispatcher.BeginInvoke(new Action(UpdateLocalStatus));
_relayAgent.NameChanged += name => Dispatcher.BeginInvoke(new Action(() =>
{
if (_settings.RelayDeviceName != name) { _settings.RelayDeviceName = name; SaveSettings(); }
}));
_relayAgent.Revoked += why => Dispatcher.BeginInvoke(new Action(() =>
{
_relayAgentKey = "revoked";
UpdateLocalStatus();
}));
// 중계 서버: 관리 PC 역할 (로그인 → 권한 있는 기기를 자동으로 표시)
_relayManager = new RelayManager((m, l) => Log(m, l));
_relayManager.DevicesChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
_relayManager.Presence += (id, online) => Dispatcher.BeginInvoke(new Action(() => OnRelayPresence(id, online)));
_relayManager.StateChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
_relayManager.LoginRequired += msg => Dispatcher.BeginInvoke(new Action(() =>
{
if (_relayLoginNotice == msg) return;
_relayLoginNotice = msg;
Log(msg, LogLevel.Warning);
}));
CreateTray();
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : "") + AppInstance.TitleTag, LogLevel.Notice);
if (!IsAdmin()) Log("관리자 권한이 아닙니다. 서비스 Start/Stop이 실패할 수 있습니다.", LogLevel.Warning);
if (_local.Rows.Count == 0) Log("[추가] 버튼을 눌러 관리할 서비스를 등록하세요.");
ApplySettings(startup: true);
RestoreHostTabs();
UpdateCounts();
_ = RefreshStatuses(true);
_statusTimer.Start();
// 컴퓨터 시작시 기동이 켜져 있으면 실행 파일 위치가 바뀌었을 경우를 대비해 다시 등록
if (_settings.RunAtStartup) Task.Run(() => StartupManager.Enable());
}
// ───────────────────────── 로그 ─────────────────────────
private void Log(string text, LogLevel level = LogLevel.Info)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => Log(text, level)));
return;
}
OperationLogFile.Append(DateTime.Now, text);
if (_settings.ShowOperationLog) _log.Add(text, level);
}
// ───────────────────────── 목록 / 탭 ─────────────────────────
private 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()
{
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 = _local.Rows.Select(r => r.Entry).ToList();
UpdateSnapshot();
SaveSettings();
_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.Concat(_pendingRelayTabs).Distinct().ToList();
SaveSettings();
}
private void RestoreHostTabs()
{
foreach (var id in _settings.OpenHostTabs.ToList())
{
if (id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase) && !_groups.Any(g => g.Id == id))
_pendingRelayTabs.Add(id); // 로그인 뒤 기기 목록을 받으면 엶
else
OpenHostTab(id, select: false);
}
// 없어진 PC 탭은 정리 (중계 기기 탭은 유지)
_settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
}
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
private void AddServices()
{
if (_modalOpen) return;
var picker = new ServicePickerWindow(_local.Rows.Select(r => r.ServiceName)) { Owner = this };
bool? ok;
_modalOpen = true;
try { ok = picker.ShowDialog(); }
finally { _modalOpen = false; }
if (ok != true || picker.Result == null) return;
var chosen = picker.Result;
var chosenNames = new HashSet<string>(chosen.Select(c => c.ServiceName), StringComparer.OrdinalIgnoreCase);
// 체크 해제된 기존 항목은 목록에서 뺌
var removed = _local.Rows.Where(r => !chosenNames.Contains(r.ServiceName)).ToList();
foreach (var r in removed)
{
_local.Rows.Remove(r);
_logWindows.Close("local|" + r.ServiceName);
}
// 새로 체크된 항목 추가 (기존 순서 유지, 새 항목은 뒤에)
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;
_local.Rows.Add(new ServiceRow(new ServiceEntry { ServiceName = c.ServiceName, DisplayName = c.DisplayName }, _local));
added.Add(c.DisplayName);
}
if (added.Count > 0) Log($"서비스 추가: {string.Join(", ", added)}", LogLevel.Success);
if (removed.Count > 0) Log($"목록에서 제외: {string.Join(", ", removed.Select(r => r.DisplayName))}");
if (added.Count == 0 && removed.Count == 0) Log("변경 사항 없음");
SyncEntriesFromRows();
_ = RefreshStatuses(true);
}
private void DeleteChecked()
{
var checkedRows = AllRows().Where(r => r.IsChecked).ToList();
var targets = checkedRows.Where(r => !r.IsRemote).ToList();
if (targets.Count == 0)
{
Dialogs.Info(this, checkedRows.Count > 0
? "원격 PC의 서비스는 여기서 삭제할 수 없습니다. (대상 PC에서 관리)\n삭제할 내 PC 서비스를 체크하세요."
: "삭제할 서비스를 체크하세요.");
return;
}
var names = string.Join("\n", targets.Select(t => " · " + t.DisplayName));
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)
{
_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)
{
if (e.OriginalSource is Button hb && hb.DataContext is HostGroup hg && hb.Tag as string == "remoteDesktop")
{
e.Handled = true;
OpenRemoteDesktop(hg);
return;
}
if (e.OriginalSource is Button pb && pb.DataContext is HostGroup pg && pb.Tag as string == "relayPassword")
{
e.Handled = true;
AskRelayPassword(pg);
return;
}
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
switch (b.Tag as string)
{
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 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.LogName} {what} 요청");
var sw = Stopwatch.StartNew();
try
{
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.LogName} {ex.Message}", LogLevel.Warning); }
catch (Exception ex) { Log($"{row.LogName} {what} 실패: {ServiceOps.Describe(ex)}", LogLevel.Error); }
finally
{
row.IsBusy = false;
await RefreshStatuses(true);
}
}
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.LogName} {what} 요청 (원격)");
try
{
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 (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;
}
}
private async Task RunChecked(bool start)
{
var targets = AllRows().Where(r => r.IsChecked).ToList();
var what = start ? "시작" : "중지";
if (targets.Count == 0)
{
Dialogs.Info(this, $"{what}할 서비스를 체크하세요.");
return;
}
// 여러 개를 한꺼번에 할 때는 숫자를 한 번만 입력
if (!ConfirmStartStop(start, targets)) return;
Log($"선택한 {targets.Count}개 서비스 {what}", LogLevel.Notice);
foreach (var r in targets)
{
if (start) await StartService(r);
else await StopService(r);
}
}
private async Task ShowStatus(ServiceRow row)
{
try
{
var lines = 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)
{
var msg = row.IsRemote ? RemoteClient.Friendly(ex) : ServiceOps.Describe(ex);
Log($"{row.LogName} 상태 조회 실패: {msg}", LogLevel.Error);
}
if (!row.IsRemote) await RefreshStatuses(true);
}
private void OpenLog(ServiceRow row, FrameworkElement anchor)
{
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(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)
};
}
// ───────────────────────── 원격관리 (화면 보기 / 제어) ─────────────────────────
private void OpenRemoteDesktop(HostGroup g)
{
if (g.IsLocal || g.Client == null) return;
if (_viewers.TryGetValue(g.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
if (g.Client.State != RemoteState.Connected)
{
Dialogs.Info(this, $"{g.Title} 에 연결되어 있지 않습니다.\n({g.StatusText})");
return;
}
if (!g.Client.AllowView)
{
// 중계 서버 권한이 없어서인지, 대상 PC 설정이 꺼져 있어서인지 구분
if (g.Client.RelayPerms is { } rp && !rp.screenView)
Dialogs.Info(this, $"{g.Title} 의 [화면 보기] 권한이 중계 서버에서 주어지지 않았습니다.\n중계 서버 관리자에게 [서버 관리 → 권한]에서 '화면 보기'를 요청하세요.");
else
Dialogs.Info(this, $"{g.Title} 에서 화면 보기를 허용하지 않았습니다.\n대상 PC 설정 → 원격 접속 허용 → 원격관리에서 '화면 보기 허용'을 켜야 합니다.");
return;
}
// 원격관리는 항상 숫자 6자리 확인
if (_modalOpen) return;
bool ok;
_modalOpen = true;
try
{
ok = CodeConfirmWindow.Ask(this, "원격관리",
$"{g.Title} 의 화면을 {(g.Client.AllowControl ? "보고 키보드·마우스로 제어" : "봅")}니다.",
"원격관리 시작");
}
finally { _modalOpen = false; }
if (!ok)
{
Log($"[{g.Prefix}] 원격관리 취소");
return;
}
Log($"[{g.Prefix}] 원격관리 시작 ({(g.Client.AllowControl ? "보기 + 제어" : "보기")})", LogLevel.Notice);
var v = new RemoteViewerWindow(g.Id, g.Client, g.Title);
v.Closed += (s, e) =>
{
_viewers.Remove(g.Id);
Log($"[{g.Prefix}] 원격관리 끝");
};
_viewers[g.Id] = v;
v.Show();
}
/// <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)
{
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;
}
}
private void ShowLogMenu(ServiceRow row, FrameworkElement anchor, bool includeView)
{
var menu = new ContextMenu { PlacementTarget = anchor, Placement = PlacementMode.MousePoint };
if (includeView && row.HasLog)
{
var view = new MenuItem { Header = "로그 보기", FontWeight = FontWeights.Bold };
view.Click += (s, e) => _logWindows.Open(LogTarget.ForLocal(row.Entry));
menu.Items.Add(view);
menu.Items.Add(new Separator());
}
var file = new MenuItem { Header = "로그 파일 지정…" };
file.Click += (s, e) => PickLogFile(row);
menu.Items.Add(file);
var folder = new MenuItem { Header = "로그 폴더 지정… (가장 최근 파일 자동 표시)" };
folder.Click += (s, e) => PickLogFolder(row);
menu.Items.Add(folder);
if (row.HasLog)
{
menu.Items.Add(new Separator());
var cur = new MenuItem { Header = (row.Entry.LogIsFolder ? "현재 폴더: " : "현재 파일: ") + row.Entry.LogPath, IsEnabled = false };
menu.Items.Add(cur);
var clear = new MenuItem { Header = "로그 경로 해제" };
clear.Click += (s, e) =>
{
row.Entry.LogPath = "";
row.Entry.LogIsFolder = false;
row.LogPathChanged();
Log($"{row.LogName} 로그 경로 해제");
SyncEntriesFromRows();
_logWindows.LocalPathChanged(row.Entry);
};
menu.Items.Add(clear);
}
menu.IsOpen = true;
}
private void PickLogFile(ServiceRow row)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = $"{row.DisplayName} - 로그 파일 선택",
Filter = "로그 파일 (*.log;*.txt)|*.log;*.txt|모든 파일 (*.*)|*.*",
CheckFileExists = true
};
if (row.HasLog)
{
try
{
dlg.InitialDirectory = row.Entry.LogIsFolder
? row.Entry.LogPath
: System.IO.Path.GetDirectoryName(row.Entry.LogPath);
}
catch { }
}
if (dlg.ShowDialog(this) != true) return;
row.Entry.LogPath = dlg.FileName;
row.Entry.LogIsFolder = false;
row.LogPathChanged();
Log($"{row.LogName} 로그 파일 지정: {dlg.FileName}");
SyncEntriesFromRows();
_logWindows.LocalPathChanged(row.Entry);
_logWindows.Open(LogTarget.ForLocal(row.Entry));
}
private void PickLogFolder(ServiceRow row)
{
var dlg = new Microsoft.Win32.OpenFolderDialog
{
Title = $"{row.DisplayName} - 로그 폴더 선택 (가장 최근 파일을 자동으로 표시)"
};
if (row.HasLog)
{
try
{
dlg.InitialDirectory = row.Entry.LogIsFolder
? row.Entry.LogPath
: System.IO.Path.GetDirectoryName(row.Entry.LogPath);
}
catch { }
}
if (dlg.ShowDialog(this) != true) return;
row.Entry.LogPath = dlg.FolderName;
row.Entry.LogIsFolder = true;
row.LogPathChanged();
Log($"{row.LogName} 로그 폴더 지정: {dlg.FolderName}");
SyncEntriesFromRows();
_logWindows.LocalPathChanged(row.Entry);
_logWindows.Open(LogTarget.ForLocal(row.Entry));
}
// ───────────────────────── 내 PC 상태 자동 갱신 ─────────────────────────
private async Task RefreshStatuses(bool force)
{
if (_refreshing && !force) return;
_refreshing = true;
try
{
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 _local.Rows)
{
if (!result.TryGetValue(r.ServiceName, out var st)) continue;
if (r.StatusKnown && r.Status != st && !r.IsBusy) LogStatusChange(r, r.Status, st);
r.Status = st;
}
UpdateCounts();
}
catch
{
// 다음 주기에 다시 시도
}
finally
{
_refreshing = false;
}
}
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.Direction}|{t.AccessCode}|{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 정리
_passiveHub.Clear();
foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups.Where(g => !g.IsLocal && !IsRelayGroup(g)).ToList())
RemoveRemoteGroup(g);
if (_settings.RemoteManageEnabled)
{
// 중계 기기 그룹보다 앞에 넣음
int index = 1;
foreach (var t in _settings.Targets)
{
var client = new RemoteClient(t);
var g = CreateRemoteGroup(t.Id, t.Label, client, index++);
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
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();
}
/// <summary>원격 PC 그룹 만들기 (Active / Passive / 중계 공통)</summary>
private HostGroup CreateRemoteGroup(string id, string title, RemoteClient client, int index = -1)
{
var g = new HostGroup
{
Id = id,
Client = client,
Title = title,
StatusText = "연결 중…",
StatusBrush = Brushes2.Pending,
EmptyText = "연결 중…"
};
client.StateChanged += c =>
{
// 상태는 이벤트 시점 값으로 넘김 (UI에서 나중에 읽으면 연속 변경이 모두 같은 값으로 보임)
var st = c.State;
var stMsg = c.StateMessage;
Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, st, stMsg)));
};
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
if (index >= 0 && index <= _groups.Count) _groups.Insert(index, g);
else _groups.Add(g);
return g;
}
private void RemoveRemoteGroup(HostGroup g)
{
if (_viewers.TryGetValue(g.Id, out var v)) v.Close();
g.Client?.Dispose();
_logWindows.Close($"remote|{g.Id}|", prefix: true);
_groups.Remove(g);
}
// ───────────────────────── 중계 서버 ─────────────────────────
private const string RelayGroupPrefix = "relay-";
private static bool IsRelayGroup(HostGroup g) => g.Id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase);
/// <summary>대상 PC 역할: 이 PC를 중계 서버에 등록해 두고 온라인 유지</summary>
private void ApplyRelayAgent()
{
var s = _settings;
bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
bool need = s.RemoteServerEnabled && hasPw && s.RelayAgentEnabled && !string.IsNullOrEmpty(s.RelayDeviceId);
var key = need ? $"{s.RelayAgentUrl}|{s.RelayDeviceId}|{s.RelayDeviceKeyProtected}" : "off";
if (key != _relayAgentKey)
{
_relayAgentKey = key;
if (need)
{
_relayAgent.Start(s.RelayAgentUrl, s.RelayDeviceId, RemoteSecurity.Unprotect(s.RelayDeviceKeyProtected),
s.RelayServerPublicKey, RelayActiveListen);
}
else
{
_relayAgent.Stop();
}
}
else if (need)
{
_relayAgent.SendUpdate(); // Active 수신 정보가 바뀌었을 수 있음
}
if (!need && !string.IsNullOrEmpty(s.RelayServerPublicKey)) _remoteServer.SetRelayTrust(s.RelayServerPublicKey, s.RelayDeviceId);
}
/// <summary>중계 서버에 알릴 Active 수신 정보 (직접 연결 후보)</summary>
private JsonObject RelayActiveListen()
{
if (!_remoteServer.IsRunning) return null;
return new JsonObject { ["mode"] = _remoteServer.Mode == RemoteMode.Ssh ? "Ssh" : "Port", ["port"] = _remoteServer.Port };
}
/// <summary>관리 역할: 중계 서버 로그인 유지</summary>
private void ApplyRelayManage()
{
var s = _settings;
bool need = s.RemoteManageEnabled && s.RelayManageEnabled
&& (!string.IsNullOrEmpty(s.RelayTokenProtected) || !string.IsNullOrEmpty(s.RelayPasswordProtected));
var key = need ? $"{s.RelayManageUrl}|{s.RelayUsername}|{s.RelayTokenProtected}|{s.RelayPasswordProtected}" : "off";
if (key == _relayManageKey) return;
_relayManageKey = key;
_relayLoginNotice = null;
if (need)
{
_relayManager.Start(s.RelayManageUrl, s.RelayUsername,
() => RemoteSecurity.Unprotect(_settings.RelayTokenProtected),
() => RemoteSecurity.Unprotect(_settings.RelayPasswordProtected),
token => Dispatcher.BeginInvoke(new Action(() =>
{
_settings.RelayTokenProtected = RemoteSecurity.Protect(token);
_relayManageKey = $"{_settings.RelayManageUrl}|{_settings.RelayUsername}|{_settings.RelayTokenProtected}|{_settings.RelayPasswordProtected}";
SaveSettings();
})));
}
else
{
_relayManager.Stop();
}
SyncRelayGroups();
}
/// <summary>중계 서버의 기기 목록 → 초기화면 그룹 (권한 있는 기기만)</summary>
private readonly Dictionary<string, string> _relayPermSig = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private static string PermSig(RelayDevice d) => $"{d.View}{d.Control}{d.ScreenView}{d.ScreenControl}";
private void SyncRelayGroups()
{
bool active = _settings.RemoteManageEnabled && _settings.RelayManageEnabled && _relayManageKey != "off" && _relayManageKey != "";
var devices = active ? _relayManager.Devices : Array.Empty<RelayDevice>();
var wanted = devices.Where(d => d.Enabled).ToDictionary(d => RelayGroupPrefix + d.Id, StringComparer.OrdinalIgnoreCase);
// 없어진 기기
foreach (var g in _groups.Where(IsRelayGroup).ToList())
{
if (wanted.ContainsKey(g.Id)) continue;
// 서버 연결이 잠깐 끊긴 경우(목록이 비어 있음)에는 그대로 둠
if (active && devices.Count == 0 && !_relayManager.IsLoggedIn) continue;
RemoveRemoteGroup(g);
_relayTargets.Remove(g.Id);
_relayPermSig.Remove(g.Id);
if (_hostTabs.ContainsKey(g.Id)) CloseHostTab(g.Id, save: false);
}
foreach (var kv in wanted)
{
var d = kv.Value;
var gid = kv.Key;
var existing = _groups.FirstOrDefault(x => x.Id == gid);
if (existing != null)
{
// 이름이 바뀌었으면 제목 갱신
if (_relayTargets.TryGetValue(gid, out var t0) && (t0.RelayName != d.Name))
{
t0.RelayName = d.Name;
existing.Title = t0.Label;
existing.TitleChanged();
}
// 권한이 바뀌었으면: 대상 PC가 옛 티켓 연결을 끊음 → 기다리지 않고 새 티켓으로 바로 다시 연결
bool permChanged = _relayPermSig.TryGetValue(gid, out var sig0) && sig0 != PermSig(d);
_relayPermSig[gid] = PermSig(d);
if (d.Online && (permChanged || existing.Client?.State != RemoteState.Connected)) existing.Client?.Nudge();
continue;
}
var t = new RemoteTarget
{
Id = gid,
Direction = ConnectDirection.Relay,
RelayDeviceId = d.Id,
RelayName = d.Name,
Hostname = d.Hostname
};
_relayTargets[gid] = t;
_relayPermSig[gid] = PermSig(d);
var deviceId = d.Id;
var client = new RemoteClient(t, () =>
_settings.RelayDevicePasswords.TryGetValue(deviceId, out var p) ? RemoteSecurity.Unprotect(p) : "", _relayManager);
var g = CreateRemoteGroup(gid, t.Label, client);
client.RelayModeChanged += c => Dispatcher.BeginInvoke(new Action(() =>
{
t.RelayMode = c.RelayMode;
g.Title = t.Label;
g.TitleChanged();
_logWindows.ChoicesChanged();
}));
client.Start();
if (_pendingRelayTabs.Remove(gid)) OpenHostTab(gid, select: false);
}
_logWindows.ChoicesChanged();
UpdateCounts();
}
private void OnRelayPresence(string deviceId, bool online)
{
var g = _groups.FirstOrDefault(x => x.Id == RelayGroupPrefix + deviceId);
if (g == null) return;
if (online) g.Client?.Nudge();
}
/// <summary>중계 기기의 대상 PC 접속 비밀번호 입력 (기기마다 저장)</summary>
private void AskRelayPassword(HostGroup g)
{
if (!_relayTargets.TryGetValue(g.Id, out var t) || _modalOpen) return;
string pw;
_modalOpen = true;
try
{
pw = TextPrompt.Ask(this, "대상 PC 접속 비밀번호",
$"{g.Title}\n\n대상 PC의 [원격 접속 허용] 접속 비밀번호를 입력하세요.\n(이 PC에 Windows 계정으로 암호화해 저장합니다)", "", password: true);
}
finally { _modalOpen = false; }
if (string.IsNullOrEmpty(pw)) return;
_settings.RelayDevicePasswords[t.RelayDeviceId] = RemoteSecurity.Protect(pw);
SaveSettings();
Log($"[{g.Prefix}] 접속 비밀번호 저장");
g.Client?.Nudge();
}
/// <summary>내 PC 제목 줄의 원격 접속 허용 상태</summary>
private void UpdateLocalStatus()
{
var s = _settings;
var parts = new List<string>();
bool passive = s.RemoteServerEnabled && !string.IsNullOrEmpty(s.RemotePasswordHash) && s.PassiveManagers.Count > 0;
if (_remoteServer.IsRunning) parts.Add($"Active {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}");
if (passive) parts.Add($"Passive {s.PassiveManagers.Count}곳");
if (_relayAgentKey == "revoked") parts.Add("중계 서버: 다시 등록 필요");
else if (_relayAgentKey != "off" && _relayAgentKey != "") parts.Add(_relayAgent.IsOnline ? "중계 서버 온라인" : "중계 서버 연결 안 됨");
if (s.RemoteServerEnabled && s.RemoteAllowView) parts.Add(s.RemoteAllowControl ? "화면 보기·제어 허용" : "화면 보기 허용");
_local.StatusText = parts.Count > 0 ? "원격 접속 허용 중 · " + string.Join(" · ", parts) : "";
_local.StatusBrush = _relayAgentKey == "revoked" ? Brushes2.Stopped : Brushes2.Running;
}
/// <summary>Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)</summary>
private void ApplyPassiveListen(bool startup)
{
var s = _settings;
bool need = s.RemoteManageEnabled && s.Targets.Any(t => t.Direction == ConnectDirection.Passive);
var key = need ? $"{s.PassiveListenMode}|{s.PassiveListenPort}" : "off";
if (key == _passiveListenKey) return;
bool wasOn = _passiveListenKey != "" && _passiveListenKey != "off";
_passiveListenKey = key;
_passiveHub.Apply(need, s.PassiveListenMode, s.PassiveListenPort);
if (need || wasOn)
{
bool on = need && _passiveHub.IsRunning;
int port = s.PassiveListenPort;
Task.Run(() =>
{
var (ok, msg) = FirewallManager.Apply(FirewallManager.PassiveRule, on, port);
if (!startup || !ok) Log(msg, ok ? LogLevel.Info : LogLevel.Warning);
});
}
}
/// <summary>Passive: 대상 PC가 접속해 온 IP 표시</summary>
private void OnPeerAddress(HostGroup g, string addr)
{
var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id);
if (t == null || string.IsNullOrEmpty(addr)) return;
if (t.LastAddress != addr)
{
t.LastAddress = addr;
SaveSettings();
}
g.Title = t.Label;
g.TitleChanged();
_logWindows.ChoicesChanged();
}
private void OnRemoteState(HostGroup g, RemoteState state, string stateMessage)
{
if (!_groups.Contains(g)) return;
switch (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 = (state == RemoteState.AuthFailed ? "인증 실패: " : "연결 안 됨: ") + stateMessage;
g.StatusBrush = Brushes2.Stopped;
g.EmptyText = g.StatusText;
foreach (var r in g.Rows) r.MarkUnknown();
// 같은 실패 메시지는 처음 한 번만 기록
if (wasConnected || g.Tag != stateMessage)
Log($"[{g.Prefix}] {g.StatusText}", state == RemoteState.AuthFailed ? LogLevel.Error : LogLevel.Warning);
g.Tag = stateMessage;
break;
}
if (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)
{
if (_relayTargets.TryGetValue(g.Id, out var rt))
{
rt.Hostname = host;
g.Title = rt.Label;
g.TitleChanged();
_logWindows.ChoicesChanged();
return;
}
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;
bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
// 비밀번호는 Active / Passive 모두 사용하므로 항상 반영
_remoteServer.UpdatePassword(s.RemotePasswordHash, s.RemotePasswordSalt);
if (s.RemoteServerEnabled && !hasPw)
Log("원격 접속 허용: 접속 비밀번호가 없어 시작하지 않았습니다. (설정 → 원격 접속 허용)", LogLevel.Warning);
// ── Active 수신
bool active = s.RemoteServerEnabled && hasPw && s.RemoteServerActiveListen;
var key = active ? $"{s.RemoteServerMode}|{s.RemoteServerPort}" : "off";
if (key != _serverKey)
{
bool wasOn = _serverKey != "" && _serverKey != "off";
_serverKey = key;
if (active) _remoteServer.Start(s.RemoteServerMode, s.RemoteServerPort, s.RemotePasswordHash, s.RemotePasswordSalt);
else _remoteServer.Stop();
// 방화벽: 켤 때 포트 열기, 끌 때 규칙 삭제 (처음 실행 시 꺼져 있으면 건드리지 않음)
if (active || wasOn || !startup)
{
bool on = active && _remoteServer.IsRunning;
int port = s.RemoteServerPort;
Task.Run(() =>
{
var (ok, msg) = FirewallManager.Apply(FirewallManager.ActiveRule, on, port);
if (!startup || !ok) Log(msg, ok ? LogLevel.Info : LogLevel.Warning);
});
}
}
// ── Passive 접속 (이 PC → 관리 PC)
bool passive = s.RemoteServerEnabled && hasPw && s.PassiveManagers.Count > 0;
var pkey = passive ? string.Join(";", s.PassiveManagers.Select(m => $"{m.Address}|{m.Mode}|{m.Port}|{m.AccessCode}")) : "off";
if (pkey != _passiveKey)
{
_passiveKey = pkey;
if (passive)
{
_remoteServer.StartPassive(s.PassiveManagers);
Log($"Passive 접속 시작: 관리 PC {s.PassiveManagers.Count}곳", LogLevel.Info);
}
else _remoteServer.StopPassive();
}
// ── 중계 서버 (이 PC를 등록해 두고 연결 요청 받기)
ApplyRelayAgent();
// ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
UpdateLocalStatus();
}
// ───────────────────────── Windows 종료 ─────────────────────────
private void Shutdown(bool restart)
{
var what = restart ? "재시작" : "강제 종료";
if (_modalOpen) return;
bool confirmed;
_modalOpen = true;
try
{
confirmed = CodeConfirmWindow.Ask(this, "Windows " + what,
$"Windows를 지금 {what}합니다.\n저장하지 않은 작업은 모두 사라집니다.",
restart ? "Windows 재시작" : "Windows 강제종료");
}
finally { _modalOpen = false; }
if (!confirmed)
{
Log($"Windows {what} 취소");
return;
}
Log($"Windows {what} 실행", LogLevel.Warning);
try
{
if (restart) PowerOps.ForceRestart(); else PowerOps.ForceShutdown();
}
catch (Exception ex)
{
Log($"Windows {what} 실패: {ex.Message}", LogLevel.Error);
}
}
// ───────────────────────── 설정 ─────────────────────────
private void OpenSettings()
{
ShowFromTray();
if (_modalOpen) return;
bool wasStartup = _settings.RunAtStartup;
var dlg = new SettingsWindow(_settings, _relayManager) { Owner = this };
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
_hotkey.Unregister();
bool? dialogOk;
_modalOpen = true;
try { dialogOk = dlg.ShowDialog(); }
finally { _modalOpen = false; }
if (dialogOk != true)
{
ApplyHotkey(quiet: true);
return;
}
SaveSettings();
Log("설정 저장");
if (wasStartup != _settings.RunAtStartup || (_settings.RunAtStartup && !StartupManager.IsEnabled()))
{
var (ok, msg) = _settings.RunAtStartup ? StartupManager.Enable() : StartupManager.Disable();
if (ok) Log(_settings.RunAtStartup ? "컴퓨터 시작시 기동: 켬 (작업 스케줄러 등록)" : "컴퓨터 시작시 기동: 끔", LogLevel.Success);
else Log("컴퓨터 시작시 기동 설정 실패: " + msg, LogLevel.Error);
}
ApplySettings(startup: false);
}
private void ApplySettings(bool startup)
{
// Windows 종료 버튼 표시
_pnlShutdown.Visibility = _settings.ShowShutdownButtons ? Visibility.Visible : Visibility.Collapsed;
// 작업 로그 표시
_ccLog.Visibility = _settings.ShowOperationLog ? Visibility.Visible : Visibility.Collapsed;
_txtLogHidden.Visibility = _settings.ShowOperationLog ? Visibility.Collapsed : Visibility.Visible;
ApplyHotkey(quiet: startup);
ApplyRemoteServer(startup);
ApplyRemoteManage();
ApplyRelayManage();
ApplyPassiveListen(startup);
}
private void ApplyHotkey(bool quiet)
{
var mods = (ModifierKeys)_settings.HotkeyModifiers;
var key = (Key)_settings.HotkeyKey;
if (_settings.HotkeyEnabled)
{
if (_hotkey.Register(mods, key))
{
if (!quiet) Log($"단축키 등록: {HotkeyManager.Format(mods, key)}");
}
else
{
var prev = _hotkey.Active;
Log($"단축키 {HotkeyManager.Format(mods, key)} 등록 실패 (다른 프로그램이 사용 중일 수 있습니다)"
+ (prev.HasValue ? $" → 이전 단축키 {HotkeyManager.Format(prev.Value.mods, prev.Value.key)} 유지" : ""),
LogLevel.Warning);
}
}
else
{
_hotkey.Unregister();
}
if (_tray != null)
_tray.Text = 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;
private void SaveSettings()
{
if (WindowState == WindowState.Normal && IsLoaded)
{
_settings.WindowLeft = Left;
_settings.WindowTop = Top;
_settings.WindowWidth = Width;
_settings.WindowHeight = Height;
}
SettingsStore.Save(_settings);
}
private void RestoreWindowBounds(AppSettings s)
{
Width = s.WindowWidth > 300 ? s.WindowWidth : 1000;
Height = s.WindowHeight > 300 ? s.WindowHeight : 720;
bool valid = !double.IsNaN(s.WindowLeft) && !double.IsNaN(s.WindowTop)
&& s.WindowLeft >= SystemParameters.VirtualScreenLeft - 50
&& s.WindowTop >= SystemParameters.VirtualScreenTop - 50
&& s.WindowLeft < SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth - 100
&& s.WindowTop < SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight - 100;
if (valid)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = s.WindowLeft;
Top = s.WindowTop;
}
else
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
// ───────────────────────── 트레이 / Esc / 종료 ─────────────────────────
private void CreateTray()
{
var menu = new Forms.ContextMenuStrip();
menu.Items.Add("열기", null, (s, e) => Dispatcher.Invoke(ShowFromTray));
menu.Items.Add("설정", null, (s, e) => { if (!_modalOpen) Dispatcher.Invoke(OpenSettings); else Dispatcher.Invoke(ShowFromTray); });
menu.Items.Add(new Forms.ToolStripSeparator());
menu.Items.Add("완전 종료", null, (s, e) => Dispatcher.Invoke(ExitApp));
_tray = new Forms.NotifyIcon
{
Icon = AppIcon.Icon,
Text = Trim63("JJServiceManager" + AppInstance.TitleTag),
ContextMenuStrip = menu,
Visible = true
};
_tray.MouseClick += (s, e) =>
{
if (e.Button == Forms.MouseButtons.Left) Dispatcher.Invoke(ShowFromTray);
};
}
public void ShowFromTray()
{
if (!IsVisible) Show();
if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
Activate();
Topmost = true; // 다른 창 뒤에 숨지 않도록 잠깐 맨 앞으로
Topmost = false;
Focus();
}
private void HideToTray()
{
SaveSettings();
Hide();
if (!_trayTipShown && _tray != null)
{
_trayTipShown = true;
var hk = _settings.HotkeyEnabled ? $" 단축키 {HotkeyManager.Format((ModifierKeys)_settings.HotkeyModifiers, (Key)_settings.HotkeyKey)}로 다시 열 수 있습니다." : "";
_tray.ShowBalloonTip(2500, "JJServiceManager" + AppInstance.TitleTag, "트레이에서 계속 실행 중입니다." + hk, Forms.ToolTipIcon.Info);
}
}
private void ToggleVisibility()
{
if (IsVisible && IsActive && WindowState != WindowState.Minimized) HideToTray();
else ShowFromTray();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Escape) return;
e.Handled = true;
if (_settings.EscAction == EscAction.Exit) ExitApp();
else HideToTray();
}
protected override void OnClosing(CancelEventArgs e)
{
if (!_exiting)
{
// 창의 X 버튼도 Esc 설정을 따름
if (_settings.EscAction == EscAction.Tray)
{
e.Cancel = true;
HideToTray();
return;
}
_exiting = true;
Cleanup();
}
base.OnClosing(e);
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
Application.Current.Shutdown();
}
/// <summary>Windows 로그오프/종료 시 정리</summary>
public void OnSessionEnding()
{
if (_exiting) return;
_exiting = true;
OperationLogFile.Append(DateTime.Now, "Windows 세션 종료");
Cleanup();
}
public void ExitApp()
{
if (_exiting) return;
_exiting = true;
Cleanup();
Close();
Application.Current.Shutdown();
}
private void Cleanup()
{
OperationLogFile.Append(DateTime.Now, "프로그램 종료");
_statusTimer.Stop();
SaveSettings();
_logWindows.CloseAll();
foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups) g.Client?.Dispose();
_relayManager.Dispose();
_relayAgent.Dispose();
_passiveHub.Dispose();
_remoteServer.Dispose();
_indicator?.Close();
_hotkey?.Dispose();
if (_tray != null)
{
_tray.Visible = false;
_tray.Dispose();
_tray = null;
}
}
private static bool IsAdmin()
{
try { return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); }
catch { return false; }
}
}
}