Compare commits

..

7 Commits

Author SHA1 Message Date
Claude 49c2192d7d
v2.0.0: README에 원격관리·Active/Passive 설명 추가, 버전 2.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 05:38:48 +00:00
Claude 07c1b423a8
Passive 대상 제목에 (Passive) 표시, 연결됨 로그 중복 수정
- Passive 대상 PC 묶음 제목/서비스 앞 표시: "IP / hostname (Passive)"
- 연결 상태 변경을 이벤트 시점 값으로 UI에 전달 (연속 변경 시 "연결됨"이 여러 번 기록되던 문제)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 05:31:54 +00:00
Claude 39a8f03b59
원격관리: 연결 끊기 후 표시 막대가 남는 문제 수정
- 화면 프레임 전송을 화면 중지 토큰으로 취소하지 않음 (TLS 스트림 손상 방지)
- screenStop/연결 종료/[연결 끊기] 시 표시 막대 즉시 갱신 (EndScreen)
- Passive 수신을 켠 적이 없으면 시작 시 "Passive 수신 중지" 로그 생략

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 05:02:50 +00:00
diadmin e7f7bcf1f2
원격관리(화면 보기/제어)와 Active/Passive 연결 방향 추가
- 원격관리: PC 제목 줄 [원격관리] → 숫자 6자리 → 원격 화면 창
  (모니터 1/2/…/전체, 제어 허용 시 마우스·키보드 바로 전달)
- 대상 PC 설정: 화면 보기 허용 / 제어 허용 / 연결 시 승인 필요, 연결 중 상단 표시줄 + [연결 끊기]
- Passive: 대상 PC가 관리 PC로 접속 (접속 코드로 구분, 끊기면 자동 재접속)
- 관리 대상 PC마다 Active/Passive 선택, Passive 수신 방식·포트, 방화벽 규칙 자동
- 수정: Windows 명령 출력 글자 깨짐(UTF-8/OEM 자동 판별), 대상 목록 표시, 창을 처음 뜰 때 맨 앞으로
- 수정: Passive만 쓸 때 대상 PC 비밀번호가 반영되지 않던 문제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 04:43:37 +00:00
diadmin 091881a5c4
테스트 빌드 옵션 추가: -p:TestBuild=true 시 관리자 권한 없이 실행 (화면 자동 점검용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 04:10:30 +00:00
diadmin 96b614d1ed
실행 파일 이름을 JJServiceManagerEnt.exe로 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 03:48:12 +00:00
diadmin c8fa82b743
원격 모니터링 기능 추가 (작업 중, 검토 반영 전)
- 원격 접속 허용(대상): 내장 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_013U497kUNL8ArhV7jkzCpKa
2026-10-27 03:44:31 +00:00
20 changed files with 4849 additions and 413 deletions

78
Core/ConsoleRunner.cs Normal file
View File

@ -0,0 +1,78 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace JJServiceManager
{
/// <summary>
/// Windows 명령(schtasks, netsh 등)을 실행하고 출력을 읽음.
/// 시스템 설정에 따라 출력이 UTF-8 또는 CP949(OEM)일 수 있어,
/// 바이트로 받은 뒤 UTF-8로 올바르게 읽히면 UTF-8, 아니면 시스템 OEM 코드페이지로 해석함.
/// </summary>
public static class ConsoleRunner
{
[DllImport("kernel32.dll")]
private static extern uint GetOEMCP();
public static (int exitCode, string output) Run(string file, string args, int timeoutMs = 15000)
{
try
{
var psi = new ProcessStartInfo(file, args)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
using var p = Process.Start(psi);
var outTask = ReadAllAsync(p.StandardOutput.BaseStream);
var errTask = ReadAllAsync(p.StandardError.BaseStream);
if (!p.WaitForExit(timeoutMs))
{
try { p.Kill(); } catch { }
return (-1, "시간 초과");
}
var bytes = Combine(outTask.Result, errTask.Result);
return (p.ExitCode, Decode(bytes).Trim());
}
catch (Exception ex)
{
return (-1, ex.Message);
}
}
private static async Task<byte[]> ReadAllAsync(Stream s)
{
using var ms = new MemoryStream();
await s.CopyToAsync(ms).ConfigureAwait(false);
return ms.ToArray();
}
private static byte[] Combine(byte[] a, byte[] b)
{
var r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
public static string Decode(byte[] bytes)
{
if (bytes.Length == 0) return "";
try
{
var strict = new UTF8Encoding(false, throwOnInvalidBytes: true);
return strict.GetString(bytes);
}
catch { }
try { return Encoding.GetEncoding((int)GetOEMCP()).GetString(bytes); }
catch { }
try { return Encoding.GetEncoding(949).GetString(bytes); }
catch { return Encoding.UTF8.GetString(bytes); }
}
}
}

View File

@ -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;

View File

@ -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);
grid.Children.Add(open);
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;
}

View File

@ -25,6 +25,118 @@ 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>접속 방향</summary>
public enum ConnectDirection
{
Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶)
Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶)
}
/// <summary>관리 대상 PC</summary>
public class RemoteTarget
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>Active: 관리 PC가 접속 / Passive: 대상 PC가 접속해 옴</summary>
public ConnectDirection Direction { get; set; } = ConnectDirection.Active;
/// <summary>Passive일 때 대상 PC가 알려주는 접속 코드 (예: AB12-CD34)</summary>
public string AccessCode { get; set; } = "";
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; } = "";
/// <summary>Passive로 접속해 온 대상 PC의 IP (표시용, 자동)</summary>
public string LastAddress { get; set; } = "";
[JsonIgnore]
public string Label
{
get
{
if (Direction == ConnectDirection.Passive)
{
// 아직 한 번도 접속하지 않았으면 코드로 표시
if (string.IsNullOrWhiteSpace(LastAddress)) return "Passive " + AccessCode;
var p = string.IsNullOrWhiteSpace(Hostname) ? LastAddress : $"{LastAddress} / {Hostname}";
return p + " (Passive)";
}
return string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}";
}
}
public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone();
/// <summary>새 접속 코드 (헷갈리는 글자 0/O, 1/I 제외)</summary>
public static string NewAccessCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 8; i++)
{
if (i == 4) sb.Append('-');
sb.Append(chars[System.Security.Cryptography.RandomNumberGenerator.GetInt32(chars.Length)]);
}
return sb.ToString();
}
}
/// <summary>대상 PC 쪽: Passive로 접속할 관리 PC</summary>
public class PassiveManager
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Address { get; set; } = "";
public RemoteMode Mode { get; set; } = RemoteMode.Port;
public int Port { get; set; } = 9990;
public string AccessCode { get; set; } = "";
[JsonIgnore]
public string Label => $"{Address} ({(Mode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {Port}) · 코드 {AccessCode}";
public PassiveManager Clone() => (PassiveManager)MemberwiseClone();
}
/// <summary>로그창 띄우기 방식</summary>
public enum LogWindowMode
{
@ -85,6 +197,60 @@ 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;
/// <summary>Active 수신: 이 PC가 포트를 열고 관리 PC의 접속을 받음</summary>
public bool RemoteServerActiveListen { get; set; } = true;
/// <summary>Passive 접속: 이 PC가 관리 PC로 접속</summary>
public List<PassiveManager> PassiveManagers { get; set; } = new List<PassiveManager>();
// ───────── 원격관리 (화면 보기 / 제어) ─────────
/// <summary>원격에서 이 PC 화면 보기 허용</summary>
public bool RemoteAllowView { get; set; }
/// <summary>원격에서 이 PC 키보드·마우스 제어 허용 (화면 보기가 켜져 있어야 함)</summary>
public bool RemoteAllowControl { get; set; }
/// <summary>원격 화면 연결 시 이 PC에서 승인해야 함</summary>
public bool RemoteRequireApproval { get; set; } = true;
// ───────── 다른 PC 관리 (관리 역할) ─────────
public bool RemoteManageEnabled { get; set; }
public List<RemoteTarget> Targets { get; set; } = new List<RemoteTarget>();
/// <summary>Passive 대상 PC의 접속을 기다리는 방식/포트</summary>
public RemoteMode PassiveListenMode { get; set; } = RemoteMode.Port;
public int PassiveListenTcpPort { get; set; } = 9990 + AppInstance.Number - 1;
public int PassiveListenSshPort { get; set; } = 2224 + AppInstance.Number - 1;
[JsonIgnore]
public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort;
/// <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 +261,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 +285,9 @@ namespace JJServiceManager
if (s != null)
{
s.Services ??= new List<ServiceEntry>();
s.Targets ??= new List<RemoteTarget>();
s.PassiveManagers ??= new List<PassiveManager>();
s.OpenHostTabs ??= new List<string>();
return s;
}
}

611
Core/Remote/RemoteClient.cs Normal file
View File

@ -0,0 +1,611 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DevTunnels.Ssh;
namespace JJServiceManager.Remote
{
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
/// <summary>
/// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴.
/// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속)
/// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌)
/// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음.
/// </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 Func<string> _password;
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 readonly object _incomingLock = new object();
private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming;
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 bool IsPassive => _target.Direction == ConnectDirection.Passive;
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>대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신)</summary>
public bool AllowView { get; private set; }
public bool AllowControl { get; private set; }
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> StateChanged;
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> ServicesChanged;
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
public event Action<RemoteClient, string> HostnameDiscovered;
/// <summary>Passive: 대상 PC가 접속해 온 주소</summary>
public event Action<RemoteClient, string> PeerAddressDiscovered;
/// <summary>원격 화면 이벤트 (frame / screenEnd)</summary>
public event Action<JsonObject> ScreenEvent;
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();
lock (_incomingLock) _incoming?.TrySetCanceled();
}
/// <summary>Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true</summary>
public bool Offer(JsonLineConnection conn, string peer)
{
lock (_incomingLock)
{
if (_incoming == null || _incoming.Task.IsCompleted) return false;
return _incoming.TrySetResult((conn, peer));
}
}
// ───────────────────────── 연결 유지 ─────────────────────────
private async Task RunAsync()
{
var ct = _cts.Token;
while (!ct.IsCancellationRequested)
{
TimeSpan retry = TimeSpan.FromSeconds(5);
try
{
JsonLineConnection conn;
JsonObject hello;
if (IsPassive)
{
SetState(RemoteState.Connecting, $"대상 PC의 접속을 기다리는 중 (코드 {_target.AccessCode})");
TaskCompletionSource<(JsonLineConnection, string)> tcs;
lock (_incomingLock)
{
_incoming = new TaskCompletionSource<(JsonLineConnection, string)>(TaskCreationOptions.RunContinuationsAsynchronously);
tcs = _incoming;
}
using (ct.Register(() => tcs.TrySetCanceled()))
{
var (c, peer) = await tcs.Task.ConfigureAwait(false);
conn = c;
if (!string.IsNullOrEmpty(peer)) PeerAddressDiscovered?.Invoke(this, peer);
}
SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…");
hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false);
}
else
{
SetState(RemoteState.Connecting, "연결 중…");
(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);
}
AllowView = Json.Bool(hello, "allowView");
AllowControl = Json.Bool(hello, "allowControl");
SetState(RemoteState.Connected, "연결됨");
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
// 목록 주기적으로 받기
while (!ct.IsCancellationRequested && !reader.IsCompleted)
{
var res = await RequestAsync("list", null, TimeSpan.FromSeconds(20)).ConfigureAwait(false);
Services = ParseServices(res);
AllowView = Json.Bool(res, "allowView");
AllowControl = Json.Bool(res, "allowControl");
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 = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30);
}
catch (Exception ex)
{
SetState(RemoteState.Disconnected, Friendly(ex));
if (IsPassive) retry = TimeSpan.FromSeconds(1);
}
finally
{
var c = _conn;
_conn = null;
c?.Dispose();
FailPending("연결이 끊겼습니다.");
_logs.Clear();
ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." });
}
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");
if (ev == "frame" || ev == "screenEnd")
{
ScreenEvent?.Invoke(msg);
continue;
}
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();
}
/// <summary>응답을 기다리지 않고 보냄 (원격 화면 입력)</summary>
public void SendNoReply(JsonObject msg)
{
var conn = _conn;
if (conn == null || State != RemoteState.Connected) return;
msg["id"] = 0;
_ = conn.SendAsync(msg).ContinueWith(t => { _ = t.Exception; }, TaskScheduler.Default);
}
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>Active: 접속 + 인증 + 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("접속 비밀번호가 저장되어 있지 않습니다.");
JsonLineConnection conn;
using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
timeout.CancelAfter(ConnectTimeout);
try
{
conn = await RemoteDialer.DialAsync(t.Mode, t.Address, t.Port, RemoteServer.SshUser, password, timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)");
}
}
// SSH는 SSH 인증에서 비밀번호를 확인했으므로 hello에는 보내지 않음
var hello = await HelloAsync(conn, password, sendPassword: t.Mode == RemoteMode.Port, ct).ConfigureAwait(false);
return (conn, hello);
}
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ConnectTimeout);
try
{
var hello = new JsonObject
{
["id"] = 0,
["op"] = "hello",
["client"] = Environment.MachineName,
["version"] = RemoteServer.Version
};
if (sendPassword) hello["password"] = password;
await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false);
var reply = await conn.ReadAsync(timeout.Token).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 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,
RemoteAuthException ra => ra.Message,
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
=> "연결이 거부되었습니다. (상대 PC에서 기능이 꺼져 있거나 포트가 다름)",
SocketException se when se.SocketErrorCode == SocketError.HostNotFound
=> "상대 PC를 찾을 수 없습니다. (주소 확인)",
SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable
=> "상대 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
OperationCanceledException => "연결 시간이 초과되었습니다. (주소, 포트, 방화벽 확인)",
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>
/// 관리 PC: Passive 대상 PC들의 접속을 받는 곳.
/// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김.
/// </summary>
public sealed class PassiveHub : IDisposable
{
private readonly ConcurrentDictionary<string, RemoteClient> _byCode = new ConcurrentDictionary<string, RemoteClient>(StringComparer.OrdinalIgnoreCase);
private readonly Action<string, LogLevel> _log;
private RemoteListener _listener;
private string _key = "";
public PassiveHub(Action<string, LogLevel> log) => _log = log;
public bool IsRunning => _listener != null;
public void Register(RemoteClient c)
{
if (!string.IsNullOrWhiteSpace(c.Target.AccessCode)) _byCode[c.Target.AccessCode.Trim()] = c;
}
public void Clear() => _byCode.Clear();
/// <summary>수신 시작/중지 (방식·포트가 같으면 그대로)</summary>
public void Apply(bool enabled, RemoteMode mode, int port)
{
var key = enabled ? $"{mode}|{port}" : "off";
if (key == _key) return;
_key = key;
bool wasRunning = _listener != null;
_listener?.Dispose();
_listener = null;
if (!enabled)
{
if (wasRunning) _log("Passive 수신 중지", LogLevel.Info);
return;
}
try
{
_listener = RemoteListener.Start(mode, port, CheckSshAsync, OnConnectionAsync);
_listener.Failed += msg => _log("Passive 수신 중단: " + msg, LogLevel.Error);
_log($"Passive 수신 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
}
catch (Exception ex)
{
_key = "";
var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse
? $"포트 {port}를 다른 프로그램이 사용 중입니다."
: ex.Message;
_log($"Passive 수신 시작 실패 (포트 {port}): {msg}", LogLevel.Error);
}
}
private Task<bool> CheckSshAsync(string user, string password)
=> Task.FromResult(user == RemoteServer.PassiveSshUser && !string.IsNullOrEmpty(password) && _byCode.ContainsKey(password.Trim()));
private async Task OnConnectionAsync(JsonLineConnection conn, string peer, CancellationToken ct)
{
try
{
JsonObject first;
using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
timeout.CancelAfter(TimeSpan.FromSeconds(10));
first = await conn.ReadAsync(timeout.Token).ConfigureAwait(false);
}
if (first == null || Json.Str(first, "op") != "announce")
{
conn.Dispose();
return;
}
var code = Json.Str(first, "code").Trim();
var host = Json.Str(first, "hostname");
if (!_byCode.TryGetValue(code, out var client))
{
_log($"Passive 접속 거부: 등록되지 않은 접속 코드 ({host} {peer})", LogLevel.Warning);
conn.Dispose();
return;
}
var addr = peer == "SSH" ? host : peer;
if (!client.Offer(conn, addr))
{
// 이미 연결되어 있음
conn.Dispose();
}
}
catch
{
conn.Dispose();
}
}
public void Dispose()
{
_listener?.Dispose();
_listener = null;
_key = "";
}
}
/// <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);
}
}
}

