feat: 落地 V1 全部源码 — WebSocket 通知 + 应用内历史查看

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
This commit is contained in:
2026-09-06 18:44:55 +08:00
parent 4b7f3a383c
commit c2f44767a6
19 changed files with 1401 additions and 2 deletions
+205
View File
@@ -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<GotifyMessage> _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);
}
}
}