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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
new file mode 100644
index 0000000..2ed01d9
--- /dev/null
+++ b/MainWindow.xaml.cs
@@ -0,0 +1,205 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using WindowsGotify.Models;
+using WindowsGotify.Services;
+using WindowsGotify.Tray;
+
+namespace WindowsGotify;
+
+public partial class MainWindow : Window
+{
+ private const int MaxHistoryInUi = 200;
+
+ private AppConfig? _config;
+ private ConfigService? _configService;
+ private Logger? _logger;
+ private TrayIconService? _tray;
+ private GotifyWebSocketService? _webSocket;
+ private GotifyApiService? _apiService;
+
+ private readonly ObservableCollection _history = new();
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ HistoryList.ItemsSource = _history;
+
+ Closing += (_, e) =>
+ {
+ e.Cancel = true;
+ Hide();
+ };
+ }
+
+ public void Initialize(
+ AppConfig config,
+ ConfigService configService,
+ Logger logger,
+ TrayIconService tray,
+ GotifyWebSocketService webSocket,
+ GotifyApiService apiService)
+ {
+ _config = config;
+ _configService = configService;
+ _logger = logger;
+ _tray = tray;
+ _webSocket = webSocket;
+ _apiService = apiService;
+
+ UrlTextBox.Text = config.Gotify.Url;
+ TokenBox.Password = config.Gotify.ClientToken;
+ StatusText.Text = "状态:等待连接";
+ UpdateHistoryCount();
+
+ // 实时消息追加到历史
+ _webSocket.MessageReceived += OnLiveMessage;
+ }
+
+ public void ShowFromTray()
+ {
+ Show();
+ WindowState = WindowState.Normal;
+ Activate();
+ // 每次打开都拉一次最新历史,确保和远端同步
+ _ = LoadHistoryAsync();
+ }
+
+ private void OnLiveMessage(GotifyMessage msg)
+ {
+ // WebSocket 回调可能在非 UI 线程,切回 UI 线程
+ Dispatcher.Invoke(() =>
+ {
+ _history.Insert(0, msg);
+ TrimHistory();
+ UpdateHistoryCount();
+ });
+ }
+
+ private void TrimHistory()
+ {
+ while (_history.Count > MaxHistoryInUi)
+ {
+ _history.RemoveAt(_history.Count - 1);
+ }
+ }
+
+ private void UpdateHistoryCount()
+ {
+ HistoryCountText.Text = $"共 {_history.Count} 条(本地缓存上限 {MaxHistoryInUi})";
+ }
+
+ private async Task LoadHistoryAsync()
+ {
+ if (_apiService is null || _config is null) return;
+ if (string.IsNullOrWhiteSpace(_config.Gotify.ClientToken))
+ {
+ StatusText.Text = "状态:未配置 Client Token,无法拉取历史";
+ return;
+ }
+
+ try
+ {
+ StatusText.Text = "状态:正在拉取历史...";
+ var limit = Math.Clamp(_config.History.ViewerLimit, 1, 100);
+ var messages = await _apiService.GetRecentMessagesAsync(limit, CancellationToken.None);
+
+ // 用清空 + 批量 Insert 让 UI 一次性刷新,避免多次 CollectionChanged
+ _history.Clear();
+ foreach (var m in messages)
+ {
+ _history.Add(m);
+ }
+ UpdateHistoryCount();
+ StatusText.Text = $"状态:已加载 {messages.Count} 条历史消息";
+ _logger?.Info($"History loaded. count={messages.Count}");
+ }
+ catch (Exception ex)
+ {
+ _logger?.Error("Load history failed.", ex);
+ StatusText.Text = $"状态:拉取历史失败 - {ex.Message}";
+ }
+ }
+
+ private async void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadHistoryAsync();
+ }
+
+ private void HistoryList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (HistoryList.SelectedItem is not GotifyMessage msg) return;
+ var url = msg.ClickUrl;
+ if (string.IsNullOrWhiteSpace(url)) return;
+
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true
+ });
+ _logger?.Info($"Opened click url. id={msg.Id}, url={url}");
+ }
+ catch (Exception ex)
+ {
+ _logger?.Error("Failed to open click url.", ex);
+ MessageBox.Show($"无法打开链接:{ex.Message}", "Windows Gotify",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private async void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_config is null || _configService is null) return;
+
+ var url = UrlTextBox.Text.Trim();
+ var token = TokenBox.Password.Trim();
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ MessageBox.Show("Gotify URL 无效。", "Windows Gotify",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ MessageBox.Show("Client Token 不能为空。", "Windows Gotify",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ _config.Gotify.Url = url.TrimEnd('/');
+ _config.Gotify.ClientToken = token;
+
+ await _configService.SaveAsync(_config);
+ _webSocket?.RequestReconnect();
+
+ MessageBox.Show("配置已保存,正在重新连接。", "Windows Gotify",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private async void TestButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var notification = new NotificationService(_config!, _logger!);
+ await notification.ShowAsync(new GotifyMessage
+ {
+ Id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Message = "Windows Gotify Agent 测试成功。",
+ Title = "Gotify 测试",
+ Priority = 5,
+ Date = DateTimeOffset.UtcNow
+ });
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(ex.Message, "测试通知失败",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+}
diff --git a/Models/AppConfig.cs b/Models/AppConfig.cs
new file mode 100644
index 0000000..860a4db
--- /dev/null
+++ b/Models/AppConfig.cs
@@ -0,0 +1,41 @@
+namespace WindowsGotify.Models;
+
+public sealed class AppConfig
+{
+ public GotifyConfig Gotify { get; set; } = new();
+ public NotificationConfig Notification { get; set; } = new();
+ public ReconnectConfig Reconnect { get; set; } = new();
+ public HistoryConfig History { get; set; } = new();
+ public StartupConfig Startup { get; set; } = new();
+}
+
+public sealed class GotifyConfig
+{
+ public string Url { get; set; } = "http://127.0.0.1:8080";
+ public string ClientToken { get; set; } = "";
+}
+
+public sealed class NotificationConfig
+{
+ public bool Enabled { get; set; } = true;
+ public bool Sound { get; set; } = true;
+ public int MaxMessageLength { get; set; } = 2000;
+}
+
+public sealed class ReconnectConfig
+{
+ public int InitialDelaySeconds { get; set; } = 2;
+ public int MaxDelaySeconds { get; set; } = 60;
+}
+
+public sealed class HistoryConfig
+{
+ public bool Enabled { get; set; } = true;
+ public int StartupSyncCount { get; set; } = 20;
+ public int ViewerLimit { get; set; } = 50;
+}
+
+public sealed class StartupConfig
+{
+ public bool Enabled { get; set; } = true;
+}
diff --git a/Models/GotifyMessage.cs b/Models/GotifyMessage.cs
new file mode 100644
index 0000000..8b0ef9b
--- /dev/null
+++ b/Models/GotifyMessage.cs
@@ -0,0 +1,86 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace WindowsGotify.Models;
+
+public sealed class GotifyMessage
+{
+ [JsonPropertyName("id")]
+ public long Id { get; set; }
+
+ [JsonPropertyName("appid")]
+ public long AppId { get; set; }
+
+ [JsonPropertyName("message")]
+ public string Message { get; set; } = "";
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("priority")]
+ public int Priority { get; set; }
+
+ [JsonPropertyName("date")]
+ public DateTimeOffset Date { get; set; }
+
+ [JsonPropertyName("extras")]
+ public Dictionary? Extras { get; set; }
+
+ [JsonIgnore]
+ public string TitleDisplay =>
+ string.IsNullOrWhiteSpace(Title) ? $"(App {AppId})" : Title!;
+
+ [JsonIgnore]
+ public string DateDisplay =>
+ Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
+
+ [JsonIgnore]
+ public string PriorityDisplay => $"P{Priority}";
+
+ ///
+ /// 提取 Gotify extras 中的 client::notification.click.url(用于点击跳转)。
+ ///
+ [JsonIgnore]
+ public string? ClickUrl
+ {
+ get
+ {
+ if (Extras is null) return null;
+ try
+ {
+ if (!Extras.TryGetValue("client::notification", out var notif)) return null;
+ if (notif.ValueKind != JsonValueKind.Object) return null;
+ if (!notif.TryGetProperty("click", out var click)) return null;
+ if (click.ValueKind != JsonValueKind.Object) return null;
+ if (!click.TryGetProperty("url", out var url)) return null;
+ if (url.ValueKind != JsonValueKind.String) return null;
+ return url.GetString();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
+
+public sealed class GotifyMessagePage
+{
+ [JsonPropertyName("messages")]
+ public List Messages { get; set; } = new();
+
+ [JsonPropertyName("paging")]
+ public PagingInfo? Paging { get; set; }
+}
+
+public sealed class PagingInfo
+{
+ [JsonPropertyName("size")]
+ public int Size { get; set; }
+
+ [JsonPropertyName("limit")]
+ public int Limit { get; set; }
+
+ [JsonPropertyName("next")]
+ public string? Next { get; set; }
+}
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
new file mode 100644
index 0000000..6ef239f
--- /dev/null
+++ b/Services/ConfigService.cs
@@ -0,0 +1,56 @@
+using System.Text.Json;
+using WindowsGotify.Models;
+
+namespace WindowsGotify.Services;
+
+public sealed class ConfigService
+{
+ private readonly Logger _logger;
+ private readonly string _configPath;
+ private readonly JsonSerializerOptions _options = new()
+ {
+ WriteIndented = true
+ };
+
+ public ConfigService(Logger logger)
+ {
+ _logger = logger;
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "WindowsGotify");
+
+ Directory.CreateDirectory(dir);
+ _configPath = Path.Combine(dir, "config.json");
+ }
+
+ public async Task LoadAsync()
+ {
+ if (!File.Exists(_configPath))
+ {
+ var config = new AppConfig();
+ await SaveAsync(config);
+ _logger.Warn($"Created default config: {_configPath}");
+ return config;
+ }
+
+ try
+ {
+ await using var stream = File.OpenRead(_configPath);
+ return await JsonSerializer.DeserializeAsync(stream, _options)
+ ?? new AppConfig();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Config load failed; using default config.", ex);
+ return new AppConfig();
+ }
+ }
+
+ public async Task SaveAsync(AppConfig config)
+ {
+ await using var stream = File.Create(_configPath);
+ await JsonSerializer.SerializeAsync(stream, config, _options);
+ }
+
+ public string GetConfigPath() => _configPath;
+}
diff --git a/Services/GotifyApiService.cs b/Services/GotifyApiService.cs
new file mode 100644
index 0000000..a98485f
--- /dev/null
+++ b/Services/GotifyApiService.cs
@@ -0,0 +1,57 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using WindowsGotify.Models;
+
+namespace WindowsGotify.Services;
+
+public sealed class GotifyApiService
+{
+ private readonly GotifyConfig _config;
+ private readonly Logger _logger;
+ private readonly HttpClient _httpClient;
+
+ public GotifyApiService(AppConfig config, Logger logger)
+ {
+ _config = config.Gotify;
+ _logger = logger;
+ _httpClient = new HttpClient
+ {
+ BaseAddress = new Uri(_config.Url.TrimEnd('/') + "/"),
+ Timeout = TimeSpan.FromSeconds(15)
+ };
+
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", _config.ClientToken);
+ }
+
+ ///
+ /// 拉取 lastId 之后的增量消息(去重补齐用)。
+ ///
+ public async Task> GetMessagesSinceAsync(long sinceId, int limit, CancellationToken ct)
+ {
+ var path = $"message?limit={Math.Clamp(limit, 1, 100)}&since={Math.Max(0, sinceId)}";
+ using var response = await _httpClient.GetAsync(path, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ var page = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct);
+ return page?.Messages ?? new List();
+ }
+
+ ///
+ /// 拉取最近 N 条消息(历史查看用)。since=0 让服务器从最新往旧返回。
+ ///
+ public async Task> GetRecentMessagesAsync(int limit, CancellationToken ct)
+ {
+ var path = $"message?limit={Math.Clamp(limit, 1, 100)}&since=0";
+ using var response = await _httpClient.GetAsync(path, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ var page = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct);
+ var list = page?.Messages ?? new List();
+
+ // Gotify 的 since=0 行为:返回数据库里所有消息中的 limit 条;按 id 降序再取一次保证正确
+ return list.OrderByDescending(m => m.Id).Take(Math.Clamp(limit, 1, 100)).ToList();
+ }
+}
diff --git a/Services/GotifyWebSocketService.cs b/Services/GotifyWebSocketService.cs
new file mode 100644
index 0000000..35f8df9
--- /dev/null
+++ b/Services/GotifyWebSocketService.cs
@@ -0,0 +1,287 @@
+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 _statusChanged;
+
+ private readonly CancellationTokenSource _lifetimeCts = new();
+ private readonly object _socketLock = new();
+ private ClientWebSocket? _socket;
+ private bool _running;
+ private bool _forceReconnect;
+
+ ///
+ /// 收到一条新消息(已去重、未越界)时触发,UI 用来追加到历史列表。
+ ///
+ public event Action? MessageReceived;
+
+ public GotifyWebSocketService(
+ AppConfig config,
+ Logger logger,
+ StateService stateService,
+ GotifyApiService apiService,
+ NotificationService notificationService,
+ Action 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(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)] + "***";
+ }
+}
diff --git a/Services/Logger.cs b/Services/Logger.cs
new file mode 100644
index 0000000..aecabab
--- /dev/null
+++ b/Services/Logger.cs
@@ -0,0 +1,40 @@
+namespace WindowsGotify.Services;
+
+public sealed class Logger
+{
+ private readonly object _lock = new();
+ private readonly string _logFile;
+
+ public Logger()
+ {
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "WindowsGotify",
+ "logs");
+
+ Directory.CreateDirectory(dir);
+ _logFile = Path.Combine(dir, "app.log");
+ }
+
+ public void Info(string message) => Write("INFO", message);
+ public void Warn(string message) => Write("WARN", message);
+ public void Error(string message, Exception? ex = null)
+ => Write("ERROR", ex is null ? message : $"{message} {ex}");
+
+ private void Write(string level, string message)
+ {
+ var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}";
+
+ lock (_lock)
+ {
+ try
+ {
+ File.AppendAllText(_logFile, line + Environment.NewLine);
+ }
+ catch
+ {
+ // 日志写不进时静默,不影响主流程
+ }
+ }
+ }
+}
diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs
new file mode 100644
index 0000000..bcd930d
--- /dev/null
+++ b/Services/NotificationService.cs
@@ -0,0 +1,66 @@
+using Microsoft.Windows.AppNotifications;
+using Microsoft.Windows.AppNotifications.Builder;
+using WindowsGotify.Models;
+
+namespace WindowsGotify.Services;
+
+public sealed class NotificationService
+{
+ private readonly NotificationConfig _config;
+ private readonly Logger _logger;
+
+ public NotificationService(AppConfig config, Logger logger)
+ {
+ _config = config.Notification;
+ _logger = logger;
+ }
+
+ public Task ShowAsync(GotifyMessage message)
+ {
+ if (!_config.Enabled)
+ return Task.CompletedTask;
+
+ try
+ {
+ var title = string.IsNullOrWhiteSpace(message.Title)
+ ? "Gotify"
+ : message.Title.Trim();
+
+ var body = message.Message ?? string.Empty;
+ if (body.Length > _config.MaxMessageLength)
+ {
+ body = body[..Math.Max(0, _config.MaxMessageLength)] + "…";
+ }
+
+ var builder = new AppNotificationBuilder()
+ .AddText(title)
+ .AddText(body);
+
+ if (_config.Sound)
+ {
+ builder.SetAudioEvent(
+ message.Priority >= 8
+ ? AppNotificationSoundEvent.Alarm
+ : AppNotificationSoundEvent.Default);
+ }
+
+ // 把 extras 里的 client::notification.click.url 透传给通知,让 App.OnNotificationInvoked 收到 url=...
+ var clickUrl = message.ClickUrl;
+ if (!string.IsNullOrWhiteSpace(clickUrl))
+ {
+ builder.AddArgument("url=" + Uri.EscapeDataString(clickUrl));
+ }
+
+ var notification = builder.BuildNotification();
+ AppNotificationManager.Default.Show(notification);
+
+ _logger.Info($"Notification shown. id={message.Id}, priority={message.Priority}, title={title}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Notification display failed. id={message.Id}", ex);
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Services/StateService.cs b/Services/StateService.cs
new file mode 100644
index 0000000..7335e46
--- /dev/null
+++ b/Services/StateService.cs
@@ -0,0 +1,63 @@
+using System.Text.Json;
+
+namespace WindowsGotify.Services;
+
+public sealed class StateService
+{
+ private sealed class StateData
+ {
+ public long LastMessageId { get; set; }
+ }
+
+ private readonly Logger _logger;
+ private readonly string _statePath;
+ private StateData _state = new();
+
+ public StateService(Logger logger)
+ {
+ _logger = logger;
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "WindowsGotify");
+
+ Directory.CreateDirectory(dir);
+ _statePath = Path.Combine(dir, "state.json");
+ }
+
+ public async Task LoadAsync()
+ {
+ if (!File.Exists(_statePath))
+ return;
+
+ try
+ {
+ await using var stream = File.OpenRead(_statePath);
+ _state = await JsonSerializer.DeserializeAsync(stream)
+ ?? new StateData();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("State load failed.", ex);
+ }
+ }
+
+ public long LastMessageId => _state.LastMessageId;
+
+ public async Task SetLastMessageIdAsync(long id)
+ {
+ if (id <= _state.LastMessageId)
+ return;
+
+ _state.LastMessageId = id;
+ await SaveAsync();
+ }
+
+ private async Task SaveAsync()
+ {
+ await using var stream = File.Create(_statePath);
+ await JsonSerializer.SerializeAsync(stream, _state, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+ }
+}
diff --git a/Tray/TrayIconService.cs b/Tray/TrayIconService.cs
new file mode 100644
index 0000000..adb39b2
--- /dev/null
+++ b/Tray/TrayIconService.cs
@@ -0,0 +1,74 @@
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace WindowsGotify.Tray;
+
+public sealed class TrayIconService : IDisposable
+{
+ private readonly NotifyIcon _notifyIcon;
+ private readonly Action _onShowSettings;
+ private readonly Func _onTest;
+ private readonly Action _onReconnect;
+ private readonly Action _onExit;
+
+ public TrayIconService(
+ Action onShowSettings,
+ Func onTest,
+ Action onReconnect,
+ Action onExit)
+ {
+ _onShowSettings = onShowSettings;
+ _onTest = onTest;
+ _onReconnect = onReconnect;
+ _onExit = onExit;
+
+ var menu = new ContextMenuStrip();
+
+ var showItem = new ToolStripMenuItem("打开设置 / 查看历史");
+ showItem.Click += (_, _) => _onShowSettings();
+
+ var testItem = new ToolStripMenuItem("测试通知");
+ testItem.Click += async (_, _) => await _onTest();
+
+ var reconnectItem = new ToolStripMenuItem("重新连接");
+ reconnectItem.Click += (_, _) => _onReconnect();
+
+ var exitItem = new ToolStripMenuItem("退出");
+ exitItem.Click += (_, _) => _onExit();
+
+ menu.Items.Add(showItem);
+ menu.Items.Add(testItem);
+ menu.Items.Add(reconnectItem);
+ menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add(exitItem);
+
+ _notifyIcon = new NotifyIcon
+ {
+ Icon = SystemIcons.Application,
+ Visible = true,
+ Text = "Windows Gotify",
+ ContextMenuStrip = menu
+ };
+
+ _notifyIcon.DoubleClick += (_, _) => _onShowSettings();
+ }
+
+ public void SetStatus(string status)
+ {
+ try
+ {
+ var text = $"Gotify: {status}";
+ _notifyIcon.Text = text.Length <= 63 ? text : text[..63];
+ }
+ catch
+ {
+ // ignore tray update failures
+ }
+ }
+
+ public void Dispose()
+ {
+ _notifyIcon.Visible = false;
+ _notifyIcon.Dispose();
+ }
+}
diff --git a/Utils/StartupManager.cs b/Utils/StartupManager.cs
new file mode 100644
index 0000000..1a1c74a
--- /dev/null
+++ b/Utils/StartupManager.cs
@@ -0,0 +1,44 @@
+using Microsoft.Win32;
+using WindowsGotify.Services;
+
+namespace WindowsGotify.Utils;
+
+public static class StartupManager
+{
+ private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
+ private const string AppName = "WindowsGotify";
+
+ public static void EnableCurrentUserStartup(Logger logger)
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)
+ ?? Registry.CurrentUser.CreateSubKey(RunKey, writable: true);
+
+ var exe = Environment.ProcessPath;
+ if (string.IsNullOrWhiteSpace(exe))
+ return;
+
+ key.SetValue(AppName, $"\"{exe}\"", RegistryValueKind.String);
+ logger.Info("Current-user startup enabled.");
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Failed to enable startup.", ex);
+ }
+ }
+
+ public static void DisableCurrentUserStartup(Logger logger)
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
+ key?.DeleteValue(AppName, throwOnMissingValue: false);
+ logger.Info("Current-user startup disabled.");
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Failed to disable startup.", ex);
+ }
+ }
+}
diff --git a/WindowsGotify.csproj b/WindowsGotify.csproj
new file mode 100644
index 0000000..df780bc
--- /dev/null
+++ b/WindowsGotify.csproj
@@ -0,0 +1,34 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ true
+ enable
+ enable
+ x64
+ WindowsGotify
+ WindowsGotify
+ app.manifest
+
+
+ None
+ true
+
+
+ false
+ true
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/app.manifest b/app.manifest
new file mode 100644
index 0000000..3ebddab
--- /dev/null
+++ b/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..930b9de
--- /dev/null
+++ b/config.json
@@ -0,0 +1,23 @@
+{
+ "Gotify": {
+ "Url": "https://gotify.example.com",
+ "ClientToken": ""
+ },
+ "Notification": {
+ "Enabled": true,
+ "Sound": true,
+ "MaxMessageLength": 2000
+ },
+ "Reconnect": {
+ "InitialDelaySeconds": 2,
+ "MaxDelaySeconds": 60
+ },
+ "History": {
+ "Enabled": true,
+ "StartupSyncCount": 20,
+ "ViewerLimit": 50
+ },
+ "Startup": {
+ "Enabled": true
+ }
+}