Files
omni-notify/Services.cs
home-PC c353845fad feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。
实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。
补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。

Initial-Commit: true
2026-05-19 01:32:41 +08:00

299 lines
8.3 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Windows;
using Microsoft.Win32;
namespace OmniNotify;
public sealed class AppStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _statePath;
public AppStore()
{
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniNotify");
Directory.CreateDirectory(root);
_statePath = Path.Combine(root, "state.json");
}
public AppState Load()
{
if (!File.Exists(_statePath))
{
var state = new AppState();
state.Channels.Add(new Channel { Name = "default" });
return state;
}
try
{
return JsonSerializer.Deserialize<AppState>(File.ReadAllText(_statePath), JsonOptions) ?? new AppState();
}
catch
{
return new AppState();
}
}
public void Save(AppState state)
{
CleanupHistory(state);
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions));
}
private static void CleanupHistory(AppState state)
{
var cutoff = DateTime.Now.AddDays(-Math.Max(1, state.Settings.RetainDays));
var survivors = state.History.Where(item => item.ReceivedAt >= cutoff)
.OrderByDescending(item => item.ReceivedAt)
.Take(Math.Max(1, state.Settings.RetainCount))
.OrderBy(item => item.ReceivedAt)
.ToList();
state.History.Clear();
foreach (var item in survivors)
{
state.History.Add(item);
}
}
}
public sealed class NotificationRouter
{
private readonly AppState _state;
private readonly AppStore _store;
private readonly PopupCoordinator _popupCoordinator;
private readonly Queue<DateTime> _rateWindow = new();
public event Action? StateChanged;
public NotificationRouter(AppState state, AppStore store, PopupCoordinator popupCoordinator)
{
_state = state;
_store = store;
_popupCoordinator = popupCoordinator;
}
public void Receive(IncomingMessage message, bool recordHistory = true)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (_state.Settings.CircuitBreakerOpen)
{
return;
}
if (IsRateLimited())
{
_state.Settings.CircuitBreakerOpen = true;
_popupCoordinator.ShowSystem("熔断保护", "检测到大量消息,已开启熔断保护。");
SaveAndNotify();
return;
}
var channel = _state.Channels.FirstOrDefault(item =>
string.Equals(item.Name, message.Channel, StringComparison.Ordinal));
if (channel is null)
{
AddHistory(message, NotificationStatus.IllegalChannel);
SaveAndNotify();
return;
}
if (_state.Settings.DndEnabled)
{
AddHistory(message, NotificationStatus.DndMuted);
SaveAndNotify();
return;
}
if (recordHistory)
{
AddHistory(message, NotificationStatus.Displayed);
}
_popupCoordinator.Show(channel, message);
SaveAndNotify();
});
}
public void Replay(HistoryItem item)
{
var channel = _state.Channels.FirstOrDefault(candidate =>
string.Equals(candidate.Name, item.Channel, StringComparison.Ordinal));
if (channel is not null)
{
_popupCoordinator.Show(channel, new IncomingMessage { Channel = item.Channel, Title = item.Title, Body = item.Body });
}
}
public void ResetCircuitBreaker()
{
_state.Settings.CircuitBreakerOpen = false;
_rateWindow.Clear();
SaveAndNotify();
}
private bool IsRateLimited()
{
var now = DateTime.Now;
while (_rateWindow.Count > 0 && (now - _rateWindow.Peek()).TotalSeconds > 1)
{
_rateWindow.Dequeue();
}
_rateWindow.Enqueue(now);
return _rateWindow.Count > Math.Max(1, _state.Settings.MaxMessagesPerSecond);
}
private void AddHistory(IncomingMessage message, NotificationStatus status)
{
_state.History.Add(new HistoryItem
{
Channel = message.Channel,
Title = message.Title,
Body = message.Body,
Status = status
});
}
private void SaveAndNotify()
{
_store.Save(_state);
StateChanged?.Invoke();
}
}
public sealed class LocalHttpServer : IDisposable
{
private readonly NotificationRouter _router;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
public LocalHttpServer(NotificationRouter router)
{
_router = router;
}
public string? Url { get; private set; }
public string? LastError { get; private set; }
public void Start(int port)
{
Url = $"http://127.0.0.1:{port}/";
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(Url);
try
{
_listener.Start();
_ = ListenAsync(_cts.Token);
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
private async Task ListenAsync(CancellationToken token)
{
while (!token.IsCancellationRequested && _listener?.IsListening == true)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleAsync(context), token);
}
catch when (token.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
}
private async Task HandleAsync(HttpListenerContext context)
{
try
{
if (context.Request.HttpMethod == "GET")
{
await WriteAsync(context, 200, "Omni-Notify is listening. POST /notify with JSON: {\"channel\":\"default\",\"title\":\"Hi\",\"body\":\"Text\"}");
return;
}
if (context.Request.HttpMethod != "POST" || context.Request.Url?.AbsolutePath != "/notify")
{
await WriteAsync(context, 404, "Not found");
return;
}
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
var body = await reader.ReadToEndAsync();
var message = JsonSerializer.Deserialize<IncomingMessage>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (message is null || string.IsNullOrWhiteSpace(message.Channel))
{
await WriteAsync(context, 400, "Invalid message");
return;
}
_router.Receive(message);
await WriteAsync(context, 202, "Accepted");
}
catch (Exception ex)
{
await WriteAsync(context, 500, ex.Message);
}
}
private static async Task WriteAsync(HttpListenerContext context, int code, string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
context.Response.StatusCode = code;
context.Response.ContentType = "text/plain; charset=utf-8";
context.Response.ContentLength64 = bytes.Length;
await context.Response.OutputStream.WriteAsync(bytes);
context.Response.Close();
}
public void Dispose()
{
_cts?.Cancel();
_listener?.Close();
}
}
public static class StartupManager
{
private const string AppName = "OmniNotify";
public static void Apply(bool enabled)
{
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true);
if (key is null)
{
return;
}
if (enabled)
{
key.SetValue(AppName, Process.GetCurrentProcess().MainModule?.FileName ?? "");
}
else
{
key.DeleteValue(AppName, false);
}
}
}