本次发布聚焦降低误报风险、规范发布产物和重构仓库文档。 主要变更: - 删除开机自启功能,移除 Windows 启动项注册表写入逻辑。 - 新增标准应用清单,声明应用以普通用户权限运行。 - 新增 framework-dependent 发布脚本,保持发布包不内置 .NET 运行时。 - 禁用单文件、自解压、裁剪和 ReadyToRun 发布方式,保持产物结构透明。 - 将发布资产命名规范化为 omni-notify-v0.2.0-win-x64.zip。 - 重写文档结构:README.md 面向用户,docs/development.md 面向开发者。 - 删除过时且内容重复的 PRD.md 与 docs/usage.md。 验证: - dotnet build .\OmniNotify.csproj -c Release 构建通过。 - 发布脚本成功生成 v0.2.0 win-x64 压缩包。 - 发布目录未包含 coreclr.dll、hostfxr.dll、hostpolicy.dll 等 .NET 运行时文件。
272 lines
7.8 KiB
C#
272 lines
7.8 KiB
C#
using System.IO;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
|
|
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();
|
|
}
|
|
}
|