248
Core/Remote/RemoteCommon.cs Normal file
View File

@ -0,0 +1,248 @@
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 = 16 * 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;
private readonly char[] _buf = new char[64 * 1024];
private int _bufPos, _bufLen;
private readonly StringBuilder _line = new StringBuilder();
/// <summary>다음 메시지. 연결이 끊기면 null (한 번에 한 곳에서만 호출)</summary>
public async Task<JsonObject> ReadAsync(CancellationToken ct)
{
_line.Clear();
while (true)
{
if (_bufPos >= _bufLen)
{
_bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false);
_bufPos = 0;
if (_bufLen <= 0) return null;
}
int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos);
if (idx < 0)
{
_line.Append(_buf, _bufPos, _bufLen - _bufPos);
_bufPos = _bufLen;
if (_line.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다.");
continue;
}
_line.Append(_buf, _bufPos, idx - _bufPos);
_bufPos = idx + 1;
var text = _line.ToString().Trim();
_line.Clear();
if (text.Length == 0) continue;
try
{
return JsonNode.Parse(text) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
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" : 대상 PC의 Active 수신 포트
/// - "JJServiceManager Passive" : 관리 PC의 Passive 수신 포트
/// (+ 테스트 인스턴스 꼬리표)
/// </summary>
public static class FirewallManager
{
public const string ActiveRule = "JJServiceManager Remote";
public const string PassiveRule = "JJServiceManager Passive";
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
public static (bool ok, string message) Apply(string rule, bool enabled, int port)
{
var name = rule + AppInstance.Suffix;
// 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음)
ConsoleRunner.Run("netsh.exe", $"advfirewall firewall delete rule name=\"{name}\"");
if (!enabled) return (true, $"방화벽 규칙 삭제 ({name})");
var r = ConsoleRunner.Run("netsh.exe", $"advfirewall firewall add rule name=\"{name}\" dir=in action=allow protocol=TCP localport={port} profile=any");
return r.exitCode == 0
? (true, $"방화벽 TCP {port} 허용 ({name})")
: (false, $"방화벽 규칙 추가 실패 ({name}): {r.output}");
}
}
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; }
}
}
}

708
Core/Remote/RemoteServer.cs Normal file
View File

@ -0,0 +1,708 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace JJServiceManager.Remote
{
/// <summary>원격관리(화면) 허용 설정</summary>
public readonly record struct ScreenPolicy(bool AllowView, bool AllowControl, bool RequireApproval);
/// <summary>지금 이 PC 화면을 보고 있는 관리 PC</summary>
public readonly record struct ScreenViewer(int Id, string Who, bool Control);
/// <summary>
/// 원격 접속 허용 (대상 역할).
/// - Active: 포트를 열고 관리 PC의 접속을 받음
/// - Passive: 관리 PC로 접속해 감 (접속 코드)
/// 연결된 뒤에는 똑같이, 이 PC에 "등록된 서비스"에 한해서만
/// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리하고,
/// 허용된 경우 화면 보기 / 키보드·마우스 제어를 처리함.
/// </summary>
public sealed class RemoteServer : IDisposable
{
public const string Version = "1.2";
public const string SshUser = "jjsm";
public const string PassiveSshUser = "jjsm-passive";
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 Func<ScreenPolicy> _policy;
private readonly Func<string, bool, Task<bool>> _approve;
private readonly object _sync = new object();
private RemoteListener _listener;
private string _hash = "", _salt = "";
private readonly Queue<DateTime> _failures = new Queue<DateTime>();
// Passive
private CancellationTokenSource _passiveCts;
// 화면 보기 중인 세션
private readonly ConcurrentDictionary<int, ScreenSession> _screens = new ConcurrentDictionary<int, ScreenSession>();
private int _nextScreenId;
public bool IsRunning => _listener != null;
public RemoteMode Mode { get; private set; }
public int Port { get; private set; }
/// <summary>화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드)</summary>
public event Action<IReadOnlyList<ScreenViewer>> ScreenViewersChanged;
/// <param name="services">등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)</param>
/// <param name="log">작업 로그 기록</param>
/// <param name="changed">원격에서 Start/Stop 했을 때 (상태 갱신용)</param>
/// <param name="policy">원격관리 허용 설정</param>
/// <param name="approve">화면 연결 승인 요청 (누가, 제어 여부) → 허용 여부</param>
public RemoteServer(Func<IReadOnlyList<ServiceEntry>> services, Action<string, LogLevel> log, Action changed,
Func<ScreenPolicy> policy, Func<string, bool, Task<bool>> approve)
{
_services = services;
_log = log;
_changed = changed;
_policy = policy;
_approve = approve;
}
/// <summary>비밀번호만 바뀐 경우 (재시작 없이)</summary>
public void UpdatePassword(string hash, string salt)
{
lock (_sync)
{
_hash = hash ?? "";
_salt = salt ?? "";
}
}
// ───────────────────────── Active: 접속 받기 ─────────────────────────
public void Start(RemoteMode mode, int port, string hash, string salt)
{
Stop();
UpdatePassword(hash, salt);
Mode = mode;
Port = port;
try
{
_listener = RemoteListener.Start(mode, port, CheckSshPasswordAsync,
(conn, peer, ct) => ServeAsync(conn, peer, requireHelloPassword: mode == RemoteMode.Port, ct));
_listener.Failed += msg =>
{
_log($"원격 접속 허용(SSH) 중단: {msg}", LogLevel.Error);
_listener?.Dispose();
_listener = null;
};
_log($"원격 접속 허용 시작 (Active 수신): {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
}
catch (Exception ex)
{
_listener = null;
var msg = ex is System.Net.Sockets.SocketException se && se.SocketErrorCode == System.Net.Sockets.SocketError.AddressAlreadyInUse
? $"포트 {port}를 다른 프로그램이 사용 중입니다."
: ex.Message;
_log($"원격 접속 허용 시작 실패 (포트 {port}): {msg}", LogLevel.Error);
}
}
public void Stop()
{
if (_listener == null) return;
_listener.Dispose();
_listener = null;
_log("원격 접속 허용 (Active 수신) 중지", LogLevel.Info);
}
public void Dispose()
{
Stop();
StopPassive();
StopAllScreens();
}
// ───────────────────────── Passive: 관리 PC로 접속 ─────────────────────────
/// <summary>Passive 접속 목록 적용 (빈 목록이면 중지)</summary>
public void StartPassive(IReadOnlyList<PassiveManager> managers)
{
StopPassive();
if (managers == null || managers.Count == 0) return;
_passiveCts = new CancellationTokenSource();
foreach (var m in managers) _ = Task.Run(() => PassiveLoopAsync(m.Clone(), _passiveCts.Token));
}
public void StopPassive()
{
try { _passiveCts?.Cancel(); } catch { }
_passiveCts = null;
}
private async Task PassiveLoopAsync(PassiveManager m, CancellationToken ct)
{
string where = $"{m.Address}:{m.Port}";
string lastProblem = null;
while (!ct.IsCancellationRequested)
{
var retry = TimeSpan.FromSeconds(5);
try
{
JsonLineConnection conn;
using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
timeout.CancelAfter(TimeSpan.FromSeconds(10));
conn = await RemoteDialer.DialAsync(m.Mode, m.Address, m.Port, PassiveSshUser, m.AccessCode, timeout.Token).ConfigureAwait(false);
await conn.SendAsync(new JsonObject
{
["op"] = "announce",
["code"] = m.AccessCode,
["hostname"] = Environment.MachineName,
["version"] = Version
}, timeout.Token).ConfigureAwait(false);
}
_log($"Passive 연결됨: 관리 PC {where}", LogLevel.Success);
lastProblem = null;
await ServeAsync(conn, $"{m.Address}(Passive)", requireHelloPassword: true, ct).ConfigureAwait(false);
if (!ct.IsCancellationRequested) _log($"Passive 연결 끊김: 관리 PC {where}", LogLevel.Warning);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
if (ex is RemoteAuthException) retry = TimeSpan.FromSeconds(30);
var msg = RemoteClient.Friendly(ex);
if (msg != lastProblem)
{
_log($"Passive 접속 실패 (관리 PC {where}): {msg}", LogLevel.Warning);
lastProblem = msg;
}
}
catch
{
break;
}
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
catch { break; }
}
}
// ───────────────────────── 인증 ─────────────────────────
private async Task<bool> CheckSshPasswordAsync(string user, string password)
{
if (user != SshUser) return false;
if (IsThrottled()) return false;
if (CheckPassword(password)) return true;
RecordFailure();
await Task.Delay(1000).ConfigureAwait(false);
_log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning);
return false;
}
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;
ScreenSession screen = null;
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);
var pol = _policy();
await Reply(conn, id, result: new JsonObject
{
["hostname"] = Environment.MachineName,
["version"] = Version,
["instance"] = AppInstance.Number,
["allowView"] = pol.AllowView,
["allowControl"] = pol.AllowView && pol.AllowControl
});
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;
}
// ───── 원격관리: 화면 ─────
case "screenInfo":
{
var pol = _policy();
var arr = new JsonArray();
foreach (var m in ScreenCapture.Monitors())
arr.Add(new JsonObject { ["index"] = m.Index, ["width"] = m.Width, ["height"] = m.Height, ["primary"] = m.Primary });
await Reply(conn, id, result: new JsonObject
{
["allowView"] = pol.AllowView,
["allowControl"] = pol.AllowView && pol.AllowControl,
["monitors"] = arr
});
break;
}
case "screenStart":
{
var whoNow = who;
int monitor = Json.Int(msg, "monitor", 1);
// 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행
_ = Task.Run(async () =>
{
try
{
var pol = _policy();
if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다.");
bool control = pol.AllowControl;
if (pol.RequireApproval)
{
_log($"[원격 화면] {whoNow} 연결 승인 요청", LogLevel.Notice);
bool ok = await _approve(whoNow, control).ConfigureAwait(false);
if (!ok)
{
_log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning);
throw new RemoteException("대상 PC에서 연결을 거부했습니다.");
}
}
var old = Interlocked.Exchange(ref screen, null);
EndScreen(old);
var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor);
screen = s;
_screens[s.Id] = s;
_log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice);
NotifyViewers();
await Reply(conn, id, result: new JsonObject { ["sid"] = s.Id, ["control"] = control });
_ = Task.Run(() => ScreenLoopAsync(conn, s, ct));
}
catch (RemoteException ex) { await Reply(conn, id, error: ex.Message); }
catch (Exception ex) { await Reply(conn, id, error: ex.Message); }
});
break;
}
case "screenMonitor":
{
var s = screen;
if (s != null) s.Monitor = Json.Int(msg, "monitor", 1);
await Reply(conn, id, result: new JsonObject());
break;
}
case "screenStop":
{
var s = Interlocked.Exchange(ref screen, null);
EndScreen(s);
await Reply(conn, id, result: new JsonObject());
break;
}
case "input":
{
// 응답 없이 바로 실행 (빠르게)
var s = screen;
if (s == null || s.Stopped || !s.Control) break;
var pol = _policy();
if (!pol.AllowView || !pol.AllowControl) break;
try
{
if (Json.Str(msg, "kind") == "key")
{
InputInjector.Key(Json.Int(msg, "vk"), Json.Bool(msg, "down"));
}
else
{
double nx = 0, ny = 0;
try { nx = msg["x"]?.GetValue<double>() ?? 0; ny = msg["y"]?.GetValue<double>() ?? 0; } catch { }
InputInjector.Mouse(ScreenCapture.Area(s.Monitor), nx, ny,
Json.Str(msg, "action"), Json.Str(msg, "button"), Json.Int(msg, "delta"));
}
}
catch { }
break;
}
default:
await Reply(conn, id, error: "알 수 없는 요청: " + op);
break;
}
}
}
catch
{
// 연결 끊김
}
finally
{
lock (streams)
{
foreach (var t in streams.Values) t.Dispose();
streams.Clear();
}
var s = Interlocked.Exchange(ref screen, null);
EndScreen(s);
conn.Dispose();
if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info);
}
}
// ───────────────────────── 원격관리: 화면 보내기 ─────────────────────────
private sealed class ScreenSession
{
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
public int Id { get; }
public string Who { get; }
public bool Control { get; }
public volatile int Monitor;
public bool Stopped => _cts.IsCancellationRequested;
public CancellationToken Token => _cts.Token;
public ScreenSession(int id, string who, bool control, int monitor)
{
Id = id;
Who = who;
Control = control;
Monitor = monitor;
}
public void Stop()
{
try { _cts.Cancel(); } catch { }
}
}
private async Task ScreenLoopAsync(JsonLineConnection conn, ScreenSession s, CancellationToken ct)
{
const int FrameMs = 120; // 최대 약 8장/초
ulong lastHash = 0;
var lastSent = DateTime.MinValue;
int lastMonitor = -1;
string endReason = "종료";
try
{
while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed)
{
var sw = Stopwatch.StartNew();
var pol = _policy();
if (!pol.AllowView)
{
endReason = "대상 PC에서 화면 보기 허용을 껐습니다.";
break;
}
int monitor = s.Monitor;
var area = ScreenCapture.Area(monitor);
int maxWidth = monitor == 0 ? 2560 : 1920;
byte[] jpeg = null;
int w = 0, h = 0;
ulong hash = 0;
try { jpeg = ScreenCapture.CaptureJpeg(area, maxWidth, 60L, out w, out h, out hash); }
catch { /* 잠금 화면 등 캡처할 수 없는 순간 */ }
bool changed = hash != lastHash || monitor != lastMonitor;
if (jpeg != null && (changed || DateTime.UtcNow - lastSent > TimeSpan.FromSeconds(2)))
{
await conn.SendAsync(new JsonObject
{
["event"] = "frame",
["sid"] = s.Id,
["monitor"] = monitor,
["w"] = w,
["h"] = h,
["data"] = Convert.ToBase64String(jpeg)
}, ct).ConfigureAwait(false); // 보내는 도중 취소하면 TLS 스트림이 깨지므로 화면 중지 토큰은 쓰지 않음
lastHash = hash;
lastMonitor = monitor;
lastSent = DateTime.UtcNow;
}
int wait = FrameMs - (int)sw.ElapsedMilliseconds;
if (wait > 0) await Task.Delay(wait, s.Token).ConfigureAwait(false);
}
}
catch
{
// 연결 끊김 또는 중지
}
finally
{
s.Stop();
_screens.TryRemove(s.Id, out _);
if (!conn.IsDisposed)
{
try
{
using var t = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await conn.SendAsync(new JsonObject { ["event"] = "screenEnd", ["sid"] = s.Id, ["reason"] = endReason }, t.Token).ConfigureAwait(false);
}
catch { }
}
_log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info);
NotifyViewers();
}
}
/// <summary>화면 연결 하나 끝내기 — 표시 막대는 바로 갱신</summary>
private void EndScreen(ScreenSession s)
{
if (s == null) return;
s.Stop();
if (_screens.TryRemove(s.Id, out _)) NotifyViewers();
}
/// <summary>이 PC에서 [연결 끊기]를 눌렀을 때 모든 화면 연결 종료</summary>
public void StopAllScreens()
{
foreach (var s in _screens.Values.ToList()) EndScreen(s);
}
private void NotifyViewers()
{
var list = _screens.Values.Where(s => !s.Stopped).Select(s => new ScreenViewer(s.Id, s.Who, s.Control)).ToList();
ScreenViewersChanged?.Invoke(list);
}
// ───────────────────────── 서비스 ─────────────────────────
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
});
}
var pol = _policy();
return new JsonObject
{
["services"] = arr,
["allowView"] = pol.AllowView,
["allowControl"] = pol.AllowView && pol.AllowControl
};
}
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 { }
}
}
}

