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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +