Add WebSocket heartbeat, client cleanup, and connection toast notifications

- Add heartbeat ping every 5 seconds to detect stale connections
- Replace clients when same IP reconnects instead of accumulating
- Remove unavailable clients on connect and heartbeat probe
- Marshal UI updates to UI thread via SynchronizationContext
- Show balloon toast on device connect/disconnect
- Add disposal safety check for UpdateTrayStatus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-05-17 12:34:02 +08:00
parent 32ef8fb1ac
commit cbbadd547d
2 changed files with 105 additions and 2 deletions

View File

@@ -9,15 +9,18 @@ namespace WirelessTextSyncer.Windows.Services;
public sealed class WebSocketServerService : IDisposable public sealed class WebSocketServerService : IDisposable
{ {
private static readonly byte[] HeartbeatPayload = "wts-heartbeat"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly TimeSpan heartbeatInterval = TimeSpan.FromSeconds(5);
private readonly SyncMessageHandler messageHandler; private readonly SyncMessageHandler messageHandler;
private readonly List<IWebSocketConnection> clients = []; private readonly List<IWebSocketConnection> clients = [];
private readonly object clientsLock = new(); private readonly object clientsLock = new();
private WebSocketServer? server; private WebSocketServer? server;
private System.Threading.Timer? heartbeatTimer;
public WebSocketServerService(SyncMessageHandler messageHandler) public WebSocketServerService(SyncMessageHandler messageHandler)
{ {
@@ -34,6 +37,7 @@ public sealed class WebSocketServerService : IDisposable
{ {
lock (clientsLock) lock (clientsLock)
{ {
clients.RemoveAll(client => !client.IsAvailable);
return clients.Count > 0; return clients.Count > 0;
} }
} }
@@ -52,11 +56,21 @@ public sealed class WebSocketServerService : IDisposable
{ {
socket.OnOpen = () => socket.OnOpen = () =>
{ {
IWebSocketConnection[] replacedClients;
lock (clientsLock) lock (clientsLock)
{ {
replacedClients = clients
.Where(client => client.ConnectionInfo.ClientIpAddress == socket.ConnectionInfo.ClientIpAddress)
.ToArray();
clients.RemoveAll(client => !client.IsAvailable || replacedClients.Contains(client));
clients.Add(socket); clients.Add(socket);
} }
foreach (var replacedClient in replacedClients)
{
replacedClient.Close();
}
AppLogger.Info($"Client connected: {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}"); AppLogger.Info($"Client connected: {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}");
StatusChanged?.Invoke(this, EventArgs.Empty); StatusChanged?.Invoke(this, EventArgs.Empty);
}; };
@@ -73,14 +87,16 @@ public sealed class WebSocketServerService : IDisposable
socket.OnError = exception => socket.OnError = exception =>
{ {
AppLogger.Error("WebSocket connection error.", exception); AppLogger.Error("WebSocket connection error.", exception);
StatusChanged?.Invoke(this, EventArgs.Empty); RemoveClient(socket, "connection error");
}; };
socket.OnMessage = HandleRawMessage; socket.OnMessage = HandleRawMessage;
}); });
heartbeatTimer = new System.Threading.Timer(_ => ProbeClients(), null, heartbeatInterval, heartbeatInterval);
} }
public void Dispose() public void Dispose()
{ {
heartbeatTimer?.Dispose();
IWebSocketConnection[] currentClients; IWebSocketConnection[] currentClients;
lock (clientsLock) lock (clientsLock)
{ {
@@ -96,6 +112,66 @@ public sealed class WebSocketServerService : IDisposable
server?.Dispose(); 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) private void HandleRawMessage(string rawMessage)
{ {
try try

View File

@@ -10,17 +10,21 @@ public sealed class TrayApplicationContext : ApplicationContext
private readonly NotifyIcon notifyIcon; private readonly NotifyIcon notifyIcon;
private readonly Icon connectedIcon; private readonly Icon connectedIcon;
private readonly Icon waitIcon; private readonly Icon waitIcon;
private readonly SynchronizationContext uiContext;
private ToolStripMenuItem? clipboardPasteMenuItem; private ToolStripMenuItem? clipboardPasteMenuItem;
private ToolStripMenuItem? sendInputTypingMenuItem; private ToolStripMenuItem? sendInputTypingMenuItem;
private bool? lastConnectionState;
private bool disposed;
public TrayApplicationContext() public TrayApplicationContext()
{ {
AppLogger.Info("Tray application starting."); AppLogger.Info("Tray application starting.");
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
keyboard = new KeyboardInjectionService(); keyboard = new KeyboardInjectionService();
var handler = new SyncMessageHandler(keyboard); var handler = new SyncMessageHandler(keyboard);
server = new WebSocketServerService(handler); server = new WebSocketServerService(handler);
server.StatusChanged += (_, _) => UpdateTrayStatus(); server.StatusChanged += (_, _) => uiContext.Post(_ => UpdateTrayStatus(), null);
discovery = new DiscoveryResponderService(() => server.LocalIpAddress, () => server.Port); discovery = new DiscoveryResponderService(() => server.LocalIpAddress, () => server.Port);
connectedIcon = TrayIconFactory.CreateConnectedIcon(); connectedIcon = TrayIconFactory.CreateConnectedIcon();
waitIcon = TrayIconFactory.CreateWaitIcon(); waitIcon = TrayIconFactory.CreateWaitIcon();
@@ -53,6 +57,7 @@ public sealed class TrayApplicationContext : ApplicationContext
{ {
if (disposing) if (disposing)
{ {
disposed = true;
notifyIcon.Dispose(); notifyIcon.Dispose();
connectedIcon.Dispose(); connectedIcon.Dispose();
waitIcon.Dispose(); waitIcon.Dispose();
@@ -139,10 +144,32 @@ public sealed class TrayApplicationContext : ApplicationContext
private void UpdateTrayStatus() private void UpdateTrayStatus()
{ {
if (disposed)
{
return;
}
var connected = server.HasClient; var connected = server.HasClient;
var status = connected ? "connected" : "waiting"; var status = connected ? "connected" : "waiting";
var text = $"WirelessTextSyncer {server.LocalIpAddress}:{server.Port} ({status})"; var text = $"WirelessTextSyncer {server.LocalIpAddress}:{server.Port} ({status})";
notifyIcon.Icon = connected ? connectedIcon : waitIcon; notifyIcon.Icon = connected ? connectedIcon : waitIcon;
notifyIcon.Text = text.Length > 63 ? text[..63] : text; 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);
} }
} }