View File

@ -0,0 +1,229 @@
using System;
using System.Diagnostics;
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.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 SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer;
namespace JJServiceManager.Remote
{
/// <summary>
/// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH).
/// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용
/// </summary>
public sealed class RemoteListener : IDisposable
{
private static X509Certificate2 _cert;
private static readonly object CertLock = new object();
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private TcpListener _tcp;
private SshServerTcp _ssh;
public RemoteMode Mode { get; }
public int Port { get; }
/// <summary>SSH가 비정상 종료되었을 때</summary>
public event Action<string> Failed;
private RemoteListener(RemoteMode mode, int port)
{
Mode = mode;
Port = port;
}
/// <param name="sshAuth">SSH 비밀번호 확인 (user, password) → 허용 여부</param>
/// <param name="onConnection">연결 하나가 생길 때 (연결, 상대 표시, 취소)</param>
public static RemoteListener Start(RemoteMode mode, int port,
Func<string, string, Task<bool>> sshAuth,
Func<JsonLineConnection, string, CancellationToken, Task> onConnection)
{
var l = new RemoteListener(mode, port);
if (mode == RemoteMode.Port) l.StartTls(onConnection);
else l.StartSsh(sshAuth, onConnection);
return l;
}
private void StartTls(Func<JsonLineConnection, string, CancellationToken, Task> onConnection)
{
var cert = Certificate();
_tcp = new TcpListener(IPAddress.Any, Port);
_tcp.Start();
var listener = _tcp;
var ct = _cts.Token;
_ = Task.Run(async () =>
{
while (!ct.IsCancellationRequested)
{
TcpClient tcp;
try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
catch { break; }
_ = Task.Run(async () =>
{
string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
if (peer.StartsWith("::ffff:")) peer = peer.Substring(7);
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);
}
await onConnection(new JsonLineConnection(ssl, tcp), peer, ct).ConfigureAwait(false);
}
catch
{
try { tcp.Dispose(); } catch { }
}
});
}
});
}
private void StartSsh(Func<string, string, Task<bool>> sshAuth, Func<JsonLineConnection, string, CancellationToken, Task> onConnection)
{
var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
ssh.Credentials = new SshServerCredentials(new[] { hostKey });
var ct = _cts.Token;
ssh.SessionAuthenticating += (s, e) =>
{
if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
{
var user = e.Username;
var pw = e.Password;
e.AuthenticationTask = Task.Run(async () =>
await sshAuth(user, pw).ConfigureAwait(false) ? new ClaimsPrincipal(new ClaimsIdentity("password")) : null);
}
// 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
};
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;
_ = Task.Run(() => onConnection(new JsonLineConnection(new SshStream(channel)), "SSH", ct));
};
};
_ssh = ssh;
_ = ssh.AcceptSessionsAsync(Port, IPAddress.Any).ContinueWith(t =>
{
if (t.IsFaulted && !ct.IsCancellationRequested)
{
var ex = t.Exception?.GetBaseException();
var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse
? $"포트 {Port}를 다른 프로그램이 사용 중입니다."
: ex?.Message ?? "알 수 없는 오류";
Failed?.Invoke(msg);
}
}, TaskScheduler.Default);
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _tcp?.Stop(); } catch { }
try { _ssh?.Dispose(); } catch { }
}
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
private static X509Certificate2 Certificate()
{
lock (CertLock)
{
if (_cert != null) return _cert;
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로 한 번 내보냈다가 다시 읽어야 함
_cert = new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
return _cert;
}
}
}
/// <summary>접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)</summary>
public static class RemoteDialer
{
/// <summary>
/// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
/// TLS는 암호화만 하고 서버 지문은 확인하지 않음.
/// </summary>
public static async Task<JsonLineConnection> DialAsync(RemoteMode mode, string host, int port,
string sshUser, string sshPassword, CancellationToken ct)
{
host = (host ?? "").Trim();
if (mode == RemoteMode.Port)
{
var tcp = new TcpClient { NoDelay = true };
try
{
await tcp.ConnectAsync(host, port, ct).ConfigureAwait(false);
var ssl = new SslStream(tcp.GetStream(), false);
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = "JJServiceManager",
RemoteCertificateValidationCallback = (a, b, c, d) => true
}, ct).ConfigureAwait(false);
return new JsonLineConnection(ssl, tcp);
}
catch
{
tcp.Dispose();
throw;
}
}
var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient"));
try
{
var session = await client.OpenSessionAsync(host, port, ct).ConfigureAwait(false);
// 서버 지문 확인 안 함 (설정: 확인 없이 암호화만)
session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal());
bool ok = await session.AuthenticateAsync(new SshClientCredentials(sshUser, sshPassword), ct).ConfigureAwait(false);
if (!ok)
{
session.Dispose();
throw new RemoteAuthException(sshUser == RemoteServer.PassiveSshUser
? "관리 PC가 접속 코드를 받아들이지 않았습니다."
: "접속 비밀번호가 맞지 않습니다.");
}
var channel = await session.OpenChannelAsync(ct).ConfigureAwait(false);
return new JsonLineConnection(new SshStream(channel), session, client);
}
catch
{
client.Dispose();
throw;
}
}
}
}

View File

