diff --git a/.gitignore b/.gitignore index 7800585..8124e69 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ obj/ .vs/ *.user *.suo +artifacts/ diff --git a/MainWindow.xaml b/MainWindow.xaml index c58d591..3e55b9c 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -184,7 +184,6 @@ - diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index dbfb482..6d4fca0 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -247,7 +247,6 @@ public partial class MainWindow : Window private void LoadSettings() { - StartWithWindowsBox.IsChecked = _state.Settings.StartWithWindows; DndBox.IsChecked = _state.Settings.DndEnabled; RateLimitBox.Text = _state.Settings.MaxMessagesPerSecond.ToString(); RetainDaysBox.Text = _state.Settings.RetainDays.ToString(); @@ -257,13 +256,11 @@ public partial class MainWindow : Window private void SaveSettings() { - _state.Settings.StartWithWindows = StartWithWindowsBox.IsChecked == true; _state.Settings.DndEnabled = DndBox.IsChecked == true; _state.Settings.MaxMessagesPerSecond = ReadInt(RateLimitBox, _state.Settings.MaxMessagesPerSecond); _state.Settings.RetainDays = ReadInt(RetainDaysBox, _state.Settings.RetainDays); _state.Settings.RetainCount = ReadInt(RetainCountBox, _state.Settings.RetainCount); _state.Settings.LocalPort = ReadInt(PortBox, _state.Settings.LocalPort); - StartupManager.Apply(_state.Settings.StartWithWindows); } private void RefreshHistoryFilters() diff --git a/Models.cs b/Models.cs index 46fc23a..3df79bf 100644 --- a/Models.cs +++ b/Models.cs @@ -54,7 +54,6 @@ public sealed class AppState public sealed class GlobalSettings { - public bool StartWithWindows { get; set; } public bool DndEnabled { get; set; } public bool CircuitBreakerOpen { get; set; } public int MaxMessagesPerSecond { get; set; } = 8; diff --git a/OmniNotify.csproj b/OmniNotify.csproj index 2c1cc84..0f53526 100644 --- a/OmniNotify.csproj +++ b/OmniNotify.csproj @@ -8,6 +8,25 @@ true true app.ico + app.manifest + OmniNotify + OmniNotify + 0.2.0 + 0.2.0.0 + 0.2.0.0 + OmniNotify + OmniNotify + Omni-Notify + Local desktop notification popup receiver. + Copyright (c) OmniNotify + git + zh-CN + true + true + false + false + false + false diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 9e05424..0000000 --- a/PRD.md +++ /dev/null @@ -1,67 +0,0 @@ -# 📦 Omni-Notify 产品需求与交互说明书 (PRD) - -## 一、 产品定位与全局原则 -* **产品名称:** Omni-Notify -* **核心功能:** 接收本机其他应用的格式化信息,进行高度自定义的视觉弹窗展示。 -* **适用系统:** windows10以上系统。 -* **全局核心原则:** - 1. **绝对零交互:** 弹窗纯视觉展示,绝对不抢夺焦点,不可点击,不可交互。 - 2. **扁平化频道制:** 弹窗样式与“频道(Channel)”一对一强绑定。 - 3. **性能优先:** 具备完善的日志清理与全局熔断保护机制。 - ---- - -## 二、 核心模块拆解与业务逻辑 - -### 模块 1:全局设置与托盘 (Tray & Global Settings) -* **系统托盘入口:** - * 左键:呼出主控面板。 - * 右键菜单:打开主控面板、全局设置、免打扰(DND)开关、**解除熔断(动态显示,仅熔断时高亮)**、退出软件。 -* **全局设置面板:** - * **基础设置:** 开机自启。 - * **历史清理策略:** 设定阈值(如保留最近 X 天,或最多保留 X 条),超限自动静默清理。 - * **免打扰模式 (DND):** 开启后,所有接收到的消息不触发弹窗,但正常静默写入“历史面板”。 - * **防刷屏熔断机制(Rate Limit):** - * **设定:** 全局阈值(如“每秒最多处理 X 条信息”)。 - * **触发表现:** 超限瞬间,系统直接抛弃后续所有新消息(不弹窗、不进历史面板)。 - * **熔断通知:** 触发瞬间,系统生成一条不受熔断限制的“系统级弹窗”(固定文案:检测到大量垃圾消息,已开启熔断保护)。 - * **解除方式:** 必须由用户手动在“托盘右键菜单”或“全局设置面板”点击【解除熔断】方可恢复。 - -### 模块 2:频道管理 (Channel Management) -* **信息接收匹配逻辑:** - * 采用**“主动创建,严格匹配”**模式。 - * 用户需在主控面板新建频道,自定义“频道名”(唯一标识符)。其他应用发来的信息必须包含该频道名。 - * **异常处理(严格模式):** 收到未注册/拼写错误的频道信息,直接丢弃不弹窗,并在历史面板记录一条报错日志(标记为:非法来源/未匹配频道)。 -* **主控面板列表:** 展示所有已建频道,提供新建、删除、重命名、编辑弹窗样式等基础管理功能。 - -### 模块 3:弹窗样式设计器 (Profile Designer) - 核心 -本模块为频道提供独立的样式配置表单。无需“所见即所得”预览,依赖外部发送测试信息验证。 - -**3.1 弹窗内容构成** -* 仅支持纯文本,严格划分为:**标题(Title)** 与 **正文(Body)** 两个区块。不支持图标等非文本元素。 - -**3.2 UI 配置参数分组** -1. **多屏与位置:** 指定显示器(主屏/副屏1/副屏2...)、屏幕九宫格位置(左上、上中、右下等)、屏幕边距(位置在边中央时为一个值,在角落时为两个值,在屏幕正中央时无需值)。 -2. **尺寸与排版:** 宽度、最大高度。 -3. **视觉样式:** 内边距、字体类型、字号(标题/正文可分设)、文字颜色、背景颜色与透明度、边框样式/颜色/透明度、整体透明度。 -4. **时间与动画:** 存在时间(X秒)、出现动画(淡入/滑入/放大等)、消失动画。 - -**3.3 高级行为规则:多消息堆叠模式(3选1)** -1. **队列排队:** 同一位置只显 1 条。新消息入队,上一条消失后,下一条按原动画出现。 -2. **推挤平移:** 新消息出现将老消息推挤开。**核心细节:**如果新消息先消失,老消息需重新滑回原位置。**边界规则:**无视屏幕物理边界,超限的老消息在屏幕外继续维持其存在时间与坐标逻辑,不强制销毁。 -3. **直接覆盖:** 新消息直接替换当前弹窗的标题与正文,并重新开始倒计时。 - -**3.4 高级行为规则:溢出处理模式(3选1)** -*前提:当正文内容过多,达到设定的“最大高度”时触发。* -1. **截断:** 超出最大高度的内容隐藏,末尾显示“...”。 -2. **跑马灯:** 文本在弹窗内匀速滚动。**核心细节(时间动态补偿):** - * 若设定存在时间 < 滚动显示完所需时间:滚动完毕后,停留 X 秒(用户设定)再消失。 - * 若设定存在时间 > 滚动显示完所需时间:等待原有存在时间耗尽后再消失。 -3. **分割信息:** 严格按“最大高度所能容纳的最大行数”为标准切断。切割后的多段信息,以一定间隔时间(用户设定)依次作为独立弹窗展示。 - -### 模块 4:历史信息面板 (History Panel) -* **数据展示:** 列表形态,字段包含:接收时间、频道来源、状态(成功展示 / 免打扰静默 / 非法拦截)。 -* **查询检索:** 支持按时间段、按频道来源、按状态进行高级筛选;支持关键字搜索。 -* **交互操作(右键菜单):** - 1. **复制完整内容:** 将该条信息的 Title 和 Body 复制到剪贴板。 - 2. **重新显示该弹窗(复播):** 纯视觉回放,按照该频道当前的样式重新走一遍弹窗流程,**不**在历史面板生成新的接收记录。 diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..b68e329 --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,17 @@ + + + + Release + Any CPU + bin\Release\net8.0-windows\win-x64\publish\ + FileSystem + net8.0-windows + win-x64 + false + false + false + false + embedded + false + + diff --git a/README.md b/README.md index 4a9a973..f00a36b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,154 @@ -# Omni-Notify +# Omni-Notify 用户文档 -Omni-Notify is a Windows 10+ WPF desktop app for receiving local structured messages and showing configurable, non-interactive visual popups. +Omni-Notify 是一款 Windows 本地通知弹窗工具。它接收来自本机脚本、自动化任务或其他程序的结构化消息,并以可配置的频道样式显示轻量弹窗。 -## Tech Stack +它适合这些场景: -- .NET 8 WPF for the desktop UI and popup windows -- Windows Forms `NotifyIcon` for the system tray entry -- Built-in `HttpListener` for local message intake -- JSON persistence under `%LOCALAPPDATA%\OmniNotify\state.json` +- 构建、部署、备份等脚本完成后弹出提示。 +- 本地监控任务发现异常时发出视觉提醒。 +- 工作流工具向桌面发送不抢焦点的状态通知。 +- 不想使用系统通知中心,希望弹窗样式、位置和历史记录可控。 -## Local Message API +## 安装要求 -When the app is running, it listens by default on: +- Windows 10 或更高版本 +- .NET 8 Desktop Runtime + +Omni-Notify 的发布包不内置 .NET 运行时,因此体积较小。首次运行前,请确认电脑已安装 .NET 8 Desktop Runtime。 + +## 下载与运行 + +下载发布包: + +```text +omni-notify-v0.2.0-win-x64.zip +``` + +解压后运行: + +```text +OmniNotify.exe +``` + +应用启动后会出现在系统托盘。左键点击托盘图标可显示或隐藏主控制面板,右键点击可打开菜单。 + +## 快速发送一条通知 + +默认接收地址: ```text http://127.0.0.1:19845/notify ``` -Send a POST request with UTF-8 JSON: - -```json -{ - "channel": "default", - "title": "Build finished", - "body": "The nightly job completed successfully." -} -``` - -Channels must be created in the control panel first. Unknown channels are blocked and recorded in history as `IllegalChannel`. - -## Build +PowerShell 示例: ```powershell -dotnet build +Invoke-RestMethod ` + -Uri "http://127.0.0.1:19845/notify" ` + -Method Post ` + -ContentType "application/json; charset=utf-8" ` + -Body '{"channel":"default","title":"Build finished","body":"The nightly job completed successfully."}' ``` + +JSON 字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `channel` | 是 | 频道名称,必须已在主面板中创建 | +| `title` | 否 | 弹窗标题 | +| `body` | 否 | 弹窗正文 | + +如果发送到不存在的频道,消息不会弹窗,但会在历史记录中标记为无效频道。 + +## 频道 + +频道用于区分不同来源或不同用途的通知。每个频道都可以独立配置弹窗样式。 + +常见用法: + +- `default`:普通通知。 +- `build`:构建或 CI 任务。 +- `monitor`:监控告警。 +- `backup`:备份任务。 + +在主控制面板的“频道”页中可以新建、删除、重命名频道,并配置该频道的显示效果。 + +## 弹窗样式 + +每个频道支持配置: + +- 显示器序号和屏幕位置。 +- 边距、宽度、最大高度和内边距。 +- 字体、标题字号、正文字号。 +- 文本颜色、背景颜色、边框颜色和透明度。 +- 存在时间、进入动画和退出动画。 +- 多条消息的堆叠方式。 +- 正文过长时的截断、滚动或拆分显示方式。 + +建议先配置频道,再点击“发送测试”观察实际效果。 + +## 历史记录 + +历史记录页会记录接收过的消息,包括: + +- 接收时间 +- 频道 +- 状态 +- 标题 +- 正文 + +你可以搜索、筛选、清空历史记录,也可以右键复制消息内容或重新显示某条历史消息。 + +## 全局设置 + +全局设置包括: + +- 免打扰模式:继续接收消息并记录历史,但不弹窗。 +- 每秒最多处理消息数:超过阈值后触发熔断保护。 +- 历史保留天数。 +- 历史最多保留条数。 +- 本地监听端口。 + +Omni-Notify v0.2.0 已移除开机自启功能。应用不会写入 Windows 启动项注册表。 + +## 熔断保护 + +如果短时间内收到大量消息,Omni-Notify 会自动进入熔断状态,防止弹窗刷屏或持续写入大量历史记录。 + +触发熔断后,你可以在托盘菜单或全局设置页中手动解除。 + +## 常见问题 + +**发送请求后没有弹窗** + +先确认频道名称是否存在,再确认是否开启了免打扰模式。也可以查看历史记录中的状态。 + +**提示无法连接本地地址** + +确认 Omni-Notify 正在运行,并检查全局设置中的本地监听端口。如果修改了端口,请使用新的端口发送请求。 + +**为什么发布包这么小** + +发布包采用 framework-dependent 模式,不内置 .NET 运行时。这样体积更小,文件结构也更透明。 + +**是否会开机自动启动** + +不会。v0.2.0 已删除开机自启功能,也不会写入 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`。 + +**杀毒软件仍然提示风险怎么办** + +建议使用正式签名的发布版本,并避免从不可信来源下载。Omni-Notify 的官方发布方式不使用混淆、壳、自解压或单文件打包。 + +## 数据位置 + +配置和历史记录保存在: + +```text +%LOCALAPPDATA%\OmniNotify\state.json +``` + +需要重置配置时,可以在退出应用后删除该文件。 + +## 面向开发者 + +如果你需要构建、修改或发布项目,请阅读 [开发者文档](docs/development.md)。 diff --git a/Services.cs b/Services.cs index e97d6c7..4511793 100644 --- a/Services.cs +++ b/Services.cs @@ -1,10 +1,8 @@ -using System.Diagnostics; using System.IO; using System.Net; using System.Text; using System.Text.Json; using System.Windows; -using Microsoft.Win32; namespace OmniNotify; @@ -61,7 +59,6 @@ public sealed class AppStore } } } - public sealed class NotificationRouter { private readonly AppState _state; @@ -169,7 +166,6 @@ public sealed class NotificationRouter StateChanged?.Invoke(); } } - public sealed class LocalHttpServer : IDisposable { private readonly NotificationRouter _router; @@ -273,26 +269,3 @@ public sealed class LocalHttpServer : IDisposable _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); - } - } -} diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..73ce899 --- /dev/null +++ b/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..213c2c6 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,189 @@ +# Omni-Notify 开发者文档 + +本文档面向需要构建、修改、审查或发布 Omni-Notify 的开发者。最终用户安装和使用说明请见 [README.md](../README.md)。 + +## 技术栈 + +- .NET 8 +- WPF +- Windows Forms `NotifyIcon` +- Built-in `HttpListener` +- JSON 本地持久化 + +项目不依赖第三方 NuGet 包。 + +## 项目结构 + +| 文件 | 责任 | +| --- | --- | +| `App.xaml.cs` | 应用生命周期、单实例互斥、托盘入口、本地 HTTP 服务启动 | +| `MainWindow.xaml` | 主控制面板 UI | +| `MainWindow.xaml.cs` | 频道管理、历史筛选、全局设置保存 | +| `Models.cs` | 应用状态、频道、弹窗样式、历史记录、输入消息模型 | +| `Services.cs` | 状态持久化、消息路由、限流熔断、本地 HTTP 接收服务 | +| `PopupCoordinator.cs` | 弹窗队列、堆叠、替换和位置计算 | +| `PopupWindow.xaml` | 单个弹窗窗口 UI | +| `PopupWindow.xaml.cs` | 弹窗动画、生命周期和正文溢出处理 | +| `app.manifest` | Windows 应用清单,声明普通用户权限运行 | +| `scripts/package-release.ps1` | 标准发布脚本 | + +## 本地构建 + +```powershell +dotnet build .\OmniNotify.csproj -c Release +``` + +目标框架: + +```xml +net8.0-windows +``` + +应用类型为 WPF 桌面应用,输出为 `WinExe`。 + +## 本地运行 + +```powershell +dotnet run --project .\OmniNotify.csproj +``` + +默认监听: + +```text +http://127.0.0.1:19845/ +``` + +通知接口: + +```text +POST http://127.0.0.1:19845/notify +``` + +请求体: + +```json +{ + "channel": "default", + "title": "Build finished", + "body": "The nightly job completed successfully." +} +``` + +## 状态持久化 + +状态文件路径: + +```text +%LOCALAPPDATA%\OmniNotify\state.json +``` + +保存内容包括: + +- 全局设置 +- 频道列表和频道样式 +- 历史记录 + +`AppStore.Save` 会在写入前清理超出保留天数和数量限制的历史记录。 + +## 消息处理流程 + +1. `LocalHttpServer` 接收本地 HTTP 请求。 +2. 请求体反序列化为 `IncomingMessage`。 +3. `NotificationRouter.Receive` 进入 UI Dispatcher。 +4. 检查熔断状态和每秒消息上限。 +5. 按 `channel` 严格匹配频道。 +6. 根据免打扰状态决定是否弹窗。 +7. 写入历史记录。 +8. `PopupCoordinator` 按频道样式显示弹窗。 + +未知频道不会弹窗,会记录为 `IllegalChannel`。 + +## 发布包 + +生成 v0.2.0 Windows x64 发布包: + +```powershell +.\scripts\package-release.ps1 -Version 0.2.0 +``` + +默认产物: + +```text +artifacts\omni-notify-v0.2.0-win-x64.zip +``` + +脚本参数: + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `Configuration` | `Release` | 构建配置 | +| `Version` | `0.2.0` | 应用版本和产物版本 | +| `RuntimeIdentifier` | `win-x64` | 目标运行时标识 | + +## 发布策略 + +当前发布策略刻意保持透明、标准和低风险: + +- `SelfContained=false` +- `PublishSingleFile=false` +- `PublishTrimmed=false` +- `PublishReadyToRun=false` +- `--self-contained false` +- 不使用混淆器 +- 不使用 UPX +- 不使用自解压启动器 +- 不请求管理员权限 + +发布脚本会检查产物目录中是否出现以下 .NET 运行时文件: + +- `coreclr.dll` +- `clrjit.dll` +- `hostfxr.dll` +- `hostpolicy.dll` + +如果出现这些文件,脚本会失败,避免误发布自包含运行时包。 + +## 安全敏感行为约束 + +v0.2.0 已删除开机自启功能。代码中不应重新引入以下行为,除非经过明确产品决策: + +- 写入 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` +- 创建计划任务实现自启动 +- 后台下载或执行外部程序 +- 全局键盘/鼠标钩子 +- 注入、提权、隐藏进程或绕过安全产品 + +应用保留的本地监听服务仅绑定 `127.0.0.1`,不应改为 `0.0.0.0` 或局域网地址,除非同时补充认证和明确的安全设计。 + +## 版本与 Git + +发布新版本时: + +1. 更新 `OmniNotify.csproj` 中的 `Version`、`FileVersion`、`AssemblyVersion`。 +2. 更新 `app.manifest` 中的 `assemblyIdentity version`。 +3. 更新文档中的示例版本号。 +4. 运行发布脚本生成 zip。 +5. 验证发布目录不包含 .NET 运行时文件。 +6. 提交代码。 +7. 创建 tag,例如 `v0.2.0`。 +8. 推送分支和 tag。 + +建议 tag 命名: + +```text +vMAJOR.MINOR.PATCH +``` + +建议发布资产命名: + +```text +omni-notify-vMAJOR.MINOR.PATCH-win-x64.zip +``` + +## v0.2.0 发布摘要 + +- 删除开机自启功能及相关注册表写入逻辑。 +- 新增标准应用清单,声明普通用户权限运行。 +- 新增 framework-dependent 发布脚本。 +- 发布包改为 `omni-notify-v0.2.0-win-x64.zip`。 +- README 改为用户文档,开发信息迁入本文档。 diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 new file mode 100644 index 0000000..2f5d87a --- /dev/null +++ b/scripts/package-release.ps1 @@ -0,0 +1,60 @@ +param( + [string]$Configuration = "Release", + [string]$Version = "0.2.0", + [string]$RuntimeIdentifier = "win-x64" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$publishProfile = "FolderProfile" +$publishDir = Join-Path $repoRoot "bin\$Configuration\net8.0-windows\$RuntimeIdentifier\publish" +$artifactsDir = Join-Path $repoRoot "artifacts" +$zipPath = Join-Path $artifactsDir "omni-notify-v$Version-$RuntimeIdentifier.zip" + +Set-Location $repoRoot + +function Invoke-DotNet { + param([string[]]$Arguments) + + & dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE" + } +} + +Invoke-DotNet @("restore", ".\OmniNotify.csproj") +Invoke-DotNet @("clean", ".\OmniNotify.csproj", "-c", $Configuration) +Invoke-DotNet @( + "publish", + ".\OmniNotify.csproj", + "-c", + $Configuration, + "-r", + $RuntimeIdentifier, + "--self-contained", + "false", + "-p:PublishProfile=$publishProfile", + "-p:Version=$Version" +) + +$runtimeFiles = @("coreclr.dll", "clrjit.dll", "hostfxr.dll", "hostpolicy.dll") +$bundledRuntime = Get-ChildItem -Path $publishDir -Recurse -File | + Where-Object { $runtimeFiles -contains $_.Name } + +if ($bundledRuntime) { + $names = ($bundledRuntime | Select-Object -ExpandProperty Name) -join ", " + throw "Publish output appears to include .NET runtime files: $names" +} + +if (!(Test-Path $artifactsDir)) { + New-Item -ItemType Directory -Path $artifactsDir | Out-Null +} + +if (Test-Path $zipPath) { + Remove-Item $zipPath +} + +Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $zipPath -CompressionLevel Optimal + +Write-Host "Created $zipPath"