diff --git a/WirelessTextSyncer.Windows/Services/WebSocketServerService.cs b/WirelessTextSyncer.Windows/Services/WebSocketServerService.cs index e3b806b..900fbee 100644 --- a/WirelessTextSyncer.Windows/Services/WebSocketServerService.cs +++ b/WirelessTextSyncer.Windows/Services/WebSocketServerService.cs @@ -9,15 +9,18 @@ namespace WirelessTextSyncer.Windows.Services; public sealed class WebSocketServerService : IDisposable { + private static readonly byte[] HeartbeatPayload = "wts-heartbeat"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; + private readonly TimeSpan heartbeatInterval = TimeSpan.FromSeconds(5); private readonly SyncMessageHandler messageHandler; private readonly List clients = []; private readonly object clientsLock = new(); private WebSocketServer? server; + private System.Threading.Timer? heartbeatTimer; public WebSocketServerService(SyncMessageHandler messageHandler) { @@ -34,6 +37,7 @@ public sealed class WebSocketServerService : IDisposable { lock (clientsLock) { + clients.RemoveAll(client => !client.IsAvailable); return clients.Count > 0; } } @@ -52,11 +56,21 @@ public sealed class WebSocketServerService : IDisposable { socket.OnOpen = () => { + IWebSocketConnection[] replacedClients; lock (clientsLock) { + replacedClients = clients + .Where(client => client.ConnectionInfo.ClientIpAddress == socket.ConnectionInfo.ClientIpAddress) + .ToArray(); + clients.RemoveAll(client => !client.IsAvailable || replacedClients.Contains(client)); clients.Add(socket); } + foreach (var replacedClient in replacedClients) + { + replacedClient.Close(); + } + AppLogger.Info($"Client connected: {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}"); StatusChanged?.Invoke(this, EventArgs.Empty); }; @@ -73,14 +87,16 @@ public sealed class WebSocketServerService : IDisposable socket.OnError = exception => { AppLogger.Error("WebSocket connection error.", exception); - StatusChanged?.Invoke(this, EventArgs.Empty); + RemoveClient(socket, "connection error"); }; socket.OnMessage = HandleRawMessage; }); + heartbeatTimer = new System.Threading.Timer(_ => ProbeClients(), null, heartbeatInterval, heartbeatInterval); } public void Dispose() { + heartbeatTimer?.Dispose(); IWebSocketConnection[] currentClients; lock (clientsLock) { @@ -96,6 +112,66 @@ public sealed class WebSocketServerService : IDisposable server?.Dispose(); } + private void ProbeClients() + { + IWebSocketConnection[] currentClients; + var removedUnavailableClients = false; + lock (clientsLock) + { + var countBefore = clients.Count; + clients.RemoveAll(client => !client.IsAvailable); + removedUnavailableClients = clients.Count != countBefore; + currentClients = clients.ToArray(); + } + + if (removedUnavailableClients) + { + AppLogger.Info("Removed unavailable WebSocket clients during heartbeat."); + StatusChanged?.Invoke(this, EventArgs.Empty); + } + + foreach (var client in currentClients) + { + _ = SendHeartbeatAsync(client); + } + } + + private async Task SendHeartbeatAsync(IWebSocketConnection client) + { + try + { + if (!client.IsAvailable) + { + RemoveClient(client, "heartbeat unavailable"); + return; + } + + await client.SendPing(HeartbeatPayload); + } + catch (Exception exception) + { + AppLogger.Error("WebSocket heartbeat failed.", exception); + RemoveClient(client, "heartbeat failure"); + } + } + + private void RemoveClient(IWebSocketConnection client, string reason) + { + var removed = false; + lock (clientsLock) + { + removed = clients.Remove(client); + } + + if (!removed) + { + return; + } + + AppLogger.Info($"Removed WebSocket client after {reason}: {client.ConnectionInfo.ClientIpAddress}:{client.ConnectionInfo.ClientPort}"); + StatusChanged?.Invoke(this, EventArgs.Empty); + } + private void HandleRawMessage(string rawMessage) { try diff --git a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs index 51ed674..003ebf1 100644 --- a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs +++ b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs @@ -10,17 +10,21 @@ public sealed class TrayApplicationContext : ApplicationContext private readonly NotifyIcon notifyIcon; private readonly Icon connectedIcon; private readonly Icon waitIcon; + private readonly SynchronizationContext uiContext; private ToolStripMenuItem? clipboardPasteMenuItem; private ToolStripMenuItem? sendInputTypingMenuItem; + private bool? lastConnectionState; + private bool disposed; public TrayApplicationContext() { AppLogger.Info("Tray application starting."); + uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); keyboard = new KeyboardInjectionService(); var handler = new SyncMessageHandler(keyboard); server = new WebSocketServerService(handler); - server.StatusChanged += (_, _) => UpdateTrayStatus(); + server.StatusChanged += (_, _) => uiContext.Post(_ => UpdateTrayStatus(), null); discovery = new DiscoveryResponderService(() => server.LocalIpAddress, () => server.Port); connectedIcon = TrayIconFactory.CreateConnectedIcon(); waitIcon = TrayIconFactory.CreateWaitIcon(); @@ -53,6 +57,7 @@ public sealed class TrayApplicationContext : ApplicationContext { if (disposing) { + disposed = true; notifyIcon.Dispose(); connectedIcon.Dispose(); waitIcon.Dispose(); @@ -139,10 +144,32 @@ public sealed class TrayApplicationContext : ApplicationContext private void UpdateTrayStatus() { + if (disposed) + { + return; + } + var connected = server.HasClient; var status = connected ? "connected" : "waiting"; var text = $"WirelessTextSyncer {server.LocalIpAddress}:{server.Port} ({status})"; notifyIcon.Icon = connected ? connectedIcon : waitIcon; notifyIcon.Text = text.Length > 63 ? text[..63] : text; + + if (lastConnectionState is not null && lastConnectionState != connected) + { + ShowConnectionToast(connected); + } + + lastConnectionState = connected; + } + + private void ShowConnectionToast(bool connected) + { + notifyIcon.BalloonTipTitle = "WirelessTextSyncer"; + notifyIcon.BalloonTipText = connected + ? $"Device connected to {server.LocalIpAddress}:{server.Port}." + : "Device disconnected. Waiting for connection."; + notifyIcon.BalloonTipIcon = connected ? ToolTipIcon.Info : ToolTipIcon.Warning; + notifyIcon.ShowBalloonTip(3000); } }