@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Forms = System.Windows.Forms;
namespace JJServiceManager.Remote
{
/// <summary>모니터 정보 (실제 픽셀 좌표)</summary>
public sealed class MonitorInfo
{
public int Index { get; set; }
public string Name { get; set; } = "";
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public bool Primary { get; set; }
}
/// <summary>대상 PC: 화면 캡처</summary>
public static class ScreenCapture
{
/// <summary>모니터 목록 (1부터, 주 모니터 우선이 아니라 Windows 순서)</summary>
public static List<MonitorInfo> Monitors()
{
var list = new List<MonitorInfo>();
int i = 1;
foreach (var s in Forms.Screen.AllScreens.OrderBy(s => s.Bounds.X).ThenBy(s => s.Bounds.Y))
{
list.Add(new MonitorInfo
{
Index = i++,
Name = s.DeviceName,
X = s.Bounds.X,
Y = s.Bounds.Y,
Width = s.Bounds.Width,
Height = s.Bounds.Height,
Primary = s.Primary
});
}
return list;
}
/// <summary>monitor 0 = 전체 화면, 1~ = 해당 모니터</summary>
public static Rectangle Area(int monitor)
{
if (monitor > 0)
{
var m = Monitors().FirstOrDefault(x => x.Index == monitor);
if (m != null) return new Rectangle(m.X, m.Y, m.Width, m.Height);
}
return Forms.SystemInformation.VirtualScreen;
}
[StructLayout(LayoutKind.Sequential)]
private struct CURSORINFO
{
public int cbSize;
public int flags;
public IntPtr hCursor;
public POINT ptScreenPos;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct ICONINFO
{
public bool fIcon;
public int xHotspot;
public int yHotspot;
public IntPtr hbmMask;
public IntPtr hbmColor;
}
[DllImport("user32.dll")] private static extern bool GetCursorInfo(ref CURSORINFO pci);
[DllImport("user32.dll")] private static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
[DllImport("user32.dll")] private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int step, IntPtr brush, int flags);
[DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr h);
/// <summary>
/// 영역을 캡처해 JPEG로. maxWidth보다 넓으면 줄임.
/// hash: 변화 확인용 (같으면 보내지 않음)
/// </summary>
public static byte[] CaptureJpeg(Rectangle area, int maxWidth, long quality, out int width, out int height, out ulong hash)
{
using var bmp = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppRgb);
using (var g = Graphics.FromImage(bmp))
{
g.CopyFromScreen(area.X, area.Y, 0, 0, area.Size, CopyPixelOperation.SourceCopy);
DrawCursor(g, area);
}
hash = Hash(bmp);
Bitmap output = bmp;
Bitmap scaled = null;
if (area.Width > maxWidth)
{
double r = (double)maxWidth / area.Width;
int w = maxWidth, h = Math.Max(1, (int)(area.Height * r));
scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb);
using var g2 = Graphics.FromImage(scaled);
g2.InterpolationMode = InterpolationMode.HighQualityBilinear;
g2.DrawImage(bmp, 0, 0, w, h);
output = scaled;
}
try
{
width = output.Width;
height = output.Height;
using var ms = new MemoryStream();
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using var ep = new EncoderParameters(1);
ep.Param[0] = new EncoderParameter(Encoder.Quality, quality);
output.Save(ms, codec, ep);
return ms.ToArray();
}
finally
{
scaled?.Dispose();
}
}
private static void DrawCursor(Graphics g, Rectangle area)
{
try
{
var ci = new CURSORINFO { cbSize = Marshal.SizeOf<CURSORINFO>() };
if (!GetCursorInfo(ref ci) || ci.flags != 1 /* CURSOR_SHOWING */) return;
int hx = 0, hy = 0;
if (GetIconInfo(ci.hCursor, out var ii))
{
hx = ii.xHotspot;
hy = ii.yHotspot;
if (ii.hbmMask != IntPtr.Zero) DeleteObject(ii.hbmMask);
if (ii.hbmColor != IntPtr.Zero) DeleteObject(ii.hbmColor);
}
int x = ci.ptScreenPos.X - area.X - hx;
int y = ci.ptScreenPos.Y - area.Y - hy;
if (x < -64 || y < -64 || x > area.Width || y > area.Height) return;
var hdc = g.GetHdc();
try { DrawIconEx(hdc, x, y, ci.hCursor, 0, 0, 0, IntPtr.Zero, 3 /* DI_NORMAL */); }
finally { g.ReleaseHdc(hdc); }
}
catch { }
}
/// <summary>화면이 바뀌었는지 빠르게 확인하기 위한 값 (일부 픽셀만 사용)</summary>
private static unsafe ulong Hash(Bitmap bmp)
{
var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppRgb);
try
{
ulong h = 1469598103934665603UL;
byte* p = (byte*)data.Scan0;
for (int y = 0; y < data.Height; y += 4)
{
uint* row = (uint*)(p + (long)y * data.Stride);
for (int x = 0; x < data.Width; x += 4)
{
h ^= row[x];
h *= 1099511628211UL;
}
}
return h;
}
finally
{
bmp.UnlockBits(data);
}
}
}
/// <summary>대상 PC: 원격에서 받은 마우스/키보드 입력을 실행</summary>
public static class InputInjector
{
private const int INPUT_MOUSE = 0;
private const int INPUT_KEYBOARD = 1;
private const uint MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004,
MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010, MOUSEEVENTF_MIDDLEDOWN = 0x0020, MOUSEEVENTF_MIDDLEUP = 0x0040,
MOUSEEVENTF_WHEEL = 0x0800, MOUSEEVENTF_HWHEEL = 0x1000, MOUSEEVENTF_VIRTUALDESK = 0x4000, MOUSEEVENTF_ABSOLUTE = 0x8000;
private const uint KEYEVENTF_EXTENDEDKEY = 0x0001, KEYEVENTF_KEYUP = 0x0002;
[StructLayout(LayoutKind.Sequential)]
private struct MOUSEINPUT { public int dx; public int dy; public int mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Explicit)]
private struct InputUnion
{
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
}
[StructLayout(LayoutKind.Sequential)]
private struct INPUT { public int type; public InputUnion U; }
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
private static extern uint MapVirtualKey(uint uCode, uint uMapType);
/// <summary>
/// 마우스. nx, ny = 캡처 영역 안의 비율(0~1).
/// action: move / down / up / wheel / hwheel, button: left / right / middle
/// </summary>
public static void Mouse(Rectangle area, double nx, double ny, string action, string button, int delta)
{
var vs = Forms.SystemInformation.VirtualScreen;
nx = Math.Clamp(nx, 0, 1);
ny = Math.Clamp(ny, 0, 1);
double px = area.X + nx * (area.Width - 1);
double py = area.Y + ny * (area.Height - 1);
int ax = (int)Math.Round((px - vs.X) * 65535.0 / Math.Max(1, vs.Width - 1));
int ay = (int)Math.Round((py - vs.Y) * 65535.0 / Math.Max(1, vs.Height - 1));
uint flags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
int data = 0;
switch (action)
{
case "down":
flags |= button switch { "right" => MOUSEEVENTF_RIGHTDOWN, "middle" => MOUSEEVENTF_MIDDLEDOWN, _ => MOUSEEVENTF_LEFTDOWN };
break;
case "up":
flags |= button switch { "right" => MOUSEEVENTF_RIGHTUP, "middle" => MOUSEEVENTF_MIDDLEUP, _ => MOUSEEVENTF_LEFTUP };
break;
case "wheel":
flags |= MOUSEEVENTF_WHEEL;
data = delta;
break;
case "hwheel":
flags |= MOUSEEVENTF_HWHEEL;
data = delta;
break;
}
var input = new INPUT { type = INPUT_MOUSE };
input.U.mi = new MOUSEINPUT { dx = ax, dy = ay, mouseData = data, dwFlags = flags };
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
}
/// <summary>키보드 (가상 키 코드)</summary>
public static void Key(int vk, bool down)
{
if (vk <= 0 || vk > 254) return;
uint flags = down ? 0 : KEYEVENTF_KEYUP;
if (IsExtended(vk)) flags |= KEYEVENTF_EXTENDEDKEY;
var input = new INPUT { type = INPUT_KEYBOARD };
input.U.ki = new KEYBDINPUT
{
wVk = (ushort)vk,
wScan = (ushort)MapVirtualKey((uint)vk, 0),
dwFlags = flags
};
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
}
private static bool IsExtended(int vk) => vk switch
{
0x21 or 0x22 or 0x23 or 0x24 => true, // PgUp PgDn End Home
0x25 or 0x26 or 0x27 or 0x28 => true, // 방향키
0x2D or 0x2E => true, // Insert Delete
0x5B or 0x5C or 0x5D => true, // Win Apps
0x6F => true, // Num /
0x90 => true, // NumLock
0xA3 or 0xA5 => true, // 오른쪽 Ctrl, Alt
0x2C => true, // PrintScreen
_ => false
};
}
}

View File

@ -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>
@ -93,36 +95,8 @@ $@"<?xml version=""1.0"" encoding=""UTF-16""?>
return (r.ExitCode == 0, r.Output);
}
/// <summary>명령 프롬프트 출력 인코딩 (한국어 Windows = CP949)</summary>
private static Encoding OemEncoding()
{
try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
catch { return Encoding.UTF8; }
}
private static (int ExitCode, string Output) RunSchtasks(string args)
{
try
{
var psi = new ProcessStartInfo("schtasks.exe", args)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = OemEncoding(),
StandardErrorEncoding = OemEncoding()
};
using var p = Process.Start(psi);
var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
p.WaitForExit(15000);
return (p.ExitCode, output.Trim());
}
catch (Exception ex)
{
return (-1, ex.Message);
}
}
=> ConsoleRunner.Run("schtasks.exe", args);
}
/// <summary>전역 단축키 (어느 프로그램을 쓰고 있어도 동작)</summary>

View File

@ -6,14 +6,17 @@
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RootNamespace>JJServiceManager</RootNamespace>
<AssemblyName>JJServiceManager</AssemblyName>
<AssemblyName>JJServiceManagerEnt</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- [테스트 빌드 전용] dotnet publish -p:TestBuild=true → 관리자 권한 없이 실행 (화면 자동 점검용) -->
<ApplicationManifest Condition="'$(TestBuild)' == 'true'">app.test.manifest</ApplicationManifest>
<ApplicationIcon>app.ico</ApplicationIcon>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>1.0.0</Version>
<Product>JJServiceManager</Product>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Version>2.0.0</Version>
<Product>JJServiceManagerEnt</Product>
<Description>Windows 서비스 기동/종료 관리</Description>
<SatelliteResourceLanguages>ko</SatelliteResourceLanguages>
</PropertyGroup>
@ -22,6 +25,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>

View File

@ -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)

145
README.md
View File

