搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
299 lines
8.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|