feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
This commit is contained in:
298
Services.cs
Normal file
298
Services.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user