@ -1,27 +1,34 @@
# JJServiceManager
# JJServiceManager (Ent)
Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
- v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다.
- v2.0부터 **원격관리(화면 보기 / 키보드·마우스 제어)** 와 **Active / Passive 접속 방향 선택**이 추가되었습니다.
## 실행 파일
| 파일 | 크기 | 필요 조건 |
|---|---|---|
| `JJServiceManager.exe` (런타임 필요판) | 약 1.4MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 |
| `JJServiceManager.exe` (단독 실행판) | 약 65MB | 없음 |
| `JJServiceManagerEnt.exe` (런타임 필요판) | 약 2MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 |
| `JJServiceManagerEnt.exe` (단독 실행판) | 약 65MB | 없음 |
항상 **관리자 권한**으로 실행됩니다 (실행할 때 UAC 창이 한 번 뜹니다).
## 화면
### 초기화면
- **추가**: 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", "192.168.0.20 / SERVER02 (Passive)" …), PC별 접기/펼치기
- **PC 탭 추가** 콤보박스: 고른 PC만 보여주는 탭을 옆에 추가, ✕로 닫기 (다음 실행 때도 유지)
- 원격 서비스 앞에는 `IP/hostname` 표시 (Passive 대상은 뒤에 `(Passive)`)
- 원격 PC 제목 줄의 **[원격관리]**: 그 PC 화면 보기 / 키보드·마우스 제어 (아래 "원격관리" 참고)
- 서비스마다 **Start / Stop / Status / Log**, 상태 자동 갱신 (초록=실행 중, 빨강=중지됨)
- 서비스 줄 **우클릭**(내 PC): 로그 파일 지정 / 로그 폴더 지정 / 경로 해제
- 하단 **로그**: 작업 기록 (번호 + 체크박스)
- 전체선택 / 전체취소 / 복사(체크한 줄)
- **화면클리어**: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
@ -33,22 +40,109 @@ 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의 서비스를 초기화면에 표시, Start/Stop, 로그 보기, 원격관리 |
### 접속 방향 (대상 PC마다 선택)
| 방향 | 누가 접속하나 | 쓰는 경우 | 연결 방법 |
|---|---|---|---|
| **Active** | 관리 PC → 대상 PC | 관리 PC가 대상 PC에 직접 접속할 수 있을 때 | 대상 PC의 IP·포트 |
| **Passive** | 대상 PC → 관리 PC | 대상 PC가 방화벽·공유기 뒤에 있어서 들어갈 수 없을 때 | **접속 코드** (예: `3HY7-6TEH`) |
- Passive는 대상 PC가 관리 PC로 먼저 접속한 뒤, 연결 안에서는 Active와 똑같이 동작합니다 (서비스 목록, Start/Stop, 로그, 원격관리).
- 관리 PC는 접속 코드로 어느 대상 PC인지 구분합니다. 코드가 맞지 않는 접속은 거부합니다.
- 연결이 끊기면 양쪽 모두 자동으로 다시 연결합니다.
### 통신 방식
| 방식 | Active 기본 포트 (대상 PC) | Passive 기본 포트 (관리 PC) | 설명 |
|---|---|---|---|
| SSH (프로그램 내장) | 2222 | 2224 | OpenSSH 설치 없이 프로그램 안의 SSH 서버 사용 |
| 지정 포트 (TLS) | 9988 | 9990 | 프로그램끼리 TLS로 암호화해 통신 |
- 인증: 대상 PC에서 정한 **접속 비밀번호** (대상 PC에는 해시로만 저장, 관리 PC에는 Windows 계정으로 암호화해 저장)
- 서버 지문(인증서) 확인은 하지 않음 (암호화만)
- 비밀번호를 5분 동안 10번 넘게 틀리면 잠시 접속 거부
- Windows 방화벽 규칙을 자동으로 열고 닫음
- 대상 PC Active 수신: `JJServiceManager Remote`
- 관리 PC Passive 수신: `JJServiceManager Passive`
### 사용 순서 — Active
1. **대상 PC**: 설정 → 원격 접속 허용 → 켜기, 접속 비밀번호 입력, **Active 수신** 켜기, 방식·포트 선택 → 확인
2. **관리 PC**: 설정 → 관리 대상 PC → 다른 PC 관리 켜기 → 연결 방향 **Active**, 대상 IP, 같은 방식·포트, 비밀번호 입력 → [연결 테스트] → [추가] → 확인
3. 초기화면에 대상 PC 묶음이 나타나고, hostname은 자동으로 채워짐
### 사용 순서 — Passive
1. **관리 PC**: 설정 → 관리 대상 PC
- 다른 PC 관리 켜기, **Passive 수신 방식 / 포트** 확인 (기본: 지정 포트 9990, Passive 대상이 하나라도 있을 때만 열림)
- 연결 방향 **Passive** 선택 → 접속 코드가 자동으로 만들어짐 ([새 코드]로 다시 만들기)
- 대상 PC의 접속 비밀번호 입력 → [추가] → 확인
- 초기화면에 `Passive 접속코드` 묶음이 "연결 중…"으로 나타남
2. **대상 PC**: 설정 → 원격 접속 허용
- 켜기, 접속 비밀번호 입력 (관리 PC에 넣은 것과 같게)
- **Passive 접속** 칸에 관리 PC 주소, 접속 코드, 방식·포트(관리 PC의 Passive 수신과 같게) 입력 → [추가] → 확인
- 여러 관리 PC에 동시에 접속하도록 여러 줄 등록 가능
3. 대상 PC가 접속하면 관리 PC의 묶음 제목이 `IP / hostname (Passive)`로 바뀜
4. Active 수신이 필요 없으면 대상 PC에서 **Active 수신**을 꺼도 됨 (포트를 열지 않음)
### 숫자 6자리 확인
설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬).
여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다.
## 원격관리 (화면 보기 / 키보드·마우스 제어)
### 대상 PC 설정 (원격 접속 허용 탭 → 원격관리)
| 항목 | 설명 |
|---|---|
| 컴퓨터 시작시 기동 | 로그인 시 트레이로 자동 실행 (작업 스케줄러 `JJServiceManager` 등록) |
| 단축키 | 직접 눌러서 지정 (기본 Ctrl + Shift + A), Enable 토글. 누르면 창 보이기/숨기기 |
| 서비스 시작시 로그보기 | 작업 로그를 초기화면 하단에 표시할지 여부 |
| 로그창 띄우기 | 개별 창 / 탭 / 좌우 분할 |
| Esc | 트레이로 (백그라운드) / 완전 종료 |
| Windows 종료키 | Windows 강제종료 / 재시작 버튼 표시 여부 |
| 화면 보기 허용 | 관리 PC에서 이 PC 화면을 볼 수 있음 |
| 화면 및 키보드·마우스 제어 허용 | 관리 PC에서 이 PC를 마우스·키보드로 조작할 수 있음 (화면 보기가 켜져 있어야 함) |
| 연결 시 승인 필요 | 원격관리를 시작할 때마다 이 PC에 승인 창을 띄움 (30초 안에 누르지 않으면 자동 거부) |
- 허용을 끄면 이미 연결된 원격관리도 바로 끊기거나(보기) 조작이 막힙니다(제어).
- 원격관리 중에는 대상 PC 화면 맨 위 가운데에 **빨간 표시 막대**("원격 제어 중: 관리 PC")가 항상 나타납니다. 막대의 **[연결 끊기]** 를 누르면 즉시 종료됩니다.
### 관리 PC에서 사용
1. 초기화면에서 원격 PC 제목 줄의 **[원격관리]** 클릭
2. 무작위 숫자 6자리 입력 (원격관리는 설정과 관계없이 **항상** 입력, 붙여넣기 불가)
3. 대상 PC에서 승인하면 원격관리 창이 열림
- **모니터 1 / 모니터 2 … / 전체**: 볼 모니터 선택 (주 모니터에 `(주)` 표시)
- 창을 열면 바로 제어가 시작됨 (제어 허용 시), 상태 줄에 `● 제어 중` 또는 `보기 전용`과 초당 장수 표시
- 창 안에서 클릭·드래그·휠·키보드 입력이 대상 PC로 전달됨 (창을 떠나면 눌린 키는 자동으로 뗌)
- **[연결 끊기]** 또는 창 닫기로 종료
- 화면은 JPEG로 압축해 최대 약 8장/초로 보내고, 바뀌지 않은 화면은 보내지 않습니다.
- Ctrl+Alt+Del, Windows 키 조합, UAC 창(보안 데스크톱)은 원격으로 조작할 수 없습니다.
## 테스트용 중복 실행
같은 PC에서 여러 개를 띄워 원격 기능을 시험할 때:
```
JJServiceManagerEnt.exe --instance 2
```
- 창 제목에 `[테스트 인스턴스 2]` 표시
- 설정 폴더를 따로 씀: `%AppData%\JJServiceManager_instance2`
- 기본 포트가 인스턴스 번호만큼 밀림 (인스턴스 N: Active 지정 포트 9988+N-1, SSH 2222+N-1 / Passive 수신 지정 포트 9990+N-1, SSH 2224+N-1)
- 번호가 2 이상 차이 나는 인스턴스끼리는 포트가 겹칠 수 있으니 설정에서 포트를 바꿔 쓰세요.
- 시작 프로그램 작업 이름, 방화벽 규칙 이름, 중복 실행 방지도 인스턴스별로 따로 씀
- 코드에서 `[테스트용 중복 실행]` 주석이 붙은 부분이 이 기능입니다.
## 설정
| 탭 | 항목 |
|---|---|
| 일반 | 컴퓨터 시작시 기동, 단축키(기본 Ctrl+Shift+A), 서비스 시작시 로그보기, 로그창 띄우기, Esc, Windows 종료키, 숫자 확인(내 PC/원격) |
| 원격 접속 허용 | 켜기/끄기, 접속 비밀번호, Active 수신(방식·포트), Passive 접속 목록(관리 PC 주소·접속 코드·방식·포트), 원격관리(화면 보기 / 제어 / 승인), 이 PC의 IP 안내 |
| 관리 대상 PC | 다른 PC 관리 켜기/끄기, Passive 수신 방식·포트, 대상 목록(연결 방향, 대상 IP 또는 접속 코드, 방식·포트, 비밀번호), hostname 자동, 연결 테스트(Active) |
## 저장 위치
- 설정: `%AppData%\JJServiceManager\settings.json`
@ -57,4 +151,7 @@ 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)
- 원격관리 화면 캡처·입력: `Core/Remote/ScreenCapture.cs`, 화면: `Windows/RemoteScreenWindows.cs`
- 관리자 권한 없이 시험할 때: `dotnet publish ... -p:TestBuild=true` (asInvoker 매니페스트, 서비스 Start/Stop·방화벽은 실패함)

View File

@ -1,9 +1,65 @@
<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"/>
<Button Content="원격관리" Tag="remoteDesktop" Visibility="{Binding RemoteButtonVisibility}"
Style="{DynamicResource RowButton}" Margin="14,0,0,0" MinHeight="22" Padding="8,0"
ToolTip="이 PC 화면 보기 / 키보드·마우스 제어 (대상 PC에서 허용해야 함)"/>
</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 +74,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"/>
<TextBlock x:Name="txtCount" Grid.Column="2" Foreground="#607D8B" VerticalAlignment="Center" Margin="0,0,16,0"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<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>
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="icServices">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#ECEFF1" BorderThickness="0,0,0,1" Padding="8,4" Background="Transparent">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox IsChecked="{Binding IsChecked, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" ToolTip="{Binding Tooltip}">
<Ellipse Width="10" Height="10" Fill="{Binding StatusBrush}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ServiceNameText}" Foreground="#90A4AE" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Content="Start" Tag="start" Style="{DynamicResource RowButton}" IsEnabled="{Binding IsIdle}"/>
<Button Content="Stop" Tag="stop" Style="{DynamicResource RowButton}" IsEnabled="{Binding IsIdle}"/>
<Button Content="Status" Tag="status" Style="{DynamicResource RowButton}"/>
<Button Content="Log" Tag="log" Style="{DynamicResource RowButton}" ToolTip="{Binding LogTooltip}" FontWeight="{Binding LogFontWeight}"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock x:Name="txtEmpty" Text="[추가] 버튼을 눌러 관리할 서비스를 등록하세요."
Foreground="#90A4AE" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<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>
</TabItem>
</TabControl>
</DockPanel>
</Border>

View File

