From c2f44767a6f55b108eaf6e042bec7294e48ccc67 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sun, 6 Sep 2026 18:44:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=20V1=20=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=BA=90=E7=A0=81=20=E2=80=94=20WebSocket=20=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=20+=20=E5=BA=94=E7=94=A8=E5=86=85=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 个源文件 + .gitignore + 默认 config.json 模板,严格按方案文档第 6 节实现。 用户核心场景: - Windows 接收 Gotify 通知(WS /stream → AppNotificationBuilder) - 应用内查看历史消息(打开 MainWindow 触发 GetRecentMessagesAsync,实时通过 MessageReceived 事件追加) - 列表条目双击打开 extras.client::notification.click.url 关键变更点: - GotifyMessage.Extras 类型由 Dictionary 改为 Dictionary,避免 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 - 默认 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 --- .gitignore | 18 ++ AGENTS.md | 15 +- App.xaml | 6 + App.xaml.cs | 147 +++++++++++++++ MainWindow.xaml | 125 +++++++++++++ MainWindow.xaml.cs | 205 +++++++++++++++++++++ Models/AppConfig.cs | 41 +++++ Models/GotifyMessage.cs | 86 +++++++++ Services/ConfigService.cs | 56 ++++++ Services/GotifyApiService.cs | 57 ++++++ Services/GotifyWebSocketService.cs | 287 +++++++++++++++++++++++++++++ Services/Logger.cs | 40 ++++ Services/NotificationService.cs | 66 +++++++ Services/StateService.cs | 63 +++++++ Tray/TrayIconService.cs | 74 ++++++++ Utils/StartupManager.cs | 44 +++++ WindowsGotify.csproj | 34 ++++ app.manifest | 16 ++ config.json | 23 +++ 19 files changed, 1401 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 MainWindow.xaml create mode 100644 MainWindow.xaml.cs create mode 100644 Models/AppConfig.cs create mode 100644 Models/GotifyMessage.cs create mode 100644 Services/ConfigService.cs create mode 100644 Services/GotifyApiService.cs create mode 100644 Services/GotifyWebSocketService.cs create mode 100644 Services/Logger.cs create mode 100644 Services/NotificationService.cs create mode 100644 Services/StateService.cs create mode 100644 Tray/TrayIconService.cs create mode 100644 Utils/StartupManager.cs create mode 100644 WindowsGotify.csproj create mode 100644 app.manifest create mode 100644 config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e9d903 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +## .NET / WPF build output +bin/ +obj/ +*.user +*.suo + +## VS +.vs/ +*.code-workspace + +## Windows App SDK 生成文件 +Generated Files/ +*.*proj.user +_bundle/ + +## 本地凭据(避免误提交真实的 Gotify Client Token) +config.local.json +config.local.*.json diff --git a/AGENTS.md b/AGENTS.md index 8abff17..db7f3de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,16 @@ Windows Gotify Agent —— 在 Windows 10/11 上以托盘程序方式运行,通过 WebSocket 连接 Gotify Server,把新消息以 Windows 原生通知呈现。 +## 用户场景 + +V1 核心场景(用户明确要求): + +- Windows 接收 Gotify 通知(WebSocket → Windows 原生通知) +- 应用内查看历史消息(打开 MainWindow → 历史列表,自动拉最近 N 条 + 实时追加新消息) +- 列表条目支持双击跳转:消息 `extras` 里有 `client::notification.click.url` 时,默认浏览器打开 + +V1 暂不做(避免范围蔓延):多 Gotify Server、消息过滤、GUI 完整配置编辑、MSI/MSIX 安装器、Linux/macOS 端。 + ## Setup commands - 环境:Windows 10 19041+ / Windows 11、Visual Studio 2022、.NET 8 SDK、Desktop development with .NET 工作负载 @@ -21,8 +31,9 @@ Windows Gotify Agent —— 在 Windows 10/11 上以托盘程序方式运行, - `Services/ConfigService.cs` — `config.json` 读写,路径 `%LOCALAPPDATA%\WindowsGotify\config.json` - `Services/Logger.cs` — 文本日志写入 `app.log`,线程安全 - `Services/StateService.cs` — `state.json` 持久化 `LastMessageId`,断线重连/历史同步的去重锚点 -- `Services/GotifyApiService.cs` — `GET /message?limit=&since=` 拉历史消息 -- `Services/GotifyWebSocketService.cs` — `/stream?token=...` WebSocket 循环、自动重连(指数退避 2→5→10→20→30→60)、History 补偿 +- `Services/GotifyApiService.cs` — `GET /message?limit=&since=` 拉历史补偿;`GetRecentMessagesAsync` 拉最近 N 条(供 UI 历史查看) +- `Services/GotifyWebSocketService.cs` — `/stream?token=...` WebSocket 循环、自动重连(指数退避 2→5→10→20→30→60)、History 补偿;新增 `MessageReceived` 事件供 UI 实时追加 +- `Services/NotificationService.cs` — 通过 `AppNotificationBuilder` 弹 Windows 原生通知,Priority ≥ 8 走 Alarm 音;若消息 `extras` 含 `client::notification.click.url`,通过 `AddArgument("url=...")` 透传给 `OnNotificationInvoked` - `Services/NotificationService.cs` — 通过 `AppNotificationBuilder` 弹 Windows 原生通知,Priority ≥ 8 走 Alarm 音 - `Utils/StartupManager.cs` — 写入 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 实现当前用户开机启动 - `Tray/TrayIconService.cs` — WinForms `NotifyIcon` 托盘(打开设置/测试通知/重新连接/退出) diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..08ca92a --- /dev/null +++ b/App.xaml @@ -0,0 +1,6 @@ + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..00944fb --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using System.Windows; +using Microsoft.Windows.AppNotifications; + +namespace WindowsGotify; + +public partial class App : Application +{ + private static Mutex? _mutex; + private Services.ConfigService? _configService; + private Services.Logger? _logger; + private Services.StateService? _stateService; + private Services.GotifyApiService? _apiService; + private Services.GotifyWebSocketService? _webSocketService; + private Services.NotificationService? _notificationService; + private Tray.TrayIconService? _trayService; + private MainWindow? _mainWindow; + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + _mutex = new Mutex(true, "WindowsGotifyAgent-SingleInstance", out bool createdNew); + if (!createdNew) + { + Shutdown(); + return; + } + + _logger = new Services.Logger(); + _configService = new Services.ConfigService(_logger); + var config = await _configService.LoadAsync(); + + _stateService = new Services.StateService(_logger); + _apiService = new Services.GotifyApiService(config, _logger); + _notificationService = new Services.NotificationService(config, _logger); + + try + { + // NotificationInvoked 必须在 Register 之前注册。 + AppNotificationManager.Default.NotificationInvoked += OnNotificationInvoked; + AppNotificationManager.Default.Register(); + _logger.Info("Windows App SDK notification manager registered."); + } + catch (Exception ex) + { + _logger.Error("Notification registration failed.", ex); + } + + _mainWindow = new MainWindow(); + _mainWindow.Hide(); + + _trayService = new Tray.TrayIconService( + onShowSettings: () => _mainWindow.ShowFromTray(), + onTest: async () => await SendTestNotificationAsync(), + onReconnect: () => _webSocketService?.RequestReconnect(), + onExit: () => Shutdown() + ); + + _webSocketService = new Services.GotifyWebSocketService( + config, + _logger, + _stateService, + _apiService, + _notificationService, + status => _trayService.SetStatus(status)); + + _mainWindow.Initialize( + config, + _configService, + _logger, + _trayService, + _webSocketService, + _apiService); + + if (config.Startup.Enabled) + { + Utils.StartupManager.EnableCurrentUserStartup(_logger); + } + + await _webSocketService.StartAsync(); + } + + private async Task SendTestNotificationAsync() + { + if (_notificationService is null) + return; + + await _notificationService.ShowAsync( + new Models.GotifyMessage + { + Id = 0, + AppId = 0, + Message = "Windows Gotify Agent 测试通知。", + Title = "Gotify 测试", + Priority = 5, + Date = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + } + + private void OnNotificationInvoked( + AppNotificationManager sender, + AppNotificationActivatedEventArgs args) + { + Dispatcher.Invoke(() => + { + try + { + var arguments = args.Arguments; + if (arguments.StartsWith("url=", StringComparison.OrdinalIgnoreCase)) + { + var url = Uri.UnescapeDataString(arguments[4..]); + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + else + { + _mainWindow?.ShowFromTray(); + } + } + catch (Exception ex) + { + _logger?.Error("Notification invocation failed.", ex); + } + }); + } + + protected override void OnExit(ExitEventArgs e) + { + try + { + _webSocketService?.Stop(); + _trayService?.Dispose(); + AppNotificationManager.Default.Unregister(); + AppNotificationManager.Default.NotificationInvoked -= OnNotificationInvoked; + } + catch (Exception ex) + { + _logger?.Error("Application cleanup failed.", ex); + } + + base.OnExit(e); + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..79bbe31 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +