commit c353845fada661c5a8fa3c1c3da4899019932011 Author: home-PC Date: Tue May 19 01:32:41 2026 +0800 feat(app): 初始化本地通知桌面应用 搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7800585 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +.vs/ +*.user +*.suo diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..9e95694 --- /dev/null +++ b/App.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..e5262fe --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,152 @@ +using System.IO; +using System.Threading; +using System.Windows; +using Forms = System.Windows.Forms; + +namespace OmniNotify; + +public partial class App : System.Windows.Application +{ + private const string SingleInstanceMutexName = @"Local\OmniNotify.SingleInstance"; + private const string ActivateEventName = @"Local\OmniNotify.Activate"; + private Forms.NotifyIcon? _notifyIcon; + private AppStore? _store; + private LocalHttpServer? _server; + private Mutex? _singleInstanceMutex; + private bool _ownsSingleInstanceMutex; + private EventWaitHandle? _activateEvent; + private RegisteredWaitHandle? _activateWait; + + public AppState State { get; private set; } = new(); + public NotificationRouter Router { get; private set; } = null!; + public PopupCoordinator PopupCoordinator { get; private set; } = null!; + public string ListenUrl => _server?.Url ?? ""; + public string? ServerError => _server?.LastError; + + protected override void OnStartup(StartupEventArgs e) + { + _singleInstanceMutex = new Mutex(true, SingleInstanceMutexName, out var createdNew); + _ownsSingleInstanceMutex = createdNew; + _activateEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivateEventName); + if (!createdNew) + { + _activateEvent.Set(); + Shutdown(); + return; + } + + RegisterActivationListener(); + base.OnStartup(e); + + _store = new AppStore(); + State = _store.Load(); + PopupCoordinator = new PopupCoordinator(); + Router = new NotificationRouter(State, _store, PopupCoordinator); + Router.StateChanged += UpdateTrayMenu; + + _server = new LocalHttpServer(Router); + _server.Start(State.Settings.LocalPort); + + CreateTrayIcon(); + ShowMainWindow(); + } + + public void SaveState() + { + _store?.Save(State); + UpdateTrayMenu(); + } + + public void ShowMainWindow() + { + if (MainWindow is null) + { + MainWindow = new MainWindow(); + } + + MainWindow.Show(); + MainWindow.WindowState = WindowState.Normal; + MainWindow.Activate(); + } + + private void RegisterActivationListener() + { + if (_activateEvent is null) + { + return; + } + + _activateWait = ThreadPool.RegisterWaitForSingleObject( + _activateEvent, + (_, _) => Dispatcher.Invoke(ShowMainWindow), + null, + Timeout.InfiniteTimeSpan, + false); + } + + private void CreateTrayIcon() + { + var iconPath = Path.Combine(AppContext.BaseDirectory, "app.ico"); + _notifyIcon = new Forms.NotifyIcon + { + Icon = new System.Drawing.Icon(iconPath), + Text = "Omni-Notify", + Visible = true + }; + _notifyIcon.MouseClick += (_, args) => + { + if (args.Button == Forms.MouseButtons.Left) + { + Dispatcher.Invoke(ShowMainWindow); + } + }; + UpdateTrayMenu(); + } + + private void UpdateTrayMenu() + { + if (_notifyIcon is null) + { + return; + } + + var menu = new Forms.ContextMenuStrip(); + menu.Items.Add("打开主控制面板", null, (_, _) => Dispatcher.Invoke(ShowMainWindow)); + menu.Items.Add("全局设置", null, (_, _) => Dispatcher.Invoke(() => + { + ShowMainWindow(); + if (MainWindow is MainWindow window) + { + window.FocusSettingsTab(); + } + })); + menu.Items.Add(State.Settings.DndEnabled ? "关闭免打扰" : "开启免打扰", null, (_, _) => Dispatcher.Invoke(() => + { + State.Settings.DndEnabled = !State.Settings.DndEnabled; + SaveState(); + })); + + var resetBreaker = menu.Items.Add("解除熔断", null, (_, _) => Dispatcher.Invoke(() => Router.ResetCircuitBreaker())); + resetBreaker.Enabled = State.Settings.CircuitBreakerOpen; + resetBreaker.ForeColor = State.Settings.CircuitBreakerOpen ? System.Drawing.Color.Firebrick : System.Drawing.SystemColors.ControlText; + + menu.Items.Add(new Forms.ToolStripSeparator()); + menu.Items.Add("退出软件", null, (_, _) => Dispatcher.Invoke(Shutdown)); + _notifyIcon.ContextMenuStrip = menu; + } + + protected override void OnExit(ExitEventArgs e) + { + _activateWait?.Unregister(null); + _activateEvent?.Dispose(); + if (_ownsSingleInstanceMutex) + { + _singleInstanceMutex?.ReleaseMutex(); + } + _singleInstanceMutex?.Dispose(); + _notifyIcon?.Dispose(); + _server?.Dispose(); + _store?.Save(State); + base.OnExit(e); + } +} diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..da49b20 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + +