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 } /// /// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴. /// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속) /// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌) /// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음. /// 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 _password; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>(); private readonly ConcurrentDictionary _logs = new ConcurrentDictionary(); private readonly object _incomingLock = new object(); private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming; private JsonLineConnection _conn; private int _nextId; private readonly RelayManager _relay; private TaskCompletionSource _wake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); private string _relaySession = ""; private sealed class LogSubscriber { public Action, LogLevel> OnLines; public Action OnFile; } public RemoteTarget Target => _target; public bool IsPassive => _target.Direction == ConnectDirection.Passive; public bool IsRelay => _target.Direction == ConnectDirection.Relay; /// Relay: 실제 연결 방식 ("direct" / "relay", 연결 전에는 빈 값) public string RelayMode { get; private set; } = ""; /// Relay: 연결 방식이 정해짐 (백그라운드 스레드) public event Action RelayModeChanged; public RemoteState State { get; private set; } = RemoteState.Connecting; public string StateMessage { get; private set; } = "연결 중…"; public string Hostname { get; private set; } = ""; public List Services { get; private set; } = new List(); /// 대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신) public bool AllowView { get; private set; } public bool AllowControl { get; private set; } /// 연결 상태가 바뀜 (백그라운드 스레드에서 발생) public event Action StateChanged; /// 서비스 목록을 새로 받음 (백그라운드 스레드에서 발생) public event Action ServicesChanged; /// 대상 PC의 hostname을 처음 알게 되었거나 바뀜 public event Action HostnameDiscovered; /// Passive: 대상 PC가 접속해 온 주소 public event Action PeerAddressDiscovered; /// 원격 화면 이벤트 (frame / screenEnd) public event Action ScreenEvent; public RemoteClient(RemoteTarget target, Func passwordProvider = null, RelayManager relay = null) { _relay = relay; _target = target.Clone(); Hostname = target.Hostname ?? ""; var prot = _target.ProtectedPassword; _password = passwordProvider ?? (() => RemoteSecurity.Unprotect(prot)); } public void Start() => _ = Task.Run(RunAsync); /// 다시 시도 대기 중이면 바로 다시 시도 (대상 PC가 온라인이 됨 등) public void Nudge() => _wake.TrySetResult(true); public void Dispose() { try { _cts.Cancel(); } catch { } _conn?.Dispose(); lock (_incomingLock) _incoming?.TrySetCanceled(); } /// Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true 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 if (IsRelay) { (conn, hello) = await ConnectRelayAsync(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); // 중계: 비밀번호를 바꿀 때까지(Nudge) 다시 시도하지 않음 if (IsRelay) retry = TimeSpan.FromMinutes(10); } catch (Exception ex) { SetState(RemoteState.Disconnected, Friendly(ex)); if (IsPassive) retry = TimeSpan.FromSeconds(1); if (IsRelay) retry = TimeSpan.FromSeconds(15); } finally { var c = _conn; _conn = null; // 직접 연결이었으면 끝 정보를 중계 서버에 보고 (중계는 서버가 직접 셈) if (IsRelay && RelayMode == "direct" && _relaySession.Length > 0 && c != null) _relay?.ReportStatus(_relaySession, "ended", c.BytesSent, c.BytesReceived, ct.IsCancellationRequested ? "관리 PC에서 닫음" : "연결 끊김"); _relaySession = ""; c?.Dispose(); FailPending("연결이 끊겼습니다."); _logs.Clear(); ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." }); } try { var wake = _wake.Task; await Task.WhenAny(Task.Delay(retry, ct), wake).ConfigureAwait(false); if (wake.IsCompleted) _wake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ct.ThrowIfCancellationRequested(); } catch { break; } } } // ───────────────────────── 중계 서버를 거친 연결 ───────────────────────── /// 중계 서버에 연결 요청 → 직접 연결 시도(최대 3초) → 안 되면 중계 통로. 둘 다 같은 TLS + hello(비밀번호 + 티켓) private async Task<(JsonLineConnection conn, JsonObject hello)> ConnectRelayAsync(CancellationToken ct) { var relay = _relay ?? throw new RemoteException("중계 서버 설정이 없습니다."); var deviceId = _target.RelayDeviceId; if (!relay.IsLoggedIn) { SetState(RemoteState.Connecting, "중계 서버 로그인 기다리는 중…"); if (!await relay.WaitReadyAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false)) throw new RemoteException("중계 서버에 로그인되어 있지 않습니다."); } if (!relay.IsDeviceOnline(deviceId)) throw new RemoteException("대상 PC가 중계 서버에 접속해 있지 않습니다 (오프라인)."); var password = _password(); if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("대상 PC 접속 비밀번호가 없습니다. 제목 줄의 [비밀번호]를 눌러 입력하세요."); SetState(RemoteState.Connecting, "중계 서버에 연결 요청 중…"); var info = await relay.ConnectAsync(deviceId).ConfigureAwait(false); _relaySession = info.SessionId; // 1) 직접 연결 (대상 PC가 Active 수신 중일 때만 후보가 있음) var started = DateTime.UtcNow; foreach (var (host, port, mode) in info.Candidates) { var left = TimeSpan.FromSeconds(3) - (DateTime.UtcNow - started); if (left <= TimeSpan.Zero) break; SetState(RemoteState.Connecting, $"직접 연결 시도 중… ({host}:{port})"); JsonLineConnection conn = null; try { using (var t = CancellationTokenSource.CreateLinkedTokenSource(ct)) { t.CancelAfter(left); conn = await RemoteDialer.DialAsync(mode, host, port, RemoteServer.SshUser, password, t.Token).ConfigureAwait(false); } var hello = await HelloAsync(conn, password, sendPassword: mode == RemoteMode.Port, ct, info.Ticket).ConfigureAwait(false); relay.ReportStatus(info.SessionId, "direct"); SetRelayMode("direct"); return (conn, hello); } catch (RemoteAuthException ex) { // 비밀번호·티켓 문제는 중계로 바꿔도 같으므로 바로 실패 relay.ReportStatus(info.SessionId, "failed", reason: ex.Message); throw; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { conn?.Dispose(); } } // 2) 중계 통로 SetState(RemoteState.Connecting, "중계 서버를 거쳐 연결 중…"); WebSocketStream stream = null; try { stream = await RelayChannel.OpenRelayAsync(relay.Url, info.SessionId, "manager", info.RelayToken, ct).ConfigureAwait(false); var ssl = await RemoteDialer.TlsClientAsync(stream, ct).ConfigureAwait(false); var conn = new JsonLineConnection(ssl); var hello = await HelloAsync(conn, password, sendPassword: true, ct, info.Ticket).ConfigureAwait(false); SetRelayMode("relay"); return (conn, hello); } catch (RemoteAuthException) { throw; } catch (Exception ex) when (!ct.IsCancellationRequested) { try { stream?.Dispose(); } catch { } throw new RemoteException("중계 연결 실패: " + RelayUrl.Friendly(ex)); } } private void SetRelayMode(string mode) { if (RelayMode == mode) return; RelayMode = mode; RelayModeChanged?.Invoke(this); } 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(); Enum.TryParse(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)); } // ───────────────────────── 요청 ───────────────────────── /// 요청을 보내고 결과(result)를 받음. 실패하면 RemoteException public async Task 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(TaskCreationOptions.RunContinuationsAsynchronously); _pending[id] = tcs; try { await conn.SendAsync(msg).ConfigureAwait(false); } catch (Exception ex) { _pending.TryRemove(id, out _); throw new RemoteException("보내기 실패: " + ex.Message); } var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); if (done != tcs.Task) { _pending.TryRemove(id, out _); throw new RemoteException("대상 PC의 응답이 없습니다."); } var reply = await tcs.Task.ConfigureAwait(false); if (!Json.Bool(reply, "ok")) throw new RemoteException(Json.Str(reply, "error"), Json.Bool(reply, "info")); return reply["result"] as JsonObject ?? new JsonObject(); } /// 응답을 기다리지 않고 보냄 (원격 화면 입력) public 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 StartServiceAsync(string service) => RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); public Task StopServiceAsync(string service) => RequestAsync("stop", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); public async Task> 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(); } /// 대상 PC의 서비스 로그를 실시간으로 받기 시작. 돌려받은 번호로 CloseLog public async Task OpenLogAsync(string service, string encoding, Action, LogLevel> onLines, Action 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; }); } // ───────────────────────── 연결 ───────────────────────── /// Active: 접속 + 인증 + hello. 성공하면 연결과 hello 결과 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); } /// 연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외 public static async Task HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct, string ticket = null) { 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; if (!string.IsNullOrEmpty(ticket)) hello["ticket"] = ticket; 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; } } /// 설정 화면의 [연결 테스트]: 성공하면 hostname public static async Task 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 ParseServices(JsonObject res) { var list = new List(); if (res?["services"] is not JsonArray arr) return list; foreach (var n in arr.OfType()) { int? st = null; try { if (n["status"] != null) st = n["status"].GetValue(); } 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 }; } } /// 인증 실패 (비밀번호 틀림 등) public sealed class RemoteAuthException : Exception { public RemoteAuthException(string message) : base(message) { } } /// /// 관리 PC: Passive 대상 PC들의 접속을 받는 곳. /// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김. /// public sealed class PassiveHub : IDisposable { private readonly ConcurrentDictionary _byCode = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly Action _log; private RemoteListener _listener; private string _key = ""; public PassiveHub(Action 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(); /// 수신 시작/중지 (방식·포트가 같으면 그대로) 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 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 = ""; } } /// 원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용) public sealed class RemoteLogSource : ILogSource { private readonly RemoteClient _client; private readonly string _service; private readonly Func _encoding; private readonly Action, LogLevel> _output; private int _stream; private bool _disposed; private bool _wasConnected; public event Action FileChanged; public string CurrentFile { get; private set; } public RemoteLogSource(RemoteClient client, string service, Func encoding, Action, 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); } } }