Files
Windows-Gotify-Client/Services/GotifyWebSocketService.cs
T
KennethCheng e53c0585f7 fix: 修复 SDK 2.4.0 + WPF/WinForms 双开下的编译错误
dotnet 8.0.424 SDK + Microsoft.WindowsAppSDK 2.4.0 实测 build 通过(0 警告 0 错误)。

修复内容:

- csproj:TargetFramework 从 net8.0-windows 改为 net8.0-windows10.0.19041.0,新增 SupportedOSPlatformVersion=10.0.17763.0(WindowsAppSDK 2.4.0 强制要求 TFM 带 Windows 版本号)

- 4 个 Services 文件加 using System.IO(Logger/ConfigService/StateService/GotifyWebSocketService)。WPF+WinForms 双开项目模板不自动 import System.IO

- GotifyApiService 加 using System.Net.Http(同上,HttpClient 不在 implicit usings)

- App.xaml.cs:using Application = System.Windows.Application(WPF/WinForms Application 撞名)

- MainWindow.xaml.cs:using MessageBox = System.Windows.MessageBox(同上,MessageBox 撞名)

- App.xaml.cs:SendTestNotificationAsync 里 Date 从 .UtcNow.ToUnixTimeMilliseconds() 改回 .UtcNow(原 plan 写法有 bug,long 塞不进 DateTimeOffset)

- App.xaml.cs:OnNotificationInvoked 重写:WindowsAppSDK 2.4.0 把 args.Arguments 从 string 改成 IDictionary<string,string>,改用 TryGetValue("url", out var url) 取参数

- NotificationService:AddArgument 从单参 "url=escaped" 改成两参 ("url", url)。WindowsAppSDK 2.4.0 不再支持单参 "key=value" 写法

- GotifyWebSocketService:删除 _forceReconnect 字段(写了不读,改成 RequestReconnect 只调 socket.Abort 让外层 RunLoopAsync 自然重连)
2026-09-06 20:22:54 +08:00

287 lines
7.9 KiB
C#

