17 个源文件 + .gitignore + 默认 config.json 模板,严格按方案文档第 6 节实现。
用户核心场景:
- Windows 接收 Gotify 通知(WS /stream → AppNotificationBuilder)
- 应用内查看历史消息(打开 MainWindow 触发 GetRecentMessagesAsync,实时通过 MessageReceived 事件追加)
- 列表条目双击打开 extras.client::notification.click.url
关键变更点:
- GotifyMessage.Extras 类型由 Dictionary<string,object> 改为 Dictionary<string,JsonElement>,避免 System.Text.Json 反序列化时丢失嵌套结构
- 新增 HistoryConfig.ViewerLimit(默认 50,UI 硬上限 200)
- GotifyApiService.GetRecentMessagesAsync:since=0 拉最近 N 条,OrderByDescending(Id) 保证顺序
- GotifyWebSocketService 新增 MessageReceived 事件,MainWindow 用 Dispatcher.Invoke 切回 UI 线程追加
- NotificationService.AddArgument('url=...') 透传 click URL 给 OnNotificationInvoked
- MainWindow.xaml 增加 ListView + 刷新按钮,使用 ObservableCollection<GotifyMessage>
- 默认 config.json 用 https://gotify.example.com 占位 URL,ClientToken 留空,首启动自动写入 %LOCALAPPDATA%
依赖:Microsoft.WindowsAppSDK 2.4.0(方案文档固定版本)。
构建:本机 .NET 8 SDK 未安装,build 待用户在装好 SDK 后执行: dotnet restore && dotnet build -c Release
288 lines
7.9 KiB
C#
288 lines
7.9 KiB
C#
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;
|
|
private bool _forceReconnect;
|
|
|
|
/// <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()
|
|
{
|
|
_forceReconnect = true;
|
|
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;
|
|
|
|
_forceReconnect = false;
|
|
_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)] + "***";
|
|
}
|
|
}
|