@ -7,71 +7,292 @@
<Button x:Name="btnCancel" Content="취소" MinWidth="80"/>
</StackPanel>
<Border BorderBrush="#B0BEC5" BorderThickness="1" Background="White" Padding="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="210"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TabControl x:Name="tabs" Padding="0">
<!-- 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}"
ToolTip="Windows에 로그인하면 트레이로 자동 실행합니다. (작업 스케줄러에 등록)"/>
<TextBlock Text="로그인 시 트레이로 자동 실행" Foreground="#78909C" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═════════════ 1. 일반 ═════════════ -->
<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="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 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}"/>
<TextBlock DockPanel.Dock="Right" Text="Enable" VerticalAlignment="Center" Margin="16,0,8,0"/>
<TextBox x:Name="txtHotkey" DockPanel.Dock="Left" Width="190" Height="28" IsReadOnly="True" IsReadOnlyCaretVisible="False"
VerticalContentAlignment="Center" HorizontalContentAlignment="Center"
FontWeight="Bold" Cursor="Hand" Background="#FAFAFA"
ToolTip="여기를 클릭한 뒤 원하는 키 조합을 직접 누르세요. (Ctrl / Alt / Win 중 하나 이상 포함)"/>
</DockPanel>
<!-- 컴퓨터 시작시 기동 -->
<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}"
ToolTip="Windows에 로그인하면 트레이로 자동 실행합니다. (작업 스케줄러에 등록)"/>
<TextBlock Text="로그인 시 트레이로 자동 실행" Foreground="#78909C" VerticalAlignment="Center"/>
</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>
<!-- 단축키 -->
<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}"/>
<TextBlock DockPanel.Dock="Right" Text="Enable" VerticalAlignment="Center" Margin="16,0,8,0"/>
<TextBox x:Name="txtHotkey" DockPanel.Dock="Left" Width="190" Height="28" IsReadOnly="True" IsReadOnlyCaretVisible="False"
VerticalContentAlignment="Center" HorizontalContentAlignment="Center"
FontWeight="Bold" Cursor="Hand" Background="#FAFAFA"
ToolTip="여기를 클릭한 뒤 원하는 키 조합을 직접 누르세요. (Ctrl / Alt / Win 중 하나 이상 포함)"/>
</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. 개별 창"/>
<ComboBoxItem Content="2. 하나의 창에 탭으로"/>
<ComboBoxItem Content="3. 창을 좌우로 나누어 비교"/>
</ComboBox>
<!-- 서비스 시작시 로그보기 -->
<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>
<!-- 5. Esc -->
<TextBlock Grid.Row="4" Text="Esc" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbEsc" Grid.Row="4" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
<ComboBoxItem Content="트레이로 (백그라운드)"/>
<ComboBoxItem Content="완전 종료"/>
</ComboBox>
<!-- 로그창 띄우기 -->
<TextBlock Grid.Row="3" Text="로그창 띄우기" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbLogMode" Grid.Row="3" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
<ComboBoxItem Content="1. 개별 창"/>
<ComboBoxItem Content="2. 하나의 창에 탭으로"/>
<ComboBoxItem Content="3. 창을 좌우로 나누어 비교"/>
</ComboBox>
<!-- 6. 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>
<!-- 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>
<TextBlock Grid.Row="6" Grid.ColumnSpan="2" x:Name="txtDataPath" Foreground="#90A4AE" Margin="0,14,0,0" TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- 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>
<!-- 숫자 확인: 내 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=" 원격 접속 허용 ">
<ScrollViewer VerticalScrollBarVisibility="Auto" Background="White">
<StackPanel Margin="14,10">
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="원격 접속 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkServer" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="다른 PC가 이 PC의 등록된 서비스를 보고 Start / Stop 할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
</DockPanel>
<DockPanel Height="40">
<TextBlock Text="접속 비밀번호" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<PasswordBox x:Name="pwdServer" Width="170" Height="28" VerticalContentAlignment="Center"/>
<TextBlock Text="확인" VerticalAlignment="Center" Margin="10,0,6,0"/>
<PasswordBox x:Name="pwdServer2" Width="170" Height="28" VerticalContentAlignment="Center" HorizontalAlignment="Left"/>
</DockPanel>
<TextBlock x:Name="txtPwState" Margin="210,0,0,6" Foreground="#78909C"/>
<!-- Active 수신 -->
<TextBlock Text="Active 수신 (관리 PC → 이 PC로 접속)" Style="{DynamicResource SectionTitle}" Margin="0,10,0,2"/>
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="Active 수신" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkActiveListen" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="이 PC가 포트를 열고 관리 PC의 접속을 기다림" Foreground="#78909C" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Height="40">
<TextBlock Text="통신 방식 / 포트" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbServerMode" Width="220" Height="28" VerticalContentAlignment="Center">
<ComboBoxItem Content="SSH (프로그램 내장)"/>
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
</ComboBox>
<TextBox x:Name="txtServerPort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
<TextBlock x:Name="txtServerPortHint" Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0"/>
</DockPanel>
<!-- Passive 접속 -->
<TextBlock Text="Passive 접속 (이 PC → 관리 PC로 접속)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
<TextBlock Foreground="#78909C" TextWrapping="Wrap" Margin="0,0,0,6"
Text="이 PC가 방화벽·공유기 뒤에 있어도 됩니다. 관리 PC의 [관리 대상 PC]에서 이 PC를 Passive로 등록하면 나오는 접속 코드를 입력하세요."/>
<ListBox x:Name="lstPassive" Height="70" BorderBrush="#B0BEC5" HorizontalContentAlignment="Stretch" DisplayMemberPath="Label"/>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="70"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="34"/>
<RowDefinition Height="34"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="관리 PC 주소" VerticalAlignment="Center"/>
<TextBox x:Name="txtPmAddr" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0" ToolTip="관리 PC의 IP 또는 도메인"/>
<TextBlock Grid.Column="2" Text="접속 코드" VerticalAlignment="Center"/>
<TextBox x:Name="txtPmCode" Grid.Column="3" Width="130" Height="28" VerticalContentAlignment="Center" CharacterCasing="Upper" FontFamily="Consolas" FontWeight="Bold"/>
<TextBlock Grid.Row="1" Text="방식 / 포트" VerticalAlignment="Center"/>
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Orientation="Horizontal">
<ComboBox x:Name="cmbPmMode" Width="220" Height="28" VerticalContentAlignment="Center">
<ComboBoxItem Content="SSH (프로그램 내장)"/>
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
</ComboBox>
<TextBox x:Name="txtPmPort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
</StackPanel>
<DockPanel Grid.Row="2" Grid.ColumnSpan="4" Margin="0,4,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="btnPmNew" Content="새로 입력"/>
<Button x:Name="btnPmAdd" Content="추가" FontWeight="Bold"/>
<Button x:Name="btnPmUpdate" Content="수정"/>
<Button x:Name="btnPmDelete" Content="삭제"/>
</StackPanel>
<TextBlock x:Name="txtPmResult" VerticalAlignment="Center" TextWrapping="Wrap"/>
</DockPanel>
</Grid>
<!-- 원격관리 (화면) -->
<TextBlock Text="원격관리 (화면 보기 / 제어)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="화면 보기 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkAllowView" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="관리 PC에서 이 PC 화면을 볼 수 있게 함" Foreground="#78909C" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="화면 및 키보드·마우스 제어 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkAllowControl" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="관리 PC에서 이 PC를 마우스·키보드로 조작할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="연결 시 승인 필요" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkRequireApproval" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="원격 화면 연결 때마다 이 PC에서 허용 / 거부 (30초 안에 답이 없으면 거부)" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
</DockPanel>
<TextBlock Foreground="#78909C" Margin="0,0,0,8" TextWrapping="Wrap"
Text="원격 화면 연결 중에는 이 PC 화면 위쪽에 항상 표시줄이 나타나며 [연결 끊기]로 바로 끊을 수 있습니다. 잠금 화면·로그인 화면·UAC 창은 보거나 조작할 수 없습니다."/>
<Border Background="#F1F8FB" BorderBrush="#B0BEC5" BorderThickness="1" Padding="10" Margin="0,4,0,0">
<TextBlock x:Name="txtServerInfo" TextWrapping="Wrap" Foreground="#37474F" LineHeight="20"/>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- ═════════════ 3. 관리 대상 PC (관리 역할) ═════════════ -->
<TabItem Header=" 관리 대상 PC ">
<Border Background="White" Padding="14,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Height="40" 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>
<DockPanel DockPanel.Dock="Top" Height="40">
<TextBlock Text="Passive 수신 방식 / 포트" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<ComboBox x:Name="cmbPassiveMode" Width="220" Height="28" VerticalContentAlignment="Center">
<ComboBoxItem Content="SSH (프로그램 내장)"/>
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
</ComboBox>
<TextBox x:Name="txtPassivePort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
<TextBlock Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0" Text="Passive 대상이 있을 때만 열림"/>
</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="36"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="연결 방향" VerticalAlignment="Center"/>
<ComboBox x:Name="cmbDirection" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
<ComboBoxItem Content="Active (이 PC → 대상 PC로 접속)"/>
<ComboBoxItem Content="Passive (대상 PC → 이 PC로 접속)"/>
</ComboBox>
<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" x:Name="lblAddr" Text="대상 IP" VerticalAlignment="Center"/>
<TextBox x:Name="txtAddr" Grid.Row="1" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
ToolTip="예: 192.168.0.10 (컴퓨터 이름도 가능)"/>
<TextBlock Grid.Row="1" Grid.Column="2" x:Name="lblCode" Text="접속 코드" VerticalAlignment="Center"/>
<DockPanel Grid.Row="1" Grid.Column="3" x:Name="pnlCode">
<Button x:Name="btnNewCode" DockPanel.Dock="Right" Content="새 코드" MinWidth="0" Padding="8,2"/>
<TextBox x:Name="txtCode" Height="28" VerticalContentAlignment="Center" IsReadOnly="True"
FontFamily="Consolas" FontWeight="Bold" Background="#FFF8E1" ToolTip="대상 PC의 [원격 접속 허용 → Passive 접속]에 이 코드를 입력하세요."/>
</DockPanel>
<TextBlock Grid.Row="2" x:Name="lblMode" Text="통신 방식" VerticalAlignment="Center"/>
<ComboBox x:Name="cmbMode" Grid.Row="2" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
<ComboBoxItem Content="SSH (프로그램 내장)"/>
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="2" x:Name="lblPort" Text="포트" VerticalAlignment="Center"/>
<TextBox x:Name="txtPort" Grid.Row="2" Grid.Column="3" Width="100" HorizontalAlignment="Left" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
<TextBlock Grid.Row="3" Text="비밀번호" VerticalAlignment="Center"/>
<PasswordBox x:Name="pwdTarget" Grid.Row="3" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
ToolTip="대상 PC의 [원격 접속 허용] 접속 비밀번호"/>
<TextBlock Grid.Row="3" Grid.Column="2" Grid.ColumnSpan="2" x:Name="txtPwTarget" VerticalAlignment="Center" Foreground="#78909C"/>
<DockPanel Grid.Row="4" 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="90" HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="4,3">
<TextBlock DockPanel.Dock="Right" Text="{Binding ModeText}" Foreground="#607D8B" Margin="16,0,0,0"/>
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</TabItem>
</TabControl>
</DockPanel>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using JJServiceManager.Remote;
namespace JJServiceManager
{
/// <summary>대상 PC: 원격 화면 연결 승인 창 (30초 안에 답하지 않으면 거부)</summary>
public sealed class ApprovalWindow : Window
{
private readonly TaskCompletionSource<bool> _result = new TaskCompletionSource<bool>();
private readonly DispatcherTimer _timer;
private readonly TextBlock _countdown;
private int _left = 30;
private ApprovalWindow(string who, bool control)
{
Title = "원격관리 연결 요청" + AppInstance.TitleTag;
Width = 460;
SizeToContent = SizeToContent.Height;
ResizeMode = ResizeMode.NoResize;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
Topmost = true;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#FAFAFA");
AppIcon.Apply(this);
var root = new StackPanel { Margin = new Thickness(20, 18, 20, 16) };
root.Children.Add(new TextBlock
{
Text = control ? "원격 화면 보기 + 키보드·마우스 제어 요청" : "원격 화면 보기 요청",
FontSize = 15,
FontWeight = FontWeights.Bold,
Foreground = Brushes2.Make(control ? "#B71C1C" : "#155E75"),
Margin = new Thickness(0, 0, 0, 10)
});
root.Children.Add(new TextBlock
{
Text = $"관리 PC [{who}] 가 이 PC 화면을 {(control ? "보고 조작" : "보")}려고 합니다.\n허용할까요?",
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 0, 0, 10)
});
_countdown = new TextBlock { Foreground = Brushes.Gray };
root.Children.Add(_countdown);
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
var allow = new Button { Content = "허용", MinWidth = 90, FontWeight = FontWeights.Bold };
var deny = new Button { Content = "거부", MinWidth = 90, IsCancel = true };
allow.Click += (s, e) => Finish(true);
deny.Click += (s, e) => Finish(false);
buttons.Children.Add(allow);
buttons.Children.Add(deny);
root.Children.Add(buttons);
Content = root;
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer.Tick += (s, e) =>
{
_left--;
UpdateCountdown();
if (_left <= 0) Finish(false);
};
UpdateCountdown();
Closed += (s, e) => { _timer.Stop(); _result.TrySetResult(false); };
Loaded += (s, e) => { _timer.Start(); Activate(); };
}
private void UpdateCountdown() => _countdown.Text = $"{_left}초 뒤 자동으로 거부됩니다.";
private void Finish(bool ok)
{
_result.TrySetResult(ok);
Close();
}
/// <summary>UI 스레드에서 호출</summary>
public static Task<bool> AskAsync(string who, bool control)
{
var w = new ApprovalWindow(who, control);
w.Show();
return w._result.Task;
}
}
/// <summary>대상 PC: 원격 화면 연결 중 화면 위쪽 표시줄 (항상 표시)</summary>
public sealed class RemoteIndicatorWindow : Window
{
private readonly TextBlock _text;
private readonly Border _bar;
public event Action DisconnectRequested;
public RemoteIndicatorWindow()
{
WindowStyle = WindowStyle.None;
ResizeMode = ResizeMode.NoResize;
AllowsTransparency = true;
Background = Brushes.Transparent;
Topmost = true;
ShowInTaskbar = false;
ShowActivated = false;
SizeToContent = SizeToContent.WidthAndHeight;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
_bar = new Border
{
CornerRadius = new CornerRadius(0, 0, 8, 8),
Padding = new Thickness(14, 6, 8, 6),
Background = Brushes2.Make("#E65100")
};
var panel = new StackPanel { Orientation = Orientation.Horizontal };
_text = new TextBlock { Foreground = Brushes.White, FontWeight = FontWeights.Bold, VerticalAlignment = VerticalAlignment.Center };
var stop = new Button
{
Content = "연결 끊기",
Margin = new Thickness(12, 0, 0, 0),
MinWidth = 0,
MinHeight = 0,
Padding = new Thickness(8, 2, 8, 2),
Background = Brushes.White
};
stop.Click += (s, e) => DisconnectRequested?.Invoke();
panel.Children.Add(_text);
panel.Children.Add(stop);
_bar.Child = panel;
Content = _bar;
SizeChanged += (s, e) => Reposition();
}
public void Update(IReadOnlyList<ScreenViewer> viewers)
{
if (viewers.Count == 0)
{
Hide();
return;
}
bool control = viewers.Any(v => v.Control);
_bar.Background = Brushes2.Make(control ? "#C62828" : "#E65100");
_text.Text = $"● 원격 {(control ? "제어" : "보기")} 중: {string.Join(", ", viewers.Select(v => v.Who).Distinct())}";
if (!IsVisible) Show();
Reposition();
}
private void Reposition()
{
var wa = SystemParameters.WorkArea;
Left = wa.Left + (wa.Width - ActualWidth) / 2;
Top = wa.Top;
}
}
/// <summary>관리 PC: 원격 화면 창 (보기 / 제어)</summary>
public sealed class RemoteViewerWindow : Window
{
private readonly RemoteClient _client;
private readonly string _label;
private readonly Image _image;
private readonly TextBlock _status, _overlay;
private readonly StackPanel _monitorButtons;
private readonly HashSet<int> _pressedKeys = new HashSet<int>();
private int _sid;
private bool _control;
private bool _ended;
private int _monitor = 1;
private int _frames;
private DateTime _fpsStart = DateTime.UtcNow;
private int _decoding;
private DateTime _lastMove = DateTime.MinValue;
public string ViewerKey { get; }
public RemoteViewerWindow(string key, RemoteClient client, string label)
{
ViewerKey = key;
_client = client;
_label = label;
Title = $"원격관리 - {label}" + AppInstance.TitleTag;
Width = 1280;
Height = 800;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#263238");
AppIcon.Apply(this);
// 한글 입력기는 대상 PC에서 처리하도록 이 창에서는 끔
InputMethod.SetIsInputMethodEnabled(this, false);
var root = new DockPanel();
// ── 도구 모음
var bar = new DockPanel { Background = Brushes2.Make("#ECEFF1") };
DockPanel.SetDock(bar, Dock.Top);
var close = new Button { Content = "연결 끊기" };
close.Click += (s, e) => Close();
DockPanel.SetDock(close, Dock.Right);
bar.Children.Add(close);
_status = new TextBlock { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 12, 0), Foreground = Brushes2.Make("#37474F") };
DockPanel.SetDock(_status, Dock.Right);
bar.Children.Add(_status);
_monitorButtons = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4) };
bar.Children.Add(_monitorButtons);
root.Children.Add(bar);
// ── 화면
var stage = new Grid { Background = Brushes.Black, ClipToBounds = true };
_image = new Image
{
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Focusable = true,
Cursor = Cursors.Arrow
};
RenderOptions.SetBitmapScalingMode(_image, BitmapScalingMode.HighQuality);
_overlay = new TextBlock
{
Text = "연결 중…",
Foreground = Brushes.White,
FontSize = 16,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
stage.Children.Add(_image);
stage.Children.Add(_overlay);
root.Children.Add(stage);
Content = root;
_image.MouseMove += OnMouseMove;
_image.MouseDown += OnMouseDown;
_image.MouseUp += OnMouseUp;
_image.MouseWheel += OnMouseWheel;
PreviewKeyDown += OnKey;
PreviewKeyUp += OnKey;
Deactivated += (s, e) => ReleaseKeys();
_client.ScreenEvent += OnScreenEvent;
Loaded += async (s, e) => await StartAsync();
Closed += (s, e) => Stop();
}
// ───────────────────────── 시작 / 끝 ─────────────────────────
private async Task StartAsync()
{
try
{
_overlay.Text = "대상 PC 정보를 확인하는 중…";
var info = await _client.RequestAsync("screenInfo", null, TimeSpan.FromSeconds(15));
if (!Json.Bool(info, "allowView"))
{
End("대상 PC에서 화면 보기를 허용하지 않았습니다.\n(대상 PC 설정 → 원격 접속 허용 → 화면 보기 허용)");
return;
}
var monitors = (info["monitors"] as JsonArray)?.OfType<JsonObject>().ToList() ?? new List<JsonObject>();
var primary = monitors.FirstOrDefault(m => Json.Bool(m, "primary"));
_monitor = primary != null ? Json.Int(primary, "index", 1) : 1;
foreach (var m in monitors)
{
int idx = Json.Int(m, "index", 1);
AddMonitorButton($"모니터 {idx}" + (Json.Bool(m, "primary") ? " (주)" : ""), idx,
$"{Json.Int(m, "width")} × {Json.Int(m, "height")}");
}
if (monitors.Count > 1) AddMonitorButton("전체", 0, "모든 모니터를 한 화면에");
HighlightMonitor();
_overlay.Text = "대상 PC의 승인을 기다리는 중…";
var r = await _client.RequestAsync("screenStart", new JsonObject { ["monitor"] = _monitor }, TimeSpan.FromSeconds(45));
_sid = Json.Int(r, "sid");
_control = Json.Bool(r, "control");
_overlay.Text = "";
UpdateStatus();
_image.Cursor = _control ? Cursors.Cross : Cursors.Arrow;
_image.Focus();
}
catch (Exception ex)
{
End(RemoteClient.Friendly(ex));
}
}
private void AddMonitorButton(string text, int index, string tip)
{
var b = new Button { Content = text, Tag = index, ToolTip = tip, MinWidth = 70 };
b.Click += async (s, e) =>
{
_monitor = index;
HighlightMonitor();
try { await _client.RequestAsync("screenMonitor", new JsonObject { ["monitor"] = index }, TimeSpan.FromSeconds(10)); }
catch { }
_image.Focus();
};
_monitorButtons.Children.Add(b);
}
private void HighlightMonitor()
{
foreach (Button b in _monitorButtons.Children)
{
bool on = (int)b.Tag == _monitor;
b.FontWeight = on ? FontWeights.Bold : FontWeights.Normal;
b.Background = on ? Brushes2.Make("#BBDEFB") : Brushes.White;
}
}
private void End(string reason)
{
_ended = true;
_overlay.Text = reason;
_status.Text = "연결 끝";
_image.Cursor = Cursors.Arrow;
}
private void Stop()
{
_client.ScreenEvent -= OnScreenEvent;
ReleaseKeys();
if (_sid > 0 && !_ended)
_ = _client.RequestAsync("screenStop", null, TimeSpan.FromSeconds(5)).ContinueWith(t => { _ = t.Exception; });
}
private void UpdateStatus()
{
var secs = (DateTime.UtcNow - _fpsStart).TotalSeconds;
double fps = secs > 0 ? _frames / secs : 0;
if (secs > 3) { _frames = 0; _fpsStart = DateTime.UtcNow; }
_status.Text = $"{(_control ? "● 제어 중" : "보기 전용")} · {fps:0.0}장/초";
_status.Foreground = _control ? Brushes2.Stopped : Brushes2.Make("#37474F");
}
// ───────────────────────── 화면 받기 ─────────────────────────
private void OnScreenEvent(JsonObject msg)
{
var ev = Json.Str(msg, "event");
int sid = Json.Int(msg, "sid");
if (ev == "screenEnd")
{
if (sid == 0 || sid == _sid)
{
var reason = Json.Str(msg, "reason");
Dispatcher.BeginInvoke(new Action(() => End("원격 화면 연결이 끝났습니다.\n" + reason)));
}
return;
}
if (ev != "frame" || sid != _sid || _ended) return;
// 화면 그리기가 밀리면 이전 장면은 건너뜀
if (Interlocked.Exchange(ref _decoding, 1) == 1) return;
try
{
var bytes = Convert.FromBase64String(Json.Str(msg, "data"));
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.StreamSource = new MemoryStream(bytes);
bmp.EndInit();
bmp.Freeze();
Dispatcher.BeginInvoke(new Action(() =>
{
_image.Source = bmp;
_frames++;
UpdateStatus();
Interlocked.Exchange(ref _decoding, 0);
}), DispatcherPriority.Render);
}
catch
{
Interlocked.Exchange(ref _decoding, 0);
}
}
// ───────────────────────── 입력 보내기 ─────────────────────────
private bool CanControl => _control && !_ended && _sid > 0;
private (double x, double y) Norm(MouseEventArgs e)
{
var p = e.GetPosition(_image);
double w = Math.Max(1, _image.ActualWidth), h = Math.Max(1, _image.ActualHeight);
return (Math.Clamp(p.X / w, 0, 1), Math.Clamp(p.Y / h, 0, 1));
}
private void SendMouse(MouseEventArgs e, string action, string button = "", int delta = 0)
{
var (x, y) = Norm(e);
_client.SendNoReply(new JsonObject
{
["op"] = "input",
["kind"] = "mouse",
["action"] = action,
["button"] = button,
["x"] = Math.Round(x, 5),
["y"] = Math.Round(y, 5),
["delta"] = delta
});
}
private static string ButtonName(MouseButton b) => b switch
{
MouseButton.Right => "right",
MouseButton.Middle => "middle",
_ => "left"
};
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (!CanControl) return;
// 너무 자주 보내지 않도록 (약 30회/초)
if ((DateTime.UtcNow - _lastMove).TotalMilliseconds < 33) return;
_lastMove = DateTime.UtcNow;
SendMouse(e, "move");
}
private void OnMouseDown(object sender, MouseButtonEventArgs e)
{
_image.Focus();
if (!CanControl) return;
_image.CaptureMouse();
SendMouse(e, "down", ButtonName(e.ChangedButton));
e.Handled = true;
}
private void OnMouseUp(object sender, MouseButtonEventArgs e)
{
if (!CanControl) return;
SendMouse(e, "up", ButtonName(e.ChangedButton));
if (Mouse.LeftButton == MouseButtonState.Released && Mouse.RightButton == MouseButtonState.Released && Mouse.MiddleButton == MouseButtonState.Released)
_image.ReleaseMouseCapture();
e.Handled = true;
}
private void OnMouseWheel(object sender, MouseWheelEventArgs e)
{
if (!CanControl) return;
SendMouse(e, "wheel", "", e.Delta);
e.Handled = true;
}
private void OnKey(object sender, KeyEventArgs e)
{
if (!CanControl) return;
var key = e.Key switch
{
Key.System => e.SystemKey,
Key.ImeProcessed => e.ImeProcessedKey,
Key.DeadCharProcessed => e.DeadCharProcessedKey,
_ => e.Key
};
int vk = KeyInterop.VirtualKeyFromKey(key);
if (vk <= 0) return;
bool down = e.RoutedEvent == Keyboard.PreviewKeyDownEvent;
if (down) _pressedKeys.Add(vk); else _pressedKeys.Remove(vk);
_client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = down });
e.Handled = true;
}
/// <summary>창을 벗어나면 눌린 키가 대상 PC에 남지 않도록 모두 뗌</summary>
private void ReleaseKeys()
{
if (_pressedKeys.Count == 0) return;
foreach (var vk in _pressedKeys.ToList())
_client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = false });
_pressedKeys.Clear();
}
}
}