using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using WindowsGotify.Models;
namespace WindowsGotify.Services;
public sealed class GotifyWebSocketService
{
private readonly AppConfig _config;
private readonly Logger _logger;
private readonly StateService _stateService;
private readonly GotifyApiService _apiService;
private readonly NotificationService _notificationService;
private readonly Action<string> _statusChanged;
private readonly CancellationTokenSource _lifetimeCts = new();
private readonly object _socketLock = new();
private ClientWebSocket? _socket;
private bool _running;
/// <summary>
/// 收到一条新消息(已去重、未越界)时触发,UI 用来追加到历史列表。
/// </summary>
public event Action<GotifyMessage>? MessageReceived;
public GotifyWebSocketService(
AppConfig config,
Logger logger,
StateService stateService,
GotifyApiService apiService,
NotificationService notificationService,
Action<string> statusChanged)
{
_config = config;
_logger = logger;
_stateService = stateService;
_apiService = apiService;
_notificationService = notificationService;
_statusChanged = statusChanged;
}
public async Task StartAsync()
{
if (_running)
return;
_running = true;
await _stateService.LoadAsync();
_ = Task.Run(RunLoopAsync);
}
public void RequestReconnect()
{
// 直接 Abort 当前 socket,ConnectAndReceiveAsync 会抛 → 外层 RunLoopAsync 自动重连
lock (_socketLock)
{
try
{
_socket?.Abort();
}
catch
{
// ignored
}
}
}
public void Stop()
{
_running = false;
_lifetimeCts.Cancel();
lock (_socketLock)
{
try
{
_socket?.Abort();
_socket?.Dispose();
}
catch
{
// ignored
}
_socket = null;
}
}
private async Task RunLoopAsync()
{
var delay = Math.Max(1, _config.Reconnect.InitialDelaySeconds);
var maxDelay = Math.Max(delay, _config.Reconnect.MaxDelaySeconds);
var firstConnection = true;
while (_running && !_lifetimeCts.IsCancellationRequested)
{
try
{
_statusChanged("连接中...");
if (firstConnection && _config.History.Enabled)
{
await SyncHistoryAsync(_lifetimeCts.Token);
firstConnection = false;
}
await ConnectAndReceiveAsync(_lifetimeCts.Token);
delay = Math.Max(1, _config.Reconnect.InitialDelaySeconds);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.Error("Gotify WebSocket loop failed.", ex);
_statusChanged("断开");
}
if (!_running || _lifetimeCts.IsCancellationRequested)
break;
_logger.Info($"Reconnect after {delay}s.");
_statusChanged($"{delay}s 后重连");
try
{
await Task.Delay(TimeSpan.FromSeconds(delay), _lifetimeCts.Token);
}
catch (OperationCanceledException)
{
break;
}
delay = Math.Min(maxDelay, Math.Max(delay + 1, delay * 2));
}
}
private async Task SyncHistoryAsync(CancellationToken ct)
{
try
{
var lastId = _stateService.LastMessageId;
_logger.Info($"History sync. lastMessageId={lastId}");
var messages = await _apiService.GetMessagesSinceAsync(
lastId,
_config.History.StartupSyncCount,
ct);
foreach (var message in messages.OrderBy(x => x.Id))
{
await ProcessMessageAsync(message, showNotification: message.Id > lastId, ct);
}
}
catch (Exception ex)
{
_logger.Error("History sync failed.", ex);
}
}
private async Task ConnectAndReceiveAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
var wsUri = BuildWebSocketUri();
lock (_socketLock)
{
_socket = socket;
}
_logger.Info($"Connecting to {HideToken(wsUri)}");
await socket.ConnectAsync(wsUri, ct);
_logger.Info("WebSocket connected.");
_statusChanged("已连接");
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var buffer = new byte[64 * 1024];
while (socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(buffer, linkedCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.Warn("Gotify requested WebSocket close.");
return;
}
if (result.MessageType == WebSocketMessageType.Text && result.Count > 0)
{
ms.Write(buffer, 0, result.Count);
}
}
while (!result.EndOfMessage);
if (ms.Length == 0)
continue;
var json = Encoding.UTF8.GetString(ms.ToArray());
await HandleMessageJsonAsync(json, ct);
}
}
private async Task HandleMessageJsonAsync(string json, CancellationToken ct)
{
try
{
var message = JsonSerializer.Deserialize<GotifyMessage>(json);
if (message is null)
return;
await ProcessMessageAsync(message, showNotification: true, ct);
}
catch (JsonException ex)
{
_logger.Error("Invalid Gotify message JSON.", ex);
}
}
private async Task ProcessMessageAsync(
GotifyMessage message,
bool showNotification,
CancellationToken ct)
{
if (message.Id <= 0)
return;
var lastId = _stateService.LastMessageId;
if (message.Id <= lastId)
{
_logger.Info($"Duplicate/old message ignored. id={message.Id}, last={lastId}");
return;
}
if (showNotification)
{
await _notificationService.ShowAsync(message);
}
await _stateService.SetLastMessageIdAsync(message.Id);
_logger.Info($"Message processed. id={message.Id}");
// 触发 UI 订阅者(必须在 await SetLastMessageIdAsync 之后,确保下游不会拿到陈旧 lastId)
try
{
MessageReceived?.Invoke(message);
}
catch (Exception ex)
{
_logger.Error("MessageReceived subscriber threw.", ex);
}
}
private Uri BuildWebSocketUri()
{
var baseUri = new Uri(_config.Gotify.Url.TrimEnd('/') + "/");
var builder = new UriBuilder(baseUri)
{
Scheme = baseUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)
? "wss"
: "ws",
Path = baseUri.AbsolutePath.TrimEnd('/') + "/stream",
Query = "token=" + Uri.EscapeDataString(_config.Gotify.ClientToken)
};
return builder.Uri;
}
private static string HideToken(Uri uri)
{
var raw = uri.ToString();
var marker = "token=";
var index = raw.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
return index < 0 ? raw : raw[..(index + marker.Length)] + "***";
}
}