View File

@ -1,9 +1,73 @@
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; }
/// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary>
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
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 +77,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 +127,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 +148,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;
}
}
}

View File

@ -1,28 +1,82 @@
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, _activeListen, _allowView, _allowControl, _requireApproval;
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;
// Passive 접속 (대상 PC 쪽)
private readonly ListBox _pmList;
private readonly TextBox _pmAddr, _pmCode, _pmPort;
private readonly ComboBox _pmMode;
private readonly TextBlock _pmResult;
private readonly ObservableCollection<PassiveManager> _pms = new ObservableCollection<PassiveManager>();
// 관리 대상 PC
private readonly CheckBox _manage;
private readonly ComboBox _passiveMode;
private readonly TextBox _passivePort;
private int _passiveSshPort, _passiveTcpPort;
private int _shownPassiveMode = -1;
private readonly ListBox _targets;
private readonly TextBox _addr, _hostname, _port, _code;
private readonly ComboBox _mode, _direction;
private readonly PasswordBox _pwdTarget;
private readonly TextBlock _pwTargetState, _test, _lblAddr;
private readonly FrameworkElement _lblMode, _lblPort, _lblCode, _pnlCode;
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.Direction == ConnectDirection.Passive
? "Passive · 코드 " + T.AccessCode
: "Active · " + (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 = 800;
Height = 720;
MinHeight = 560;
ResizeMode = ResizeMode.CanResize;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#FAFAFA");
@ -33,21 +87,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 +117,113 @@ namespace JJServiceManager
_hotkeyBox.LostKeyboardFocus += (s, e) => { _hotkeyBox.Background = Brushes2.Make("#FAFAFA"); ShowHotkey(); };
_hotkeyBox.PreviewKeyDown += OnHotkeyKeyDown;
// ───────── 원격 접속 허용 ─────────
_server = root.Find<CheckBox>("chkServer");
_activeListen = root.Find<CheckBox>("chkActiveListen");
_allowView = root.Find<CheckBox>("chkAllowView");
_allowControl = root.Find<CheckBox>("chkAllowControl");
_requireApproval = root.Find<CheckBox>("chkRequireApproval");
_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;
_activeListen.IsChecked = current.RemoteServerActiveListen;
_allowView.IsChecked = current.RemoteAllowView;
_allowControl.IsChecked = current.RemoteAllowControl;
_requireApproval.IsChecked = current.RemoteRequireApproval;
_serverMode.SelectionChanged += (s, e) => OnServerModeChanged();
_serverMode.SelectedIndex = (int)current.RemoteServerMode;
_pwState.Text = string.IsNullOrEmpty(current.RemotePasswordHash)
? "비밀번호가 설정되어 있지 않습니다. 원격 접속을 허용하려면 입력하세요."
: "비밀번호가 설정되어 있습니다. 바꿀 때만 입력하세요.";
// 제어 허용은 화면 보기가 켜져 있어야 함
_allowView.Checked += (s, e) => _allowControl.IsEnabled = true;
_allowView.Unchecked += (s, e) => { _allowControl.IsChecked = false; _allowControl.IsEnabled = false; };
_allowControl.IsEnabled = _allowView.IsChecked == true;
UpdateServerInfo();
// Passive 접속 (이 PC → 관리 PC)
_pmList = root.Find<ListBox>("lstPassive");
_pmAddr = root.Find<TextBox>("txtPmAddr");
_pmCode = root.Find<TextBox>("txtPmCode");
_pmPort = root.Find<TextBox>("txtPmPort");
_pmMode = root.Find<ComboBox>("cmbPmMode");
_pmResult = root.Find<TextBlock>("txtPmResult");
foreach (var m in current.PassiveManagers) _pms.Add(m.Clone());
_pmList.ItemsSource = _pms;
_pmList.SelectionChanged += (s, e) => ShowPm(_pmList.SelectedItem as PassiveManager);
_pmMode.SelectionChanged += (s, e) =>
{
if (_pmPort.Text == "" || _pmPort.Text == "9990" || _pmPort.Text == "2224")
_pmPort.Text = _pmMode.SelectedIndex == 0 ? "2224" : "9990";
};
ClearPmForm();
root.Find<Button>("btnPmNew").Click += (s, e) => { _pmList.SelectedItem = null; ClearPmForm(); };
root.Find<Button>("btnPmAdd").Click += (s, e) => AddPm();
root.Find<Button>("btnPmUpdate").Click += (s, e) => UpdatePm();
root.Find<Button>("btnPmDelete").Click += (s, e) => DeletePm();
// ───────── 관리 대상 PC ─────────
_manage = root.Find<CheckBox>("chkManage");
_passiveMode = root.Find<ComboBox>("cmbPassiveMode");
_passivePort = root.Find<TextBox>("txtPassivePort");
_targets = root.Find<ListBox>("lstTargets");
_addr = root.Find<TextBox>("txtAddr");
_hostname = root.Find<TextBox>("txtHostname");
_port = root.Find<TextBox>("txtPort");
_code = root.Find<TextBox>("txtCode");
_mode = root.Find<ComboBox>("cmbMode");
_direction = root.Find<ComboBox>("cmbDirection");
_pwdTarget = root.Find<PasswordBox>("pwdTarget");
_pwTargetState = root.Find<TextBlock>("txtPwTarget");
_test = root.Find<TextBlock>("txtTest");
_lblAddr = root.Find<TextBlock>("lblAddr");
_lblMode = root.Find<FrameworkElement>("lblMode");
_lblPort = root.Find<FrameworkElement>("lblPort");
_lblCode = root.Find<FrameworkElement>("lblCode");
_pnlCode = root.Find<FrameworkElement>("pnlCode");
_btnTest = root.Find<Button>("btnTest");
_manage.IsChecked = current.RemoteManageEnabled;
_passiveSshPort = current.PassiveListenSshPort;
_passiveTcpPort = current.PassiveListenTcpPort;
_passiveMode.SelectionChanged += (s, e) =>
{
if (_shownPassiveMode == 0 && int.TryParse(_passivePort.Text, out var a)) _passiveSshPort = a;
if (_shownPassiveMode == 1 && int.TryParse(_passivePort.Text, out var b)) _passiveTcpPort = b;
_shownPassiveMode = _passiveMode.SelectedIndex;
_passivePort.Text = (_shownPassiveMode == 0 ? _passiveSshPort : _passiveTcpPort).ToString();
};
_passiveMode.SelectedIndex = (int)current.PassiveListenMode;
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";
};
_direction.SelectionChanged += (s, e) => OnDirectionChanged();
root.Find<Button>("btnNewCode").Click += (s, e) => _code.Text = RemoteTarget.NewAccessCode();
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 +232,8 @@ namespace JJServiceManager
};
}
// ───────────────────────── 단축키 ─────────────────────────
private void OnHotkeyKeyDown(object sender, KeyEventArgs e)
{
e.Handled = true;
@ -102,17 +269,346 @@ 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" : "기본 9988";
UpdateServerInfo();
}
private void UpdateServerInfo()
{
var ips = NetInfo.LocalIPv4();
_serverInfo.Text =
$"이 PC: {Environment.MachineName} IP: {(ips.Count == 0 ? "(확인 안 됨)" : string.Join(", ", ips))}\n" +
"Active: 관리 PC의 [관리 대상 PC]에 위 IP, 같은 통신 방식과 포트, 접속 비밀번호를 입력합니다.\n" +
"Passive: 관리 PC에서 받은 접속 코드를 위 [Passive 접속]에 입력하고, 관리 PC에는 접속 비밀번호를 입력합니다.\n" +
"Active 수신을 켜면 Windows 방화벽에 해당 TCP 포트를 자동으로 열고, 끄면 규칙을 지웁니다.\n" +
"원격에서는 이 PC에 등록된 서비스만 보고 Start / Stop 할 수 있습니다.";
}
private void ClearPmForm()
{
_pmAddr.Text = "";
_pmCode.Text = "";
_pmMode.SelectedIndex = 1;
_pmPort.Text = "9990";
_pmResult.Text = "";
}
private void ShowPm(PassiveManager m)
{
if (m == null) return;
_pmAddr.Text = m.Address;
_pmCode.Text = m.AccessCode;
_pmMode.SelectedIndex = (int)m.Mode;
_pmPort.Text = m.Port.ToString();
_pmResult.Text = "";
}
private string ReadPm(out PassiveManager m)
{
m = new PassiveManager
{
Address = _pmAddr.Text.Trim(),
AccessCode = _pmCode.Text.Trim().ToUpperInvariant(),
Mode = (RemoteMode)Math.Max(0, _pmMode.SelectedIndex)
};
if (m.Address.Length == 0) return "관리 PC 주소를 입력하세요.";
if (m.AccessCode.Length < 4) return "관리 PC에서 받은 접속 코드를 입력하세요.";
if (!int.TryParse(_pmPort.Text.Trim(), out var p) || p < 1 || p > 65535) return "포트는 1~65535 사이 숫자로 입력하세요.";
m.Port = p;
return null;
}
private void AddPm()
{
var err = ReadPm(out var m);
if (err != null) { ShowResult(_pmResult, err, false); return; }
_pms.Add(m);
_pmList.SelectedItem = m;
ShowResult(_pmResult, "추가했습니다. [확인]을 누르면 저장됩니다.", true);
}
private void UpdatePm()
{
if (_pmList.SelectedItem is not PassiveManager sel) { ShowResult(_pmResult, "수정할 항목을 목록에서 고르세요.", false); return; }
var err = ReadPm(out var m);
if (err != null) { ShowResult(_pmResult, err, false); return; }
int i = _pms.IndexOf(sel);
m.Id = sel.Id;
_pms[i] = m;
_pmList.SelectedItem = m;
ShowResult(_pmResult, "수정했습니다. [확인]을 누르면 저장됩니다.", true);
}
private void DeletePm()
{
if (_pmList.SelectedItem is not PassiveManager sel) { ShowResult(_pmResult, "삭제할 항목을 목록에서 고르세요.", false); return; }
_pms.Remove(sel);
ClearPmForm();
ShowResult(_pmResult, "삭제했습니다. [확인]을 누르면 저장됩니다.", true);
}
// ───────────────────────── 관리 대상 PC ─────────────────────────
private bool FormIsPassive => _direction.SelectedIndex == 1;
private void OnDirectionChanged()
{
bool passive = FormIsPassive;
// Active: IP·방식·포트 입력 / Passive: 접속 코드
_lblAddr.Text = passive ? "대상 IP (자동)" : "대상 IP";
_addr.IsEnabled = !passive;
_mode.IsEnabled = !passive;
_port.IsEnabled = !passive;
_lblMode.Opacity = _lblPort.Opacity = passive ? 0.4 : 1;
_lblCode.Visibility = _pnlCode.Visibility = passive ? Visibility.Visible : Visibility.Collapsed;
_btnTest.IsEnabled = !passive;
if (passive && string.IsNullOrEmpty(_code.Text)) _code.Text = RemoteTarget.NewAccessCode();
}
private void ClearForm()
{
_direction.SelectedIndex = 0;
_addr.Text = "";
_hostname.Text = "";
_code.Text = "";
_mode.SelectedIndex = 1;
_port.Text = "9988";
_pwdTarget.Password = "";
_pwTargetState.Text = "대상 PC의 접속 비밀번호";
_test.Text = "";
OnDirectionChanged();
}
private void ShowTarget(TargetItem item)
{
if (item == null) return;
var t = item.T;
_direction.SelectedIndex = (int)t.Direction;
_addr.Text = t.Direction == ConnectDirection.Passive ? t.LastAddress : t.Address;
_hostname.Text = t.Hostname;
_code.Text = t.AccessCode;
_mode.SelectedIndex = (int)t.Mode;
_port.Text = t.Port.ToString();
_pwdTarget.Password = "";
_pwTargetState.Text = string.IsNullOrEmpty(t.ProtectedPassword) ? "비밀번호 없음 - 입력하세요" : "저장됨 (바꿀 때만 입력)";
_test.Text = "";
OnDirectionChanged();
}
/// <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 (FormIsPassive)
{
if (_code.Text.Trim().Length < 4) return "[새 코드]를 눌러 접속 코드를 만드세요.";
int.TryParse(_port.Text.Trim(), out port);
return null;
}
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 && !FormIsPassive && _items.Any(i => i.T.Direction == ConnectDirection.Active && string.Equals(i.T.Address, addr, StringComparison.OrdinalIgnoreCase) && i.T.Port == port))
err = "같은 IP와 포트가 이미 등록되어 있습니다.";
if (err == null && FormIsPassive && _items.Any(i => string.Equals(i.T.AccessCode, _code.Text.Trim(), StringComparison.OrdinalIgnoreCase)))
err = "같은 접속 코드가 이미 있습니다. [새 코드]를 누르세요.";
if (err != null) { ShowTest(err, false); return; }
var t = new RemoteTarget
{
Direction = FormIsPassive ? ConnectDirection.Passive : ConnectDirection.Active,
AccessCode = FormIsPassive ? _code.Text.Trim() : "",
Address = FormIsPassive ? "" : addr,
Mode = mode,
Port = port > 0 ? port : 9988,
Hostname = _hostname.Text.Trim(),
ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password)
};
var item = new TargetItem(t);
_items.Add(item);
_targets.SelectedItem = item;
ShowTest(FormIsPassive
? $"추가했습니다. 대상 PC의 [원격 접속 허용 → Passive 접속]에 이 PC 주소와 코드 {t.AccessCode}를 입력하세요."
: "추가했습니다. [확인]을 누르면 저장됩니다.", 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;
var newDir = FormIsPassive ? ConnectDirection.Passive : ConnectDirection.Active;
if (newDir != t.Direction || (newDir == ConnectDirection.Active && !string.Equals(t.Address, addr, StringComparison.OrdinalIgnoreCase)))
t.Hostname = ""; // 방향이나 IP가 바뀌면 hostname 다시 확인
t.Direction = newDir;
if (newDir == ConnectDirection.Passive)
{
t.AccessCode = _code.Text.Trim();
}
else
{
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 = !FormIsPassive;
}
}
private void ShowTest(string msg, bool ok) => ShowResult(_test, msg, ok);
private static void ShowResult(TextBlock tb, string msg, bool ok)
{
tb.Text = msg;
tb.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
}
// ───────────────────────── 저장 ─────────────────────────
private void Save()
{
// 원격 접속 허용 검사
if (!int.TryParse(_serverPort.Text.Trim(), out var sp) || sp < 1 || sp > 65535)
{
Fail(1, "Active 수신 포트는 1~65535 사이 숫자로 입력하세요.");
return;
}
if (_serverMode.SelectedIndex == 0) _sshPort = sp; else _tcpPort = sp;
if (!int.TryParse(_passivePort.Text.Trim(), out var pp) || pp < 1 || pp > 65535)
{
Fail(2, "Passive 수신 포트는 1~65535 사이 숫자로 입력하세요.");
return;
}
if (_passiveMode.SelectedIndex == 0) _passiveSshPort = pp; else _passiveTcpPort = pp;
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;
}
if (_server.IsChecked == true && _activeListen.IsChecked != true && _pms.Count == 0)
{
Fail(1, "원격 접속 허용을 켜려면 Active 수신을 켜거나 Passive 접속을 하나 이상 추가하세요.");
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.RemoteServerActiveListen = _activeListen.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;
}
_s.PassiveManagers = _pms.Select(m => m.Clone()).ToList();
_s.RemoteAllowView = _allowView.IsChecked == true;
_s.RemoteAllowControl = _allowView.IsChecked == true && _allowControl.IsChecked == true;
_s.RemoteRequireApproval = _requireApproval.IsChecked == true;
// 관리 대상 PC
_s.RemoteManageEnabled = _manage.IsChecked == true;
_s.PassiveListenMode = (RemoteMode)Math.Max(0, _passiveMode.SelectedIndex);
_s.PassiveListenSshPort = _passiveSshPort;
_s.PassiveListenTcpPort = _passiveTcpPort;
_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, "설정");
}
}
}

23
app.test.manifest Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="JJServiceManager.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- [테스트 빌드 전용] 화면 자동 점검용: 관리자 권한 없이 실행 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>