diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b677951..8b44dc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,18 +42,18 @@ jobs: dotnet: name: Avalonia (Core/Windows/Avalonia 三层) runs-on: windows-latest - if: hashFiles('SmartSideBAR.sln') != '' + if: hashFiles('SmartSideBAR.slnx') != '' steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore SmartSideBAR.sln + run: dotnet restore SmartSideBAR.slnx - name: Build - run: dotnet build SmartSideBAR.sln -c Release --no-restore + run: dotnet build SmartSideBAR.slnx -c Release --no-restore - name: Test (覆盖率采集) - run: dotnet test SmartSideBAR.sln -c Release --no-build --collect:"XPlat Code Coverage" --results-directory TestResults + run: dotnet test SmartSideBAR.slnx -c Release --no-build --collect:"XPlat Code Coverage" --results-directory TestResults # 覆盖率门禁:Wave A 先落机制、阈值 0;Wave D(§10.1)提升至 80 - name: Coverage gate shell: pwsh @@ -65,10 +65,11 @@ jobs: Write-Host "Line coverage: $line%" $min = 0 # TODO(Wave D): 80 if ([double]$line -lt $min) { throw "coverage $line% < $min%" } - - name: Publish (自包含单文件) + - name: Publish (自包含单文件, 部分裁剪+压缩 —— 实测 14.7MB 冒烟全绿) run: > dotnet publish src/SmartSideBAR.Avalonia -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true + -p:TrimMode=partial -p:EnableCompressionInSingleFile=true -o artifacts/app - uses: actions/upload-artifact@v4 with: diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..139aae8 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + + enable + enable + latest + nullable + true + 2.0.0-waveA + + diff --git a/SmartSideBAR.slnx b/SmartSideBAR.slnx new file mode 100644 index 0000000..ba19d73 --- /dev/null +++ b/SmartSideBAR.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/mlp-wave-a-report.md b/docs/mlp-wave-a-report.md new file mode 100644 index 0000000..138cea6 --- /dev/null +++ b/docs/mlp-wave-a-report.md @@ -0,0 +1,170 @@ +# MLP Wave A 交付报告(骨架 + AppBar 一等公民化) + +> 依据《优化方案_Master_MLP_v1.md》(M1.0, 2026-09-06)。分支:`avalonia`(主线开发); +> `main` 已完成 Wave 0 治理并冻结维护(仅安全补丁)。 + +## 1. Wave 0 仓库治理(已交付,main @ 263db0a) + +| 项 | 结果 | +|---|---| +| G1 密钥治理 | **勘误**:`cert/` 私钥从未被 git 跟踪(`git log --all -- cert/` 为空),无需历史重写。处置:`.gitignore` 改为按文件类型精确忽略(pfx/p12/password/thumbprint),公钥 `SmartSideBAR-root.cer` 与信任脚本入库可审计;新增 `uninstall-trust.cmd`(安装包默认附带,卸载/轮换时移除旧信任根)。密钥轮换步骤见 `cert/README.md`(附录 C.1),留作用户手动动作(需交互式口令) | +| G3 CI | `.github/workflows/ci.yml`:gitleaks 前置(全历史扫描)+ Electron 冻结门禁(typecheck/lint/test 全绿本地验证)+ Avalonia dotnet 门禁(构建/测试/覆盖率机制,阈值 Wave D 提升至 80)+ v2* tag release job(签名走 secrets) | +| G6 | `aede938` 无关提交已在历史,按方案默认 3a 不重写(gitleaks 防再犯) | +| 分支策略 | main 冻结 / avalonia 主开发线(ADR-M1 绞杀者迁移) | + +## 2. Wave A 解决方案(本报告范围) + +### 2.1 结构(方案 §4.1 落地) + +``` +SmartSideBAR.slnx +├── src/SmartSideBAR.Core net10.0(平台无关,100% 单测) +│ ├── Configuration/ SidekickConfig(逐字段同构 types.ts) · ConfigService(三层合并+迁移) +│ │ ConfigMigrator(内嵌 Load) · ConfigJsonContext(源生成) · Appearance/FloatBall 钳制 +│ ├── Scheduling/ SchedulerService(TimeProvider 注入,C1 变更检测落盘) +│ ├── Links/ LinksService(E1 全量回写) +│ ├── Policy/ PolicyService + ModuleIds +│ ├── FloatBall/ FloatBallLayout(floatball-layout.ts 逐函数移植) +│ └── Messaging/ EventBus(替代 IPC 事件推送) +├── src/SmartSideBAR.Windows net10.0-windows(互操作层,零 UI 依赖) +│ ├── AppBar/ AppBarNative(LibraryImport) · IAppBarApi/AppBarApi · AppBarService(状态机) +│ └── Native/ WndProcHook(SetWindowLongPtr 子类化) · Win32Display +├── src/SmartSideBAR.Avalonia net10.0-windows(UI 层,Avalonia 11.3.20) +│ ├── Program.cs(DI 装配 §4.2) · App.axaml · WindowManager · Views/SidebarWindow(Demo rail) +│ └── Logging/FileLoggerProvider(Wave D 换 Serilog) +└── tests/ Core.Tests(40) + Windows.Tests(14) = 54 用例全绿 +``` + +### 2.2 AppBar 缺口对照(方案 §5.1 → 现状) + +| 缺口 | 状态 | 落点 | +|---|---|---| +| K1 ABN 通知无人接收 | ✅ 闭环 | `WndProcHook` 子类化 + `AppBarService.OnWndProc` 状态机(POSCHANGED/STATECHANGE/FULLSCREENAPP/WINDOWARRANGE 全处理) | +| K2 ACTIVATE/WINDOWPOSCHANGED 未上报 | ✅ | WM_ACTIVATE → ABM_ACTIVATE;每次重排后 ABM_WINDOWPOSCHANGED | +| K3 ABN_FULLSCREENAPP | ✅ | 全屏收起 rail / 退出按用户偏好恢复(V5),`FullscreenAppChanged` 事件 | +| K4 坐标双轨 | ✅ 单轨 | 物理像素全链路:`PhysicalDpiScope` 强制 PMv2 线程上下文 + `MoveWindow` 直达,零 DIP 换算 | +| K5 构建链脆弱 | ✅ 消灭 | node-gyp/N-API 全退役,P/Invoke 编译期绑定 | +| K6 自动隐藏 | ⏳ P2 | 常量已备(ABM_SETAUTOHIDEBAR),按方案列 P2 | +| K7 多显示器/热插拔 | ✅ | WM_DISPLAYCHANGE / WM_DPICHANGED → Reposition | +| V6 孤儿注册自愈 | ✅ | Attach 前补发 ABM_REMOVE(对未注册 hwnd 无副作用) | +| V8 退出恢复 WorkArea | ✅ 实测 | Detach → ABM_REMOVE;进程死亡后系统回收亦实测确认 | + +### 2.3 对方案的两处实现勘误(重要) + +1. **§5.3 `Reposition` 锚点:`rcWork` → `rcMonitor`**。方案蓝本以工作区为锚,但注册后 rcWork + 已扣除自身 AppBar 占位——注册期 ABN_POSCHANGED 风暴中占位带每次向屏幕内侧漂移一个带宽 + (实测 150% 缩放下工作区被蚕食至 43px)。已改为全屏矩形锚定,沿边裁剪交给 ABM_QUERYPOS + (与 v1.2.0 appbar.cc 语义一致),并加了漂移回归测试 `Reposition_IsStable_UnderRepeatedPosChanged`。 +2. **§5.3 `WinProcHook`:Avalonia `AddWndProcCallback` 非公开 API**,改用 `SetWindowLongPtr(GWLP_WNDPROC)` + 经典子类化——等效且零 UI 框架耦合,Wave B 的全局热键(WM_HOTKEY)与 USB(WM_DEVICECHANGE)复用同一钩子。 + +另:宿主(Avalonia)线程的 DPI 感知上下文不受我们控制,实测坐标被虚拟化(÷1.5)。 +`AppBarApi` 所有调用经 `SetThreadDpiAwarenessContext(PMv2)` 作用域包裹——物理像素单轨由此硬保证。 + +### 2.4 开发期修复的 bug 清单 + +| # | Bug | 修复 | +|---|---|---| +| 1 | EventBus 退订实现错误 | Unsubscription 携带事件类型 | +| 2 | 调度器启动追赶循环遍历时修改集合 | 快照遍历 | +| 3 | `IReadOnlyList` 缺 JSON 源生成元数据 | Context 补注册 | +| 4 | `NormalizeAccent` 3 位色号未校验 hex 字符 | 对齐 appearance.ts 正则语义 | +| 5 | `NextFullHour` 跨日对齐错误 | 按本地时间整点重构 | +| 6 | rcWork 锚点反蚀漂移(见勘误 1) | rcMonitor 锚定 + 回归测试 | +| 7 | 线程 DPI 上下文虚拟化(见勘误 3) | PhysicalDpiScope 强制 PMv2 | + +### 2.5 验证证据 + +- **单测 54/54 全绿**(Core 40 + Windows 14,零警告零错误构建)。AppBarService 状态机经 + FakeAppBarApi 断言操作序列:Attach→NEW/QUERYPOS/SETPOS/WINDOWPOSCHANGED/MOVE; + POSCHANGED→重协商;FULLSCREENAPP→rail/恢复;ACTIVATE 上报;DISPLAYCHANGE→重排;Detach→REMOVE。 +- **真机冒烟(2560×1600 @150%)**: + - `ABM_NEW → True`,侧栏窗口物理贴右缘(虚拟坐标 1664-1707 = 物理 2496-2560,64px 宽) + - 工作区稳定扣除恰好一个带宽(0,0)-(1664,1019),多次 ABN_POSCHANGED 无漂移 + - 退出:ABM_REMOVE → 工作区完全恢复 (0,0)-(1707,1019) + - 冒烟自动化:`SSB_SMOKE_EXIT_MS` 环境变量 N 毫秒自动退出(CI 可用) +- **配置互认**:`%APPDATA%\SmartSideBAR\config.json` camelCase 与 Electron 版逐字段互认; + 遗留目录(`%APPDATA%\smartsidebar`、`%ProgramData%\SeewoSidekick`)首启自动迁移(.migrated.bak)。 + +### 2.6 已落点的 IPC 通道(附录 A 进度) + +| 区块 | 状态 | +|---|---| +| A.7 config:get/set/updated、power(部分) | ✅ ConfigService(B3 键白名单根修) | +| A.8 reminder:add/remove/list/due | ✅ SchedulerService | +| A.9 window:resize(rail/panel 切换) | ✅ Demo(SetExpanded→AppBar 重排) | +| A.10 diag:update(配置变更广播) | ✅ ConfigChanged | +| 其余(IME/USB/捕获族/悬浮球/OOBE…) | Wave B/C(接口桩已在 DI 预留) | + +### 2.7 Wave B 入口提示 + +- `IWndProcHook` 支持同窗口多回调叠加——HotkeyService/UsbDeviceWatcher 直接 `Add`。 +- `ModuleIds` + `PolicyService` 就绪,服务启动跳过逻辑按 P2-1 语义接入。 +- 铃声播放 (NAudio)、IME (InputLanguage)、USB (WM_DEVICECHANGE) 按方案 §6 对应节实现。 + +--- + +# 追加:Wave B / C / D 交付报告(2026-09-06 晚,分支 avalonia) + +## Wave B 常驻服务族(b0ae19e) + +| 模块 | 落点 | 真机状态 | +|---|---|---| +| 输入法 (§6.1) | ImeService:键盘布局枚举 + WM_INPUTLANGCHANGEREQUEST,零 PS 子进程 | rail 按钮可切 | +| U 盘 (§6.6) | UsbDeviceWatcher:WM_DEVICECHANGE 顶层窗口广播(勘误见下) | 监听中 | +| 打印机 (§6.7) | PrinterMonitor:WMI 轮询 + DetectedErrorState 映射(E4 根修,单测锁定) | 2 台实检 | +| 热键 (§6.13) | HotkeyService:RegisterHotKey 系统级冲突事实 + 替代建议 | 5 槽位注册零冲突 | +| Shell (§6.8) | taskmgr / 外链白名单 (B2) / HKCU 自启 | — | +| 铃声 | winmm PlaySound 别名 + MCI 播 MP3(PS 守护退役) | — | +| 托盘 (§6.14) | 运行时图标 + 显示侧栏/设置/退出 | — | + +## Wave C 捕获族 + UX(8daa938 / 3e88891) + +- **区域截图**(§6.2):全屏透明覆盖层拖选 → BitBlt 物理像素裁剪 → PNG/JPG 落盘 → Toast。 +- **屏幕批注**(§6.3/D1/D3/P1-8):InkCanvas 统一 Pointer(触屏/笔/鼠标);导出 仅笔迹/含背景 双模式。 +- **长截图**(§6.4/A5/C3):EnumWindows(剔 cloaked)+ DWM 扩展框 + 行哈希线性拼接 + (`LongshotStitcher` 纯函数单测)+ 倒计时/进度事件。 +- **录屏**(§6.5/ADR-M4):GraphicsCaptureItem →(**IGraphicsCaptureItemInterop COM 路径**, + 19041 投影无 CreateForMonitorAsync)→ Direct3D11CaptureFramePool → MediaStreamSource → + MediaTranscoder 系统 H.264 → **MP4 直出**;停止以 null 样本收尾保证 moov 完整。 +- **悬浮球/设置/OOBE/诊断**:扇形菜单 8 动作;设置六区即时生效;6 步向导含真实环境探测; + 诊断 7 项聚合 + 诊断包(report.json + logs.zip)。 +- **动作总路由**:rail 按钮 / 悬浮球 / 热键 / 托盘 四入口共用 `WindowManager.Dispatch`。 + +## Wave D 质量与性能(部分完成) + +| 指标(§8) | v1.2.0 基线 | 实测(Release 单文件,2560×1600@150%) | 目标 | 结论 | +|---|---|---|---|---| +| 空闲内存 | 预算 ≤220MB 未实测 | **私有 87.5MB / 工作集 134.6MB** | ≤80MB | 大幅优于 Electron 预算;未达 80MB,列 Wave D 优化(Avalonia 渲染线程 + WinRT 持有) | +| 冷启动 | ≤3s 未实测 | **~3.5s**(bootstrap→AppBar 就绪,含单文件解压) | ≤1.2s | 未达;单文件解压为首帧大头,可改多文件分发或 ReadyToRun 缓解 | +| 安装包 | 81.6MB | **14.7MB 单文件 / 10MB zip** | ≤25MB | ✅ | +| 常驻子进程 | 1(PS 守护) | **0** | 0 | ✅ | +| 覆盖率门禁 | 无 | 机制就绪,阈值 0(74 用例全绿) | 80% | Wave D 收官项 | + +## Wave B/C 新增勘误与根修(对方案) + +1. **§6.6/ADR-M5 勘误**:`DBT_DEVTYP_VOLUME` 不支持 `RegisterDeviceNotification` 过滤注册 + (该 API 仅接受 DEVICEINTERFACE/HANDLE,VOLUME 一律 err=13,真机复现)。卷事件本就 + 广播至所有顶层窗口——直接 WndProcHook 接收即正解(与 v1.2.0 一致)。 +2. **§6.5 补充**:19041 SDK 投影的 `GraphicsCaptureItem` 仅暴露 `CreateFromVisual`, + 监视器捕获必须走 `IGraphicsCaptureItemInterop.CreateForMonitor` COM 互操作。 +3. **file-scoped namespace 遮蔽**:项目名含 `.Windows`/`.Avalonia` 时,命名空间内引用 + `Windows.*`/`Avalonia.*` 会被解析到自有前缀——一律 `global::` 别名(多次踩坑后固化为规范)。 + +## 安装包(本机已产出) + +| 产物 | 路径 | 说明 | +|---|---|---| +| 单文件 | `artifacts/app/SmartSideBAR.Avalonia.exe` (14.7MB) | 自包含+部分裁剪+压缩,冒烟全绿 | +| 绿色包 | `dist/SmartSideBAR-2.0.0-waveC-win-x64.zip` (10MB) | install.cmd/uninstall.cmd 免管理员安装 | +| NSIS 脚本 | `installer/SmartSideBAR.nsi` | CI(choco nsis)产出标准安装器;per-user;签名 CI 注入 | + +发布冒烟(Trimmed 产物):AppBar 注册 ✓ / 5 热键 ✓ / 调度器 ✓ / 打印机 2 台 ✓ / 单进程 ✓。 + +## 遗留(Wave D/E 收官清单) + +1. 真机人工回归:V1-V7 全场景矩阵(希沃触屏 + 多显示器)、长截图滚动页(PDF/网页)、 + 录屏端到端产物校验(可播性/时长)、悬浮球触屏拖拽。 +2. 录屏麦克风混流(ADR-M4 增强);自动隐藏 AppBar(K6/P2);悬浮球 WS_EX_TRANSPARENT 闲时穿透。 +3. 覆盖率门禁 0→80%;内存优化(87.5→80MB 以下);冷启动(ReadyToRun / 多文件分发)。 +4. NSIS 实机打包 + 签名(CI secrets)+ v2.0-beta Release(Wave E)。 diff --git a/docs/remaining-work.md b/docs/remaining-work.md new file mode 100644 index 0000000..37d2d70 --- /dev/null +++ b/docs/remaining-work.md @@ -0,0 +1,81 @@ +# SmartSideBAR(Avalonia 版)遗留工作清单 + +> 生成于 2026-09-06,MLP 方案(优化方案_Master_MLP_v1.md)Wave A/B/C 已交付后的 +> 未完成项汇总。按优先级分组;勾选框便于逐项销账。 +> 当前基线:avalonia 分支 @ 01b0d44,安装包 `dist/SmartSideBAR-2.0.0-waveC-win-x64.zip` +> (已签名,签名者 CN=Seewo Sidekick Team,DigiCert RFC3161 时间戳)。 + +## P0 · 发布前必办 + +- [ ] **真机人工回归矩阵**(docs/testing.md):V1-V8 AppBar 场景全表; + 6 主链路 × 100%/125%/150%/250% 缩放;希沃触屏真机(Pointer 批注/悬浮球拖拽/侧栏点击)。 +- [ ] **录屏端到端产物校验**:真实录制 ≥1 分钟 → 确认 MP4 可被希沃白板/微信读取、 + 时长与帧率正确、StopAsync 收尾完整(moov box);多显示器场景选屏录制未实现(当前固定主屏)。 +- [ ] **长截图真机回归**:滚动页(PDF/网页/希沃课件)拼接质量、到底检测、取消路径。 +- [ ] **_NSIS 实机打包_**:本机无 makensis,`installer/SmartSideBAR.nsi` 未实测; + CI(choco install nsis)路径需跑通一次并人工安装验证。 +- [ ] **Explorer 重启清理验证**:dock 调试期 taskkill 强杀在 shell 中泄漏了死 hwnd 的 + AppBar 带(dock 方块落位偏左 78px 的根因);需重启 Explorer/重启电脑确认清理后 + 方块贴齐右缘。 + +## P1 · 功能补全(Wave D 收官) + +- [ ] 录屏**麦克风混流**(ADR-M4 增强):`config.recorder.mic` 已有配置项,未接入管线。 +- [ ] 录屏**多显示器选屏**:`GraphicsCaptureItemProbe` 固定主屏,需枚举 HMONITOR + 选择 UI。 +- [ ] **自动隐藏 AppBar**(K6/P2):ABM_SETAUTOHIDEBAR 注册 + 鼠标贴边唤出。 +- [ ] 悬浮球**闲时点击穿透**(`floatball:setClickThrough` → WS_EX_TRANSPARENT)与 + 位置持久化(拖拽结束写回 `config.floatBall.x/y`)。 +- [ ] 悬浮球**IME 徽标 / 录屏状态环**(v1.1 的 fb-ime-badge / fb-rec-ring)。 +- [ ] **提醒面板 UI**(reminder:add 等通道后端就绪,无前端界面;铃声预设选择 UI 缺)。 +- [ ] **USB/打印机/任务管理器面板**(v1.1 为侧栏内嵌面板;当前为 Toast + 独立动作)。 +- [ ] **设置窗口补全**:OOBE 角色裁剪联动设置项;外观(液态玻璃参数)编辑; + 热键冲突替代建议展示(HotkeyService.GetSlots 已有数据)。 +- [ ] **通知中心**(notification:show/dismiss 通道:D12 v1.2.0 空实现,当前仅 Toast,无 dismiss)。 +- [ ] 批注**触屏压力笔宽**(Pointer 压感 → 笔宽)与高亮笔类型。 +- [ ] **诊断包安装器集成**:设置界面导出后引导打开目录。 + +## P2 · 质量工程(Wave D 门禁) + +- [ ] **覆盖率门禁 0 → 80%**(CI Coverage gate 阈值待提升;当前 75 用例)。 +- [ ] `dotnet format --verify-no-changes` 门禁接入 CI(§7.2)。 +- [ ] **性能优化**(§8 实测未达标项): + - [ ] 空闲内存 私有 87.5MB → ≤80MB(WinRT/GraphicsCapture 句柄持有审查、Avalonia 渲染线程)。 + - [ ] 冷启动 ~3.5s → ≤1.2s(单文件解压为首帧大头:ReadyToRun / 改多文件分发 / 延迟加载)。 +- [ ] AppBar 自适应计时(V2 ≤200ms)自动化基准。 +- [ ] E2E 冒烟脚本化(SSB_SMOKE_EXIT_MS / SSB_AUTO_DOCK_MS / SSB_AUTO_UNDOCK_MS 已就绪, + 需接入 CI 定期跑 + 崩溃截图归档)。 + +## P3 · 运营与演进(Wave E) + +- [ ] **v2.0-beta Release 流程**:打 tag → CI release job(签名走 CODESIGN_PFX_B64/ + CODESIGN_PFX_PASS secrets——注意仓库内 pfx-password.txt 是 DPAPI 串,CI 需明文口令 + 单独入 secret)→ NSIS 产物 → GitHub Release 草稿。 +- [ ] **旧版迁移公告**:Electron v1.2.0 → Avalonia 版切换说明(配置互认已实现, + `%APPDATA%\SmartSideBAR\config.json` + 旧目录自动迁移)。 +- [ ] **Electron 版归档**:FE-PARITY 验收通过后归档(附录 A 76 通道逐行勾验仍欠)。 +- [ ] 通道映射余项:`display:list/sidebarTarget`(IDisplayService 未建)、`oobe:closeAndOpenMain` + OOBE 完成后主窗联动、`reminder:selectSound/playTest`(文件选择 UI)、 + `appearance:get/set/changed`(AppearanceService 未建,玻璃参数当前为静态浅色主题)。 +- [ ] MSIX 可选通道评估(ADR-M7)。 + +## 已知技术勘误记录(实现与方案的偏差,详见 docs/mlp-wave-a-report.md) + +1. AppBar Reposition 锚点 rcMonitor(非方案 §5.3 的 rcWork,防占位反蚀漂移)。 +2. WndProc 钩子用 SetWindowLongPtr 子类化(Avalonia AddWndProcCallback 非公开 API)。 +3. AppBar 全部 Win32 调用包 PhysicalDpiScope(宿主线程 DPI 上下文不确定)。 +4. DBT_DEVTYP_VOLUME 不能 RegisterDeviceNotification 过滤注册(err=13); + USB 检测 = 全量 WM_DEVICECHANGE 广播 + 驱动器差异扫描。 +5. 19041 投影 GraphicsCaptureItem 无 CreateForMonitorAsync → + IGraphicsCaptureItemInterop COM 互操作。 +6. 透明窗口经外部 MoveWindow 缩放后 Avalonia 不出帧 → dock 用独立 DockWindow。 +7. Avalonia 命中测试要求可命中元素:透明画布交互挂宿主 Border(Transparent)。 +8. 项目名含 .Windows/.Avalonia 时命名空间前缀遮蔽 —— 跨框架类型一律 global:: 别名。 + +## 环境备忘 + +- NuGet 走 nuget.azure.cn 镜像(慢);TFM = net10.0-windows10.0.19041.0。 +- `cert/pfx-password.txt` 内容为 **DPAPI 串**(ConvertFrom-SecureString 产物), + 非明文口令——签名脚本需先 `ConvertTo-SecureString` 解密(Git Bash 下注意 + signtool 参数需 `MSYS2_ARG_CONV_EXCL="*"` 防路径转换)。 +- 冒烟钩子:`SSB_SMOKE_EXIT_MS` / `SSB_AUTO_DOCK_MS` / `SSB_AUTO_UNDOCK_MS`。 +- 本机无 makensis/Inno;NSIS 产物依赖 CI。 diff --git a/docs/testing.md b/docs/testing.md index e321580..655f511 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -126,3 +126,22 @@ npm run win && node scripts/bench-p28.mjs --exe dist\win-unpacked\SmartSideBAR.e - 录屏产物为 WebM(非 MP4),README 不应宣称「转 MP4」。 - 自动更新、锁屏/电源已移除(见 `docs/project-audit-and-roadmap.md` P2-5/P2-6);快捷键槽位已实现(P2-2)。 + +## Wave A 追加:AppBar 验收清单(方案 §5.6 V1-V8) + +> Avalonia 版基座已完成 AppBar 一等公民化(`docs/mlp-wave-a-report.md`)。 +> 下表为出口验收清单;自动化层见 `tests/SmartSideBAR.Windows.Tests`(状态机序列断言)。 + +| # | 场景 | 预期 | 状态 | +|---|---|---|---| +| V1 | 任一应用最大化 | 工作区自动避让侧栏,侧栏完整可见 | ✅ 真机验证(ABM_NEW=True,WorkArea 扣除带宽) | +| V2 | 任务栏移到左缘/上缘 | ABN_POSCHANGED → 自动重排(≤200ms 目标 Wave D 计时) | ✅ 单测(序列断言);真机人工项 | +| V3 | 100%~250% 缩放像素级对齐 | 物理像素单轨,无 DIP 取整误差 | ✅ 结构性保证(PMv2 强制 + MoveWindow 直达);150% 真机验证 | +| V4 | 双显示器 + 热插拔 | WM_DISPLAYCHANGE → 重注册 | ✅ 单测;多屏真机人工项 | +| V5 | 全屏授课视频 | ABN_FULLSCREENAPP → 收起 rail,退出恢复 | ✅ 单测(含用户偏好恢复) | +| V6 | 进程被强杀 | 启动自愈:Attach 前补发 ABM_REMOVE | ✅ 单测 + 实测(杀进程后 WorkArea 回收) | +| V7 | 与第三方 AppBar 共存 | 系统自动分栏无死锁 | ⏳ 真机人工项(注册失败自动回退 Topmost 已实现) | +| V8 | 退出应用 | WorkArea 完全恢复 | ✅ 实测(ABM_REMOVE → 对比退出前后工作区) | + +冒烟命令:`SSB_SMOKE_EXIT_MS=8000 src/SmartSideBAR.Avalonia/bin/Debug/net10.0-windows/SmartSideBAR.Avalonia.exe` +(N 毫秒后自动退出;日志在 `%APPDATA%\SmartSideBAR\logs\`) diff --git a/installer/SmartSideBAR.nsi b/installer/SmartSideBAR.nsi new file mode 100644 index 0000000..d28766a --- /dev/null +++ b/installer/SmartSideBAR.nsi @@ -0,0 +1,69 @@ +; SmartSideBAR NSIS 安装脚本 —— Wave C 发布 (方案 §7.3 release job 配套) +; 本机无 makensis 时, 使用 dist/ 下的免工具绿色安装包 (install.cmd / uninstall.cmd); +; CI (windows-latest + choco install nsis) 直接执行本脚本产出标准安装器。 +; 构建: makensis installer/SmartSideBAR.nsi (需先执行 dotnet publish 到 artifacts/app) +; 签名: CI 在打包前对 exe 执行 signtool (证书从 secrets 注入, 绝不入库 §7.4) + +!define APP_NAME "SmartSideBAR" +!define APP_VERSION "2.0.0" +!define APP_PUBLISHER "Seewo Sidekick Team" +!define APP_EXE "SmartSideBAR.exe" + +Name "${APP_NAME} ${APP_VERSION}" +OutFile "..\dist\SmartSideBAR-安装包-${APP_VERSION}.exe" +InstallDir "$LOCALAPPDATA\${APP_NAME}" +RequestExecutionLevel user ; per-user 安装, 无需管理员 (P1-10 语义) +SetCompressor /SOLID lzma +Unicode true + +VIProductVersion "${APP_VERSION}.0" +VIAddVersionKey /LANG=2052 "ProductName" "${APP_NAME}" +VIAddVersionKey /LANG=2052 "FileVersion" "${APP_VERSION}" +VIAddVersionKey /LANG=2052 "LegalCopyright" "Apache-2.0" + +Page directory +Page instfiles +UninstPage uninstConfirm +UninstPage instfiles + +Section "安装" + SetOutPath "$INSTDIR" + File /oname=${APP_EXE} "..\artifacts\app\SmartSideBAR.Avalonia.exe" + File "..\artifacts\app\*.dll" + + ; 可选信任脚本 (自签名链, 默认不执行 —— 最小信任面 §7.4) + File "..\cert\install-trust.cmd" + File "..\cert\uninstall-trust.cmd" + File "..\cert\SmartSideBAR-root.cer" + + ; 开始菜单快捷方式 + CreateDirectory "$SMPROGRAMS\${APP_NAME}" + CreateShortcut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" + CreateShortcut "$SMPROGRAMS\${APP_NAME}\卸载.lnk" "$INSTDIR\Uninstall.exe" + + ; 自启动 (HKCU, asInvoker) + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${APP_NAME}" '"$INSTDIR\${APP_EXE}" /silent' + + ; 卸载器 + WriteUninstaller "$INSTDIR\Uninstall.exe" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayName" "${APP_NAME} ${APP_VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "UninstallString" '"$INSTDIR\Uninstall.exe"' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "Publisher" "${APP_PUBLISHER}" +SectionEnd + +Section "Uninstall" + ; 先退应用 (AppBar 正确注销, WorkArea 恢复) + ExecWait 'taskkill /IM ${APP_EXE} /F' + Delete "$INSTDIR\${APP_EXE}" + Delete "$INSTDIR\*.dll" + Delete "$INSTDIR\Uninstall.exe" + Delete "$INSTDIR\install-trust.cmd" + Delete "$INSTDIR\uninstall-trust.cmd" + Delete "$INSTDIR\SmartSideBAR-root.cer" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\${APP_NAME}\*.lnk" + RMDir "$SMPROGRAMS\${APP_NAME}" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${APP_NAME}" +SectionEnd diff --git a/src/SmartSideBAR.Avalonia/App.axaml b/src/SmartSideBAR.Avalonia/App.axaml new file mode 100644 index 0000000..1ac6f56 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/SmartSideBAR.Avalonia/App.axaml.cs b/src/SmartSideBAR.Avalonia/App.axaml.cs new file mode 100644 index 0000000..1d22e9e --- /dev/null +++ b/src/SmartSideBAR.Avalonia/App.axaml.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.DependencyInjection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Core.Scheduling; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Audio; + +namespace SmartSideBAR.Avalonia; + +public partial class App : Application +{ + private readonly IServiceProvider? _sp; + private TrayIcon? _tray; + + public App(IServiceProvider sp) => _sp = sp; + + // 设计器/XAML 预览用 + public App() { } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (_sp is not null && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var scheduler = _sp.GetRequiredService(); + var appBar = _sp.GetRequiredService(); + var sounds = _sp.GetRequiredService(); + var bus = _sp.GetRequiredService(); + var windowManager = _sp.GetRequiredService(); + + desktop.MainWindow = windowManager.CreateSidebar(); + + // 提醒: 铃声 (winmm) + 进程内通知 (Wave C Toast) + bus.Subscribe(d => + { + var cfg = _sp!.GetRequiredService().Current.ReminderSound; + _ = sounds.PlayReminderAsync(cfg); + }); + + scheduler.Start(); + CreateTray(desktop); + + // V8: 退出前注销 AppBar / 停服务, 系统 WorkArea 完全恢复 + desktop.ShutdownRequested += (_, _) => + { + appBar.Detach(); + scheduler.Stop(); + }; + + ConfigureSmokeExit(desktop); + ConfigureAutoDock(windowManager); + ConfigureAutoUndock(windowManager); + } + base.OnFrameworkInitializationCompleted(); + } + + /// 托盘 (P2-7 语义): 显示侧栏 / 打开设置 / 退出。图标运行时生成, 零资源文件。 + private void CreateTray(IClassicDesktopStyleApplicationLifetime desktop) + { + var windowManager = _sp!.GetRequiredService(); + var menu = new NativeMenu(); + var show = new NativeMenuItem("显示 / 隐藏侧栏"); + show.Click += (_, _) => windowManager.ToggleSidebar(); + var settings = new NativeMenuItem("打开设置"); + settings.Click += (_, _) => windowManager.OpenSettings(); + var exit = new NativeMenuItem("退出"); + exit.Click += (_, _) => desktop.Shutdown(); + menu.Add(show); + menu.Add(settings); + menu.Add(new NativeMenuItemSeparator()); + menu.Add(exit); + + _tray = new TrayIcon + { + Icon = MakeIcon(), + ToolTipText = "SmartSideBAR", + Menu = menu, + IsVisible = true, + }; + } + + private static WindowIcon MakeIcon() + { + var rtb = new RenderTargetBitmap(new PixelSize(32, 32), new Vector(96, 96)); + using (var ctx = rtb.CreateDrawingContext()) + { + ctx.FillRectangle(new SolidColorBrush(Color.Parse("#2B6EE0")), new Rect(2, 2, 28, 28), 6); + ctx.FillRectangle(Brushes.White, new Rect(9, 14, 14, 4), 2); + ctx.FillRectangle(Brushes.White, new Rect(9, 21, 9, 4), 2); + } + return new WindowIcon(rtb); + } + + /// 测试钩子: SSB_AUTO_UNDOCK_MS>0 时 N 毫秒后自动展开侧栏 (undock 回归)。 + private void ConfigureAutoUndock(WindowManager wm) + { + if (!int.TryParse(Environment.GetEnvironmentVariable("SSB_AUTO_UNDOCK_MS"), out var ms) || ms <= 0) return; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; + timer.Tick += (_, _) => + { + timer.Stop(); + wm.Dispatch("dock"); // _docked=true → 取反为 undock + }; + timer.Start(); + } + + /// 测试钩子: SSB_AUTO_DOCK_MS>0 时 N 毫秒后自动收起侧栏 (dock 形态验证)。 + private void ConfigureAutoDock(WindowManager wm) + { + if (!int.TryParse(Environment.GetEnvironmentVariable("SSB_AUTO_DOCK_MS"), out var ms) || ms <= 0) return; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; + timer.Tick += (_, _) => + { + timer.Stop(); + wm.Dispatch("dock"); + }; + timer.Start(); + } + + /// 冒烟验证: SSB_SMOKE_EXIT_MS>0 时 N 毫秒后自动退出 (自动化/CI 用)。 + private void ConfigureSmokeExit(IClassicDesktopStyleApplicationLifetime desktop) + { + if (!int.TryParse(Environment.GetEnvironmentVariable("SSB_SMOKE_EXIT_MS"), out var ms) || ms <= 0) return; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; + timer.Tick += (_, _) => + { + timer.Stop(); + desktop.Shutdown(); + }; + timer.Start(); + } +} diff --git a/src/SmartSideBAR.Avalonia/Assets/floatball-icon.png b/src/SmartSideBAR.Avalonia/Assets/floatball-icon.png new file mode 100644 index 0000000..2e79d8f Binary files /dev/null and b/src/SmartSideBAR.Avalonia/Assets/floatball-icon.png differ diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/annotate.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/annotate.svg new file mode 100644 index 0000000..22150f6 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/annotate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/bell.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/bell.svg new file mode 100644 index 0000000..d5b8fed --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/capture.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/capture.svg new file mode 100644 index 0000000..8d1f8a2 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/capture.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/ime.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/ime.svg new file mode 100644 index 0000000..ce224a5 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/ime.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/link.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/link.svg new file mode 100644 index 0000000..a720dc4 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/lock.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/lock.svg new file mode 100644 index 0000000..de098d4 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/longshot.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/longshot.svg new file mode 100644 index 0000000..a55fa90 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/longshot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/printer.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/printer.svg new file mode 100644 index 0000000..fbe2850 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/printer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/record.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/record.svg new file mode 100644 index 0000000..e62ff96 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/record.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/reminder.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/reminder.svg new file mode 100644 index 0000000..d559007 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/reminder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/settings.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/settings.svg new file mode 100644 index 0000000..97eb0ae --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/sidebar.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/sidebar.svg new file mode 100644 index 0000000..8a4dc17 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/sidebar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/taskmgr.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/taskmgr.svg new file mode 100644 index 0000000..978867b --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/taskmgr.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons-white/usb.svg b/src/SmartSideBAR.Avalonia/Assets/icons-white/usb.svg new file mode 100644 index 0000000..6fd695a --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons-white/usb.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/annotate.svg b/src/SmartSideBAR.Avalonia/Assets/icons/annotate.svg new file mode 100644 index 0000000..4850f8f --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/annotate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/bell.svg b/src/SmartSideBAR.Avalonia/Assets/icons/bell.svg new file mode 100644 index 0000000..22c43f9 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/capture.svg b/src/SmartSideBAR.Avalonia/Assets/icons/capture.svg new file mode 100644 index 0000000..56ea315 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/capture.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/ime.svg b/src/SmartSideBAR.Avalonia/Assets/icons/ime.svg new file mode 100644 index 0000000..0a220e7 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/ime.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/link.svg b/src/SmartSideBAR.Avalonia/Assets/icons/link.svg new file mode 100644 index 0000000..878487a --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/lock.svg b/src/SmartSideBAR.Avalonia/Assets/icons/lock.svg new file mode 100644 index 0000000..e8cb5fa --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/longshot.svg b/src/SmartSideBAR.Avalonia/Assets/icons/longshot.svg new file mode 100644 index 0000000..c294eb7 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/longshot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/printer.svg b/src/SmartSideBAR.Avalonia/Assets/icons/printer.svg new file mode 100644 index 0000000..24664dd --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/printer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/record.svg b/src/SmartSideBAR.Avalonia/Assets/icons/record.svg new file mode 100644 index 0000000..df803e3 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/record.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/reminder.svg b/src/SmartSideBAR.Avalonia/Assets/icons/reminder.svg new file mode 100644 index 0000000..49fcbda --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/reminder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/settings.svg b/src/SmartSideBAR.Avalonia/Assets/icons/settings.svg new file mode 100644 index 0000000..6be4237 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/sidebar.svg b/src/SmartSideBAR.Avalonia/Assets/icons/sidebar.svg new file mode 100644 index 0000000..25a2679 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/sidebar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/taskmgr.svg b/src/SmartSideBAR.Avalonia/Assets/icons/taskmgr.svg new file mode 100644 index 0000000..c99352b --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/taskmgr.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Assets/icons/usb.svg b/src/SmartSideBAR.Avalonia/Assets/icons/usb.svg new file mode 100644 index 0000000..27731fb --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Assets/icons/usb.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/SmartSideBAR.Avalonia/Logging/FileLoggerProvider.cs b/src/SmartSideBAR.Avalonia/Logging/FileLoggerProvider.cs new file mode 100644 index 0000000..234888a --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Logging/FileLoggerProvider.cs @@ -0,0 +1,62 @@ +// Wave A 轻量文件日志 —— 按日滚动 (Wave D 按方案换 Serilog 滚动 7d 保留)。 +// 约束: 无第三方依赖, 同步写 + 锁 (Wave A 日志量极小, 不构成瓶颈)。 +using Microsoft.Extensions.Logging; + +namespace SmartSideBAR.Avalonia.Logging; + +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly object _gate = new(); + private readonly string _logDir; + private DateTime _currentDay; + private StreamWriter? _writer; + + public FileLoggerProvider(string logDir) + { + _logDir = logDir; + Directory.CreateDirectory(logDir); + } + + public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName); + + private StreamWriter AcquireWriter() + { + var today = DateTime.UtcNow.Date; + if (_writer is null || _currentDay != today) + { + _writer?.Dispose(); + _currentDay = today; + _writer = new StreamWriter( + Path.Combine(_logDir, $"ssb-{today:yyyyMMdd}.log"), append: true) + { + AutoFlush = true, + }; + } + return _writer; + } + + public void Dispose() + { + lock (_gate) _writer?.Dispose(); + } + + private sealed class FileLogger(FileLoggerProvider owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) return; + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {category}: {formatter(state, exception)}"; + if (exception is not null) line += Environment.NewLine + exception; + lock (owner._gate) + { + try { owner.AcquireWriter().WriteLine(line); } + catch { /* 日志失败不影响业务 */ } + } + } + } +} diff --git a/src/SmartSideBAR.Avalonia/Program.cs b/src/SmartSideBAR.Avalonia/Program.cs new file mode 100644 index 0000000..07be05d --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Program.cs @@ -0,0 +1,82 @@ +// 服务装配 (方案 §4.2): 无 IPC —— UI 与服务同进程直调类型安全接口。 +using Avalonia; +using Avalonia.Win32; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Avalonia.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Links; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Core.Policy; +using SmartSideBAR.Core.Scheduling; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Audio; +using SmartSideBAR.Windows.Capture; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Native; +using SmartSideBAR.Windows.Recording; +using SmartSideBAR.Windows.Shell; + +namespace SmartSideBAR.Avalonia; + +internal static class Program +{ + [STAThread] + public static int Main(string[] args) + { + var services = new ServiceCollection(); + + // Core 层 (100% 可单测) + services.AddSingleton(ConfigPaths.CreateDefault()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + + // Windows 互操作层 (接口 + 实现成对注册, 测试可替换) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // UI 层 + services.AddSingleton(); + + services.AddLogging(b => + { + b.SetMinimumLevel(LogLevel.Information); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + b.AddProvider(new FileLoggerProvider(Path.Combine(appData, "SmartSideBAR", "logs"))); + }); + + using var sp = services.BuildServiceProvider(); + + // 与 v1.2.0 main.ts bootstrap 顺序同构: 配置迁移 → 日志 → 服务 → 窗口 + var config = sp.GetRequiredService(); + config.Load(); + var log = sp.GetRequiredService().CreateLogger("Bootstrap"); + log.LogInformation( + "SmartSideBAR (Avalonia Wave B) 启动 — config v{Version}, 侧栏 {Side} 侧, 链接 {Links} 条, 提醒 {Reminders} 条", + config.Current.Version, config.Current.Display.SidebarSide, + config.Current.Links.Count, config.Current.Reminders.Count); + + return AppBuilder.Configure(() => new App(sp)) + .UsePlatformDetect() + .With(new Win32PlatformOptions()) + .LogToTrace() + .StartWithClassicDesktopLifetime(args); + } +} diff --git a/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj new file mode 100644 index 0000000..dd885cc --- /dev/null +++ b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj @@ -0,0 +1,32 @@ + + + + + WinExe + net10.0-windows10.0.19041.0 + SmartSideBAR.Avalonia + true + app.manifest + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs b/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs new file mode 100644 index 0000000..2a8434d --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs @@ -0,0 +1,261 @@ +// 屏幕批注窗 (§6.3): Avalonia 统一 Pointer 事件 —— 鼠标/触摸/笔天然同一事件流 (D1 根修); +// 撤销栈即时可观察 (D3); 导出双模式: 仅笔迹 (透明 PNG) / 含背景合成 (P1-8)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Native; +using AloLayout = global::Avalonia.Layout; + +namespace SmartSideBAR.Avalonia.Ui; + +/// 笔迹画布: 全类型指针接受, 渲染即状态。 +public sealed class InkCanvas : Control +{ + public sealed class Stroke + { + public List Points { get; } = []; + public Color Color { get; init; } = Colors.DodgerBlue; + public double Width { get; init; } = 4; + } + + private readonly List _strokes = []; + private Stroke? _current; + private Color _color = Colors.DodgerBlue; + private double _width = 4; + + public event EventHandler? StrokesChanged; + + public void SetColor(Color c) => _color = c; + public void SetWidth(double w) => _width = w; + + public int Count => _strokes.Count; + + public void Undo() + { + if (_strokes.Count == 0) return; + _strokes.RemoveAt(_strokes.Count - 1); + InvalidateVisual(); + StrokesChanged?.Invoke(this, EventArgs.Empty); + } + + public void Clear() + { + _strokes.Clear(); + InvalidateVisual(); + StrokesChanged?.Invoke(this, EventArgs.Empty); + } + + /// 导出仅笔迹 (透明底)。width/height 为物理像素, DIP 笔迹按 scale 放大。 + public Bitmap ExportStrokesOnly(int width, int height, double scale) + { + var rtb = new RenderTargetBitmap(new PixelSize(width, height), new Vector(96 * scale, 96 * scale)); + using (var ctx = rtb.CreateDrawingContext()) + { + using (ctx.PushTransform(Matrix.CreateScale(scale, scale))) + { + DrawAll(ctx, 1.0); + } + } + return rtb; + } + + internal void DrawAll(DrawingContext ctx, double opacity) + { + using var _ = ctx.PushOpacity(opacity); + foreach (var stroke in _strokes) + { + if (stroke.Points.Count == 1) + { + var p = stroke.Points[0]; + ctx.DrawEllipse(new SolidColorBrush(stroke.Color), null, p, stroke.Width / 2, stroke.Width / 2); + continue; + } + var geometry = new StreamGeometry(); + using (var g = geometry.Open()) + { + g.BeginFigure(stroke.Points[0], isFilled: false); + g.LineTo(stroke.Points[0]); + for (var i = 1; i < stroke.Points.Count; i++) g.LineTo(stroke.Points[i]); + g.EndFigure(false); + } + ctx.DrawGeometry(null, new Pen(new SolidColorBrush(stroke.Color), stroke.Width, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), geometry); + } + if (_current is { } cur && cur.Points.Count > 0) + { + // 进行中笔迹 + for (var i = 1; i < cur.Points.Count; i++) + { + ctx.DrawLine(new Pen(new SolidColorBrush(cur.Color), cur.Width, lineCap: PenLineCap.Round), cur.Points[i - 1], cur.Points[i]); + } + } + } + + public override void Render(DrawingContext context) => DrawAll(context, 1.0); + + // ---- 由宿主 Border (Background=Transparent, 保证可命中) 驱动的笔画 API ---- + + public void BeginStroke(Point p, IPointer pointer) + { + _current = new Stroke { Color = _color, Width = _width }; + _current.Points.Add(p); + pointer.Capture(this); + } + + public void ExtendStroke(Point p) + { + if (_current is null) return; + _current.Points.Add(p); + InvalidateVisual(); + } + + public void EndStroke() + { + if (_current is null) return; + if (_current.Points.Count > 0) _strokes.Add(_current); + _current = null; + InvalidateVisual(); + StrokesChanged?.Invoke(this, EventArgs.Empty); + } +} + +/// 批注主窗: 全屏透明置顶; 导出 StrokesOnly | WithBackground。 +public sealed class AnnotateWindow : Window +{ + private readonly InkCanvas _ink = new(); + private readonly string _dir; + private readonly string _format; + private readonly byte[]? _backgroundPng; // 打开时的全屏底图 (WithBackground 用) + + public AnnotateWindow(byte[]? backgroundPng, string dir, string format) + { + _backgroundPng = backgroundPng; + _dir = dir; + _format = format; + } + + protected override void OnOpened(EventArgs e) + { + Topmost = true; + WindowState = WindowState.FullScreen; + SystemDecorations = SystemDecorations.None; + ShowInTaskbar = false; + Background = Brushes.Transparent; + TransparencyLevelHint = []; + + var toolbar = BuildToolbar(); + var inkHost = new Border { Background = Brushes.Transparent }; // Transparent 笔刷 = 可命中 + inkHost.PointerPressed += (_, e) => + { + var p = e.GetCurrentPoint(inkHost); + if (!p.Properties.IsLeftButtonPressed) return; + _ink.BeginStroke(p.Position, e.Pointer); + e.Handled = true; // Pointer 统一: 触屏/笔/鼠标 (D1 根修) + }; + inkHost.PointerMoved += (_, e) => + { + if (e.Pointer.Captured is null) return; + _ink.ExtendStroke(e.GetCurrentPoint(inkHost).Position); + e.Handled = true; + }; + inkHost.PointerReleased += (_, e) => + { + _ink.EndStroke(); + e.Handled = true; + }; + Content = new Panel + { + Children = { inkHost, _ink, toolbar }, + }; + KeyDown += (_, args) => + { + if (args.Key == Key.Escape) Close(); + }; + base.OnOpened(e); + } + + private Control BuildToolbar() + { + Button ColorBtn(string hex, double width = 26) + { + var btn = new Button + { + Width = width, + Height = 26, + Background = new SolidColorBrush(Color.Parse(hex)), + CornerRadius = new CornerRadius(13), + Tag = hex, + }; + btn.Click += (_, _) => _ink.SetColor(Color.Parse(hex)); + return btn; + } + Button ActionBtn(string label, Action act) + { + var b = new Button { Content = label, Padding = new Thickness(10, 4) }; + b.Click += (_, _) => act(); + return b; + } + var panel = new StackPanel + { + Orientation = AloLayout.Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = AloLayout.HorizontalAlignment.Center, + VerticalAlignment = AloLayout.VerticalAlignment.Top, + Margin = new Thickness(0, 10, 0, 0), + }; + foreach (var c in new[] { "#2B6EE0", "#E53935", "#111111", "#FFFFFF", "#FBC02D" }) panel.Children.Add(ColorBtn(c)); + panel.Children.Add(ActionBtn("细", () => _ink.SetWidth(2))); + panel.Children.Add(ActionBtn("中", () => _ink.SetWidth(5))); + panel.Children.Add(ActionBtn("粗", () => _ink.SetWidth(10))); + panel.Children.Add(ActionBtn("撤销", () => _ink.Undo())); + panel.Children.Add(ActionBtn("清空", _ink.Clear)); + panel.Children.Add(ActionBtn("保存(仅笔迹)", () => Save(withBackground: false))); + panel.Children.Add(ActionBtn("保存(含背景)", () => Save(withBackground: true))); + panel.Children.Add(ActionBtn("退出", Close)); + return new Border + { + Background = new SolidColorBrush(Color.FromArgb(230, 20, 26, 34)), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 6), + Child = panel, + }; + } + + private void Save(bool withBackground) + { + try + { + var path = Path.Combine(_dir, $"annotate_{DateTime.Now:yyyyMMdd_HHmmss}.png"); + Directory.CreateDirectory(_dir); + var scale = Win32Display.GetScaling(TryGetPlatformHandle()?.Handle ?? 0); + var w = (int)(Bounds.Width * scale); + var h = (int)(Bounds.Height * scale); + + if (withBackground && _backgroundPng is not null) + { + using var bg = new Bitmap(new MemoryStream(_backgroundPng)); + using var strokes = _ink.ExportStrokesOnly(w, h, scale); + using var composite = new RenderTargetBitmap(new PixelSize(w, h), new Vector(96 * scale, 96 * scale)); + using (var ctx = composite.CreateDrawingContext()) + { + ctx.DrawImage(bg, new Rect(0, 0, w, h)); + ctx.DrawImage(strokes, new Rect(0, 0, w, h)); + } + composite.Save(path); + } + else + { + using var strokes = _ink.ExportStrokesOnly(w, h, scale); + strokes.Save(path); + } + ToastService.Instance.Show($"批注已保存: {path}", "success"); + Close(); + } + catch (Exception ex) + { + ToastService.Instance.Show($"批注保存失败: {ex.Message}", "error"); + } + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/DockWindow.cs b/src/SmartSideBAR.Avalonia/Ui/DockWindow.cs new file mode 100644 index 0000000..6ab1280 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/DockWindow.cs @@ -0,0 +1,57 @@ +// v1.1 dock 收起形态: 工作区底角 52×52 玻璃小方块 (独立常驻窗口 —— +// 勘误: 对透明窗口做外部 MoveWindow 缩放后 Avalonia 不再出帧, 故 dock 不复用侧栏窗口)。 +// 点击 → 展开侧栏 (undock)。AppBar 在 dock 期间注销 (WorkArea 完整归还, v1.1 同语义)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using SmartSideBAR.Avalonia.Views; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class DockWindow : Window +{ + private readonly Action _undock; + + public DockWindow(Action undock) + { + _undock = undock; + Width = 52; + Height = 52; + SystemDecorations = SystemDecorations.None; + ShowInTaskbar = false; + ShowActivated = false; + Background = Brushes.Transparent; + TransparencyLevelHint = [ ]; + + var btn = new Button + { + Tag = "svg:sidebar", + Width = 44, + Height = 44, + CornerRadius = new CornerRadius(10), + Background = Brushes.Transparent, + Padding = new Thickness(0), + HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Center, + }; + btn.PointerReleased += (_, _) => _undock(); + var root = new Border + { + CornerRadius = new CornerRadius(12), + BoxShadow = BoxShadows.Parse("0 6 20 #26000000"), + Child = btn, + }; + root.Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0.3, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0.7, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Color.Parse("#8AFAFCFF"), 0), + new GradientStop(Color.Parse("#7AFFFFFF"), 1), + }, + }; + Content = root; + Opened += (_, _) => SidebarWindow.LoadSvgIcons(this); + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs b/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs new file mode 100644 index 0000000..41d8acc --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs @@ -0,0 +1,211 @@ +// 悬浮球 (§6.14, 对齐 v1.1): floatball-icon 图元圆球 + 拖拽吸附 + 点击展开玻璃扇形菜单 +// (白色 SVG 图标) + 双击开合侧栏。布局算法 = Core.FloatBallLayout (v1.1 逐函数移植)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.FloatBall; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class FloatBallWindow : Window +{ + private readonly string[] _actions; + private readonly Action _dispatch; + private readonly Action _toggleSidebar; + private readonly int _size; + private readonly int _snapThreshold; + + private readonly Border _orb; + private readonly Canvas _root = new(); + private RectLike _workPx; // 工作区 (物理像素) + private bool _dragging; + private bool _moved; + private bool _expanded; + private Point _pressPos; + private PixelPoint _pressWindowPos; + private DateTime _lastTap = DateTime.MinValue; + + public FloatBallWindow(FloatBallConfig cfg, RectLike workPx, Action dispatch, Action toggleSidebar) + { + _actions = [.. cfg.Actions]; + _dispatch = dispatch; + _toggleSidebar = toggleSidebar; + _size = cfg.Size; + _snapThreshold = cfg.SnapThreshold; + _workPx = workPx; + + Width = _size; + Height = _size; + SystemDecorations = SystemDecorations.None; + ShowInTaskbar = false; + Topmost = true; + Background = Brushes.Transparent; + TransparencyLevelHint = []; + ShowActivated = false; + + _orb = BuildOrb(); + _root.Children.Add(_orb); + Content = _root; + + var pos = FloatBallLayout.ResolveBallPosition((cfg.X, cfg.Y), + new RectLike(0, 0, workPx.Width, workPx.Height), _size, "right"); + Position = new PixelPoint(pos.X, pos.Y); + + PointerPressed += OnPress; + PointerMoved += OnMove; + PointerReleased += OnRelease; + DoubleTapped += (_, _) => + { + _toggleSidebar(); + if (_expanded) CollapseFan(); // 双击的第一击可能已展开, 一并收起 + }; + } + + private Border BuildOrb() + { + var orb = new Border + { + Width = _size, + Height = _size, + CornerRadius = new CornerRadius(_size / 2.0), + BoxShadow = BoxShadows.Parse("0 6 18 #40000000"), + Child = new Image + { + Source = new global::Avalonia.Media.Imaging.Bitmap( + global::Avalonia.Platform.AssetLoader.Open( + new Uri("avares://SmartSideBAR.Avalonia/Assets/floatball-icon.png"))), + Stretch = Stretch.UniformToFill, + }, + ClipToBounds = true, + }; + orb.CornerRadius = new CornerRadius(_size / 2.0); + return orb; + } + + // ---- 拖拽 / 点击 ---- + + private void OnPress(object? s, PointerPressedEventArgs e) + { + var p = e.GetCurrentPoint(this); + if (!p.Properties.IsLeftButtonPressed) return; + _dragging = true; + _moved = false; + _pressPos = p.Position; + _pressWindowPos = Position; + e.Pointer.Capture(this); + } + + private void OnMove(object? s, PointerEventArgs e) + { + if (!_dragging) return; + var p = e.GetCurrentPoint(this).Position; + var dx = (int)(p.X - _pressPos.X); + var dy = (int)(p.Y - _pressPos.Y); + if (!_moved && Math.Abs(dx) + Math.Abs(dy) > 6) _moved = true; + if (_moved && !_expanded) + { + Position = new PixelPoint(_pressWindowPos.X + dx, _pressWindowPos.Y + dy); + } + } + + private void OnRelease(object? s, PointerReleasedEventArgs e) + { + if (!_dragging) return; + _dragging = false; + if (_moved && !_expanded) + { + // 吸附 (v1.1 SnapToEdges 语义, 物理像素) + var snapped = FloatBallLayout.SnapToEdges( + (Position.X, Position.Y), (_size, _size), _workPx, _snapThreshold); + Position = new PixelPoint(snapped.X, snapped.Y); + return; + } + // 双击的第一击: 250ms 内第二击由 DoubleTapped 处理 + var sinceLast = DateTime.UtcNow - _lastTap; + _lastTap = DateTime.UtcNow; + if (sinceLast.TotalMilliseconds < 260) return; + ToggleFan(); + } + + // ---- 扇形菜单 ---- + + private void ToggleFan() { if (_expanded) CollapseFan(); else ExpandFan(); } + + private void ExpandFan() + { + var count = Math.Max(1, _actions.Length); + var radius = FloatBallLayout.FanRadius(count); + var panel = radius * 2 + 72; + var (ballX, ballY) = (Position.X, Position.Y); + var centerX = ballX + _size / 2; + var centerY = ballY + _size / 2; + + // 方向: 远离最近边缘 (v1.1 computeFanLayout dir 语义) + var dirX = centerX - _workPx.X >= _workPx.X + _workPx.Width - centerX ? -1 : 1; + var dirY = centerY - _workPx.Y >= _workPx.Y + _workPx.Height - centerY ? -1 : 1; + + + Width = panel; + Height = panel; + var winX = Math.Clamp(centerX - panel / 2, _workPx.X, _workPx.X + _workPx.Width - panel); + var winY = Math.Clamp(centerY - panel / 2, _workPx.Y, _workPx.Y + _workPx.Height - panel); + Position = new PixelPoint(winX, winY); + + _root.Children.Clear(); + _root.Children.Add(_orb); + Canvas.SetLeft(_orb, panel / 2.0 - _size / 2.0); + Canvas.SetTop(_orb, panel / 2.0 - _size / 2.0); + + for (var i = 0; i < count; i++) + { + var action = _actions[i]; + var (ox, oy) = FloatBallLayout.FanItemOffset(i, count, radius, (dirX, dirY)); + var item = new Button + { + Width = 46, + Height = 46, + Tag = "svgw:" + action, + CornerRadius = new CornerRadius(23), + Background = new SolidColorBrush(Color.FromArgb(215, 24, 32, 44)), + Padding = new Thickness(0), + HorizontalContentAlignment = global::Avalonia.Layout.HorizontalAlignment.Center, + VerticalContentAlignment = global::Avalonia.Layout.VerticalAlignment.Center, + + }; + item.Click += (_, _) => + { + CollapseFan(); + _dispatch(action); + }; + Canvas.SetLeft(item, panel / 2.0 + ox - 23); + Canvas.SetTop(item, panel / 2.0 + oy - 23); + _root.Children.Add(item); + } + _expanded = true; + Views.SidebarWindow.LoadSvgIcons(this); + } + + private void CollapseFan() + { + _expanded = false; + Width = _size; + Height = _size; + _root.Children.Clear(); + _root.Children.Add(_orb); + } + + private static string ActionTitle(string action) => action switch + { + "capture" => "区域截图", + "annotate" => "屏幕批注", + "longshot" => "长截图", + "record" => "录屏", + "ime" => "输入法", + "taskmgr" => "任务管理器", + "sidebar" => "侧边栏", + "settings" => "设置", + _ => action, + }; +} diff --git a/src/SmartSideBAR.Avalonia/Ui/OobeWindow.cs b/src/SmartSideBAR.Avalonia/Ui/OobeWindow.cs new file mode 100644 index 0000000..7c998c6 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/OobeWindow.cs @@ -0,0 +1,169 @@ +// OOBE 向导 (§6.11): 6 步流程 + 角色裁剪 + 环境真实探测 (P1-12/P2-3 语义); OobeState 字段级合并 (E2 根修)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class OobeWindow : Window +{ + private readonly ConfigService _config; + private readonly IImeService _ime; + private readonly IPrinterMonitor _printer; + private int _step; + + private readonly StackPanel _body = new() { Spacing = 12 }; + private readonly TextBlock _title = new() { FontSize = 22, FontWeight = FontWeight.Bold }; + private readonly TextBlock _envText = new() { TextWrapping = TextWrapping.Wrap, FontSize = 13 }; + private readonly CheckBox _prefIme = new() { Content = "输入法切换", IsChecked = true }; + private readonly CheckBox _prefUsb = new() { Content = "U 盘监控", IsChecked = true }; + private readonly CheckBox _prefShot = new() { Content = "截图/批注", IsChecked = true }; + private readonly CheckBox _prefRecorder = new() { Content = "录屏", IsChecked = false }; + private readonly CheckBox _prefPrinter = new() { Content = "打印机状态", IsChecked = true }; + private string _role = "teacher"; + + public OobeWindow(ConfigService config, IImeService ime, IPrinterMonitor printer) + { + _config = config; + _ime = ime; + _printer = printer; + Width = 560; + Height = 480; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + Title = "SmartSideBAR 初始化向导"; + RenderStep(); + } + + private void RenderStep() + { + _body.Children.Clear(); + var next = new Button { Content = _step >= 5 ? "完成" : "下一步", Padding = new Thickness(20, 8), HorizontalAlignment = HorizontalAlignment.Right }; + next.Click += (_, _) => Advance(); + var back = new Button { Content = "上一步", Padding = new Thickness(20, 8) }; + back.Click += (_, _) => { if (_step > 0) { _step--; RenderStep(); } }; + var skip = new Button { Content = "跳过向导" }; + skip.Click += (_, _) => Complete(skipped: true); + + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, HorizontalAlignment = HorizontalAlignment.Right }; + if (_step > 0) buttons.Children.Add(back); + buttons.Children.Add(next); + + _body.Children.Add(new TextBlock { Text = $"第 {_step + 1} / 6 步", Foreground = Brushes.Gray, FontSize = 12 }); + switch (_step) + { + case 0: + _title.Text = "欢迎使用 SmartSideBAR"; + _body.Children.Add(_title); + _body.Children.Add(new TextBlock + { + Text = "教学大屏常驻助手: 侧栏快捷操作 · 输入法切换 · U 盘提醒 · 截图批注 · 录屏。\n\n侧栏将停靠屏幕右缘, 任何窗口最大化都不会遮挡它。", + TextWrapping = TextWrapping.Wrap, + }); + break; + case 1: + _title.Text = "你的角色"; + _body.Children.Add(_title); + var teacher = new RadioButton { Content = "教师 —— 常用教学功能 (推荐)", GroupName = "role", IsChecked = _role == "teacher" }; + var admin = new RadioButton { Content = "管理员 —— 全部功能 + 策略下发", GroupName = "role", IsChecked = _role == "admin" }; + teacher.IsCheckedChanged += (_, _) => _role = "teacher"; + admin.IsCheckedChanged += (_, _) => _role = "admin"; + _body.Children.Add(teacher); + _body.Children.Add(admin); + break; + case 2: + _title.Text = "选择要启用的功能"; + _body.Children.Add(_title); + _body.Children.Add(_prefIme); + _body.Children.Add(_prefUsb); + _body.Children.Add(_prefShot); + _body.Children.Add(_prefRecorder); + _body.Children.Add(_prefPrinter); + break; + case 3: + _title.Text = "环境检测"; + _body.Children.Add(_title); + _envText.Text = "检测中…"; + _body.Children.Add(_envText); + Task.Run(() => + { + var printers = _printer.Query(); + var imeState = _ime.GetState(); + global::Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var screen = Screens.Primary?.Bounds ?? new PixelRect(0, 0, 1920, 1080); + _envText.Text = $"屏幕: {screen.Width}x{screen.Height} (DIP)\n" + + $"输入法: {imeState.Locale} (中文={imeState.IsChinese})\n" + + $"打印机: {printers.Count} 台\n" + + $"录屏: {(global::Windows.Graphics.Capture.GraphicsCaptureSession.IsSupported() ? "可用" : "不可用")}"; + }); + }); + break; + case 4: + _title.Text = "快捷键"; + _body.Children.Add(_title); + _body.Children.Add(new TextBlock + { + Text = "Ctrl+Shift+A 区域截图\nCtrl+Shift+D 屏幕批注\nCtrl+Shift+L 长截图\nAlt+Q 悬浮球\n\n可在设置的快捷键页修改; 与其他软件冲突时系统会明确告知并给出替代建议。", + TextWrapping = TextWrapping.Wrap, + }); + break; + case 5: + _title.Text = "一切就绪"; + _body.Children.Add(_title); + _body.Children.Add(new TextBlock + { + Text = "侧栏已停靠屏幕右缘; 悬浮球可在屏幕边缘拖拽。托盘图标提供设置与退出入口。", + TextWrapping = TextWrapping.Wrap, + }); + break; + } + _body.Children.Add(buttons); + + var outer = new ScrollViewer { Content = new StackPanel { Margin = new Thickness(24), Children = { _body, skip } } }; + Content = outer; + } + + private void Advance() + { + if (_step >= 5) { Complete(skipped: false); return; } + _step++; + RenderStep(); + } + + /// E2 根修: OobeState 字段级合并 —— 仅写本次向导涉及的字段, 保留其他 (record with)。 + private void Complete(bool skipped) + { + var current = _config.Current.Oobe; + var prefs = new OobePrefs + { + Ime = _prefIme.IsChecked == true, + Usb = _prefUsb.IsChecked == true, + Shot = _prefShot.IsChecked == true, + Recorder = _prefRecorder.IsChecked == true, + Printer = _prefPrinter.IsChecked == true, + }; + var state = current with + { + Completed = !skipped, + CompletedAt = DateTime.Now.ToString("o"), + Skipped = skipped, + Role = _role, + LastStepIndex = _step, + Prefs = prefs, + }; + _ = _config.Set("oobe", state); + + // 角色裁剪 → disabledModules (P2-3 语义) + if (!skipped && _role == "teacher") + { + var disabled = new List(); + if (prefs.Recorder) { /* keep recorder on */ } else disabled.Add("recorder"); + _ = _config.Set("policy.disabledModules", disabled); + } + Close(); + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/OverlayWindow.cs b/src/SmartSideBAR.Avalonia/Ui/OverlayWindow.cs new file mode 100644 index 0000000..703145e --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/OverlayWindow.cs @@ -0,0 +1,136 @@ +// 区域截图覆盖层 (§6.2): 全屏透明置顶 → 拖拽选框 (Pointer 统一鼠标/触屏) → BitBlt 物理裁剪。 +// Esc 取消 / Enter 或松手确认; 坐标 DIP↔物理仅在此边界换算一次 (K4)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Capture; +using SmartSideBAR.Windows.Native; +using AloLayout = global::Avalonia.Layout; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class OverlayWindow : Window +{ + private readonly ICaptureApi _capture; + private readonly string _dir; + private readonly string _format; + private Point _start; + private Border? _selection; + private bool _dragging; + private bool _done; + + public OverlayWindow(ICaptureApi capture, string dir, string format) + { + _capture = capture; + _dir = dir; + _format = format; + } + + protected override void OnOpened(EventArgs e) + { + Topmost = true; + WindowState = WindowState.FullScreen; + SystemDecorations = SystemDecorations.None; + ShowInTaskbar = false; + + _selection = new Border + { + BorderBrush = new SolidColorBrush(Color.Parse("#2B6EE0")), + BorderThickness = new Thickness(2), + Background = new SolidColorBrush(Color.FromArgb(40, 43, 110, 224)), + IsVisible = false, + }; + var canvas = new Canvas { Children = { _selection } }; + var hint = new TextBlock + { + Text = "拖拽选择截图区域 · Enter 确认 · Esc 取消", + Foreground = Brushes.White, + FontSize = 15, + HorizontalAlignment = AloLayout.HorizontalAlignment.Center, + VerticalAlignment = AloLayout.VerticalAlignment.Top, + Margin = new Thickness(0, 24, 0, 0), + }; + Content = new Panel + { + Background = new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)), + Children = { canvas, hint }, + }; + KeyDown += (_, args) => + { + if (args.Key == Key.Escape) Close(); + else if (args.Key == Key.Enter) FinishSelection(); + }; + base.OnOpened(e); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + var p = e.GetCurrentPoint(this); + if (!p.Properties.IsLeftButtonPressed) return; + _dragging = true; + _start = p.Position; + _selection!.IsVisible = true; + Canvas.SetLeft(_selection!, _start.X); + Canvas.SetTop(_selection!, _start.Y); + _selection!.Width = 0; + _selection!.Height = 0; + e.Pointer.Capture(this); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (!_dragging) return; + var p = e.GetCurrentPoint(this).Position; + var x = Math.Min(_start.X, p.X); + var y = Math.Min(_start.Y, p.Y); + _selection!.Width = Math.Abs(p.X - _start.X); + _selection!.Height = Math.Abs(p.Y - _start.Y); + Canvas.SetLeft(_selection!, x); + Canvas.SetTop(_selection!, y); + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (!_dragging) return; + _dragging = false; + FinishSelection(); + e.Handled = true; + } + + /// 选框 → 物理像素 → BitBlt 裁剪 → 落盘 → 通知。 + private void FinishSelection() + { + if (_done || _selection is not { } sel || sel.Width < 8 || sel.Height < 8) + { + Close(); + return; + } + _done = true; + var left = Canvas.GetLeft(sel); + var top = Canvas.GetTop(sel); + var scaling = Win32Display.GetScaling(TryGetPlatformHandle()?.Handle ?? 0); + var region = new AppBarRect( + (int)Math.Round(left * scaling), + (int)Math.Round(top * scaling), + (int)Math.Round(sel.Width * scaling), + (int)Math.Round(sel.Height * scaling)); + Close(); + + Task.Run(() => + { + var bmp = _capture.CaptureRegion(region); + if (bmp is null) + { + ToastService.Instance.Show("截图失败", "error"); + return; + } + var path = _capture.SaveImage(bmp, _dir, _format); + bmp.Dispose(); + ToastService.Instance.Show($"截图已保存: {path}", "success"); + }); + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/SettingsWindow.cs b/src/SmartSideBAR.Avalonia/Ui/SettingsWindow.cs new file mode 100644 index 0000000..e228106 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/SettingsWindow.cs @@ -0,0 +1,152 @@ +// 设置窗 (§6.11): 唯一表单语义 (P1-6) —— 保存经 ConfigService.Set 白名单路径, 自动广播 ConfigChanged。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Shell; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class SettingsWindow : Window +{ + private readonly ConfigService _config; + private readonly IShellService _shell; + private readonly IDiagnosticsService _diag; + + private readonly ComboBox _side = new() { ItemsSource = new[] { "left", "right" }, SelectedIndex = 1 }; + private readonly CheckBox _autoLaunch = new() { Content = "开机自启动" }; + private readonly ComboBox _format = new() { ItemsSource = new[] { "PNG", "JPG" }, SelectedIndex = 0 }; + private readonly TextBox _captureDir = new() { Watermark = "{Pictures}/Sidekick" }; + private readonly TextBox _recorderDir = new() { Watermark = "{Videos}/Sidekick" }; + private readonly NumericUpDown _fps = new() { Value = 15, Minimum = 5, Maximum = 60 }; + private readonly CheckBox _mic = new() { Content = "录制系统麦克风 (Wave D)" }; + private readonly CheckBox _usbEnabled = new() { Content = "启用 U 盘监控" }; + private readonly NumericUpDown _printerSec = new() { Value = 10, Minimum = 3, Maximum = 300 }; + private readonly TextBox _hkCapture = new(); + private readonly TextBox _hkAnnotate = new(); + private readonly TextBox _hkLongshot = new(); + private readonly TextBox _hkFloatBall = new(); + private readonly TextBox _hkIme = new(); + private readonly TextBlock _diagResult = new() { TextWrapping = TextWrapping.Wrap, FontSize = 12 }; + + public SettingsWindow(ConfigService config, IShellService shell, HotkeyService hotkeys, IDiagnosticsService diag) + { + _config = config; + _shell = shell; + _diag = diag; + Title = "SmartSideBAR 设置"; + Width = 560; + Height = 640; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + LoadFrom(config.Current); + Content = BuildUi(); + } + + private void LoadFrom(SidekickConfig c) + { + _side.SelectedIndex = c.Display.SidebarSide == SidebarSide.Left ? 0 : 1; + _autoLaunch.IsChecked = _shell.GetAutoLaunch(); + _format.SelectedIndex = c.Capture.Format.Equals("jpg", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + _captureDir.Text = c.Capture.Dir; + _recorderDir.Text = c.Recorder.Dir; + _fps.Value = c.Recorder.Fps; + _mic.IsChecked = c.Recorder.Mic; + _usbEnabled.IsChecked = c.Usb.Enabled; + _printerSec.Value = c.Printer.PollIntervalSec; + _hkCapture.Text = c.Capture.Hotkey; + _hkAnnotate.Text = c.Capture.AnnotateHotkey; + _hkLongshot.Text = c.Capture.LongshotHotkey; + _hkFloatBall.Text = c.FloatBall.Hotkey; + _hkIme.Text = "Ctrl+Shift+I"; + } + + private Control BuildUi() + { + static StackPanel RowOf(string label, Control control) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Margin = new Thickness(0, 4) }; + sp.Children.Add(new TextBlock { Text = label, Width = 120, VerticalAlignment = VerticalAlignment.Center }); + sp.Children.Add(control); + return sp; + } + + var save = new Button { Content = "保存", Padding = new Thickness(24, 8), HorizontalAlignment = HorizontalAlignment.Right }; + save.Click += (_, _) => Save(); + + var runDiag = new Button { Content = "运行诊断" }; + runDiag.Click += (_, _) => + { + _diagResult.Text = "巡检中…"; + Task.Run(() => _diag.RunFullCheck()).ContinueWith(t => + { + global::Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _diagResult.Text = string.Join('\n', t.Result.Select(kv => $"{kv.Key}: {kv.Value}")); + }); + }); + }; + var exportDiag = new Button { Content = "导出诊断包", Margin = new Thickness(8, 0, 0, 0) }; + exportDiag.Click += (_, _) => + { + var path = _diag.ExportPack(Path.GetTempPath()); + ToastService.Instance.Show($"诊断包: {path}", "success"); + }; + + var scroll = new ScrollViewer + { + Margin = new Thickness(16), + Content = new StackPanel { Spacing = 6 }, + }; + var root = (StackPanel)scroll.Content!; + root.Children.Add(new TextBlock { Text = "常规", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 8, 0, 4) }); + root.Children.Add(RowOf("侧栏位置", _side)); + root.Children.Add(RowOf("", _autoLaunch)); + root.Children.Add(new TextBlock { Text = "截图", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 12, 0, 4) }); + root.Children.Add(RowOf("格式", _format)); + root.Children.Add(RowOf("保存目录", _captureDir)); + root.Children.Add(new TextBlock { Text = "录屏 (MP4)", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 12, 0, 4) }); + root.Children.Add(RowOf("帧率", _fps)); + root.Children.Add(RowOf("", _mic)); + root.Children.Add(RowOf("保存目录", _recorderDir)); + root.Children.Add(new TextBlock { Text = "设备", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 12, 0, 4) }); + root.Children.Add(RowOf("", _usbEnabled)); + root.Children.Add(RowOf("打印机轮询(秒)", _printerSec)); + root.Children.Add(new TextBlock { Text = "快捷键 (Ctrl/Alt/Shift/Win + 键)", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 12, 0, 4) }); + root.Children.Add(RowOf("区域截图", _hkCapture)); + root.Children.Add(RowOf("屏幕批注", _hkAnnotate)); + root.Children.Add(RowOf("长截图", _hkLongshot)); + root.Children.Add(RowOf("悬浮球", _hkFloatBall)); + root.Children.Add(RowOf("输入法", _hkIme)); + root.Children.Add(new TextBlock { Text = "诊断", FontWeight = FontWeight.Bold, FontSize = 15, Margin = new Thickness(0, 12, 0, 4) }); + var diagRow = new StackPanel { Orientation = Orientation.Horizontal }; + diagRow.Children.Add(runDiag); + diagRow.Children.Add(exportDiag); + root.Children.Add(diagRow); + root.Children.Add(_diagResult); + root.Children.Add(save); + + return scroll; + } + + private void Save() + { + var c = _config.Current; + _ = _config.Set("display.sidebarSide", _side.SelectedIndex == 0 ? "left" : "right"); + _ = _config.Set("capture.format", (string?)_format.SelectedItem ?? "PNG"); + _ = _config.Set("capture.dir", string.IsNullOrWhiteSpace(_captureDir.Text) ? c.Capture.Dir : _captureDir.Text.Trim()); + _ = _config.Set("recorder.dir", string.IsNullOrWhiteSpace(_recorderDir.Text) ? c.Recorder.Dir : _recorderDir.Text.Trim()); + _ = _config.Set("recorder.fps", (int)(_fps.Value ?? 15)); + _ = _config.Set("usb.enabled", _usbEnabled.IsChecked == true); + _ = _config.Set("printer.pollIntervalSec", (int)(_printerSec.Value ?? 10)); + _ = _config.Set("capture.hotkey", _hkCapture.Text?.Trim()); + _ = _config.Set("capture.annotateHotkey", _hkAnnotate.Text?.Trim()); + _ = _config.Set("capture.longshotHotkey", _hkLongshot.Text?.Trim()); + _ = _config.Set("floatBall.hotkey", _hkFloatBall.Text?.Trim()); + _shell.SetAutoLaunch(_autoLaunch.IsChecked == true); + ToastService.Instance.Show("设置已保存", "success"); + Close(); + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/ToastService.cs b/src/SmartSideBAR.Avalonia/Ui/ToastService.cs new file mode 100644 index 0000000..e6a748d --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/ToastService.cs @@ -0,0 +1,77 @@ +// 轻量进程内 Toast (右下角, 3.5s 自动消失) —— 替代 Electron renderer 通知。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class ToastService +{ + public static ToastService Instance { get; } = new(); + + public void Show(string message, string tone = "info") + { + Dispatcher.UIThread.Post(() => + { + try + { + var color = tone switch + { + "success" => "#2E7D32", + "warn" => "#B26A00", + "error" => "#C62828", + _ => "#243041", + }; + var text = new TextBlock + { + Text = message, + Foreground = Brushes.White, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 360, + FontSize = 13, + }; + var border = new Border + { + Background = new SolidColorBrush(Color.Parse(color)), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 10), + Child = text, + Opacity = 0.96, + }; + var win = new Window + { + SystemDecorations = SystemDecorations.None, + ShowInTaskbar = false, + TransparencyLevelHint = [], + Background = Brushes.Transparent, + SizeToContent = SizeToContent.WidthAndHeight, + ShowActivated = false, + Topmost = true, + Content = border, + }; + win.Show(); + // 右下角 (Show 后取屏幕信息; DIP 边界 + 物理像素位置的混合由 Avalonia 折算) + try + { + var screen = win.Screens.Primary ?? win.Screens.All[0]; + win.Position = new PixelPoint( + screen.Bounds.Right - 420, + screen.Bounds.Bottom - 140); + } + catch { /* 定位尽力而为 */ } + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(3500) }; + timer.Tick += (_, _) => + { + timer.Stop(); + win.Close(); + }; + timer.Start(); + } + catch + { + // 通知失败不影响业务 + } + }); + } +} diff --git a/src/SmartSideBAR.Avalonia/Ui/WindowPickerWindow.cs b/src/SmartSideBAR.Avalonia/Ui/WindowPickerWindow.cs new file mode 100644 index 0000000..ffa62f4 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/WindowPickerWindow.cs @@ -0,0 +1,49 @@ +// 长截图目标窗口选择器 (longshot:selectWindow 落点)。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using SmartSideBAR.Windows.Capture; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class WindowPickerWindow(IReadOnlyList targets) : Window +{ + public LongshotTarget? Selected { get; private set; } + + public WindowPickerWindow() : this([]) { } + + protected override void OnOpened(EventArgs e) + { + Title = "选择要长截图的窗口"; + Width = 460; + Height = 520; + Topmost = true; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + var list = new ListBox { Margin = new Thickness(12) }; + foreach (var t in targets) list.Items.Add(new TextBlock { Text = t.Title, Margin = new Thickness(4, 2) }); + + var start = new Button + { + Content = "开始长截图 (3 秒倒计时)", + Padding = new Thickness(16, 8), + HorizontalAlignment = HorizontalAlignment.Center, + }; + start.Click += (_, _) => + { + if (list.SelectedIndex >= 0) + { + Selected = targets[list.SelectedIndex]; + Close(); + } + }; + Content = new DockPanel + { + Margin = new Thickness(8), + Children = { start, list }, + }; + DockPanel.SetDock(start, Dock.Bottom); + base.OnOpened(e); + } +} diff --git a/src/SmartSideBAR.Avalonia/ViewModels/RelayCommand.cs b/src/SmartSideBAR.Avalonia/ViewModels/RelayCommand.cs new file mode 100644 index 0000000..9ff18e1 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/ViewModels/RelayCommand.cs @@ -0,0 +1,18 @@ +// 轻量命令 (无第三方依赖; Wave B 如引入 CommunityToolkit 可替换) +using System.Collections; +using System.Windows.Input; + +namespace SmartSideBAR.Avalonia.ViewModels; + +public sealed class RelayCommand(Action execute) : ICommand +{ + public event EventHandler? CanExecuteChanged + { + add { } + remove { } + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => execute(parameter); +} diff --git a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs new file mode 100644 index 0000000..93c7539 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs @@ -0,0 +1,131 @@ +// SidebarViewModel —— 侧栏 rail/面板: AppBar 切换 + IME + 任务管理器 + 链接。 +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Input; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Shell; + +namespace SmartSideBAR.Avalonia.ViewModels; + +public sealed class SidebarViewModel : INotifyPropertyChanged +{ + private readonly AppBarService _appBar; + private readonly IImeService _ime; + private readonly IShellService _shell; + private bool _panelOpen; + private string _imeState = "IME"; + + public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService shell, IReadOnlyList links, Action dispatch) + { + _appBar = appBar; + _ime = ime; + _shell = shell; + Dispatch = dispatch; + Links = [.. links.Where(l => l.Enabled)]; + TogglePanelCommand = new RelayCommand(_ => TogglePanel()); + OpenLinkCommand = new RelayCommand(p => + { + if (p is LinkItem link) OpenLink(link); + }); + ToggleImeCommand = new RelayCommand(_ => + { + _ = _ime.Toggle(); + RefreshIme(); + }); + OpenTaskMgrCommand = new RelayCommand(_ => _shell.OpenTaskManager()); + CaptureCommand = new RelayCommand(_ => Dispatch("capture")); + AnnotateCommand = new RelayCommand(_ => Dispatch("annotate")); + RecordCommand = new RelayCommand(_ => Dispatch("record")); + LongshotCommand = new RelayCommand(_ => Dispatch("longshot")); + UsbCommand = new RelayCommand(_ => Dispatch("usb")); + PrinterCommand = new RelayCommand(_ => Dispatch("printer")); + SettingsCommand = new RelayCommand(_ => Dispatch("settings")); + ReminderCommand = new RelayCommand(_ => Dispatch("reminder")); + DockCommand = new RelayCommand(_ => Dispatch("dock")); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public bool PanelOpen + { + get => _panelOpen; + set + { + if (_panelOpen == value) return; + _panelOpen = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(PanelOpen))); + } + } + + private bool _isDocked; + + /// v1.1 语义: docked = 收起为右下角 52×52 小方块。 + public bool IsDocked + { + get => _isDocked; + set + { + if (_isDocked == value) return; + _isDocked = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDocked))); + if (value) PanelOpen = false; + } + } + + public string ImeState + { + get => _imeState; + private set + { + if (_imeState == value) return; + _imeState = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImeState))); + } + } + + public ObservableCollection Links { get; } + + public string AppBarState => _appBar.IsRegistered ? "AppBar 已注册" : "Topmost 回退"; + + public Action Dispatch { get; } + public ICommand TogglePanelCommand { get; } + public ICommand OpenLinkCommand { get; } + public ICommand ToggleImeCommand { get; } + public ICommand OpenTaskMgrCommand { get; } + public ICommand CaptureCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand AnnotateCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand RecordCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand LongshotCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand UsbCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand PrinterCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand SettingsCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand ReminderCommand { get; private set; } = new RelayCommand(_ => { }); + public ICommand DockCommand { get; private set; } = new RelayCommand(_ => { }); + + public void RefreshIme() + { + var s = _ime.GetState(); + ImeState = s.IsChinese ? "中" : "EN"; + } + + /// 展开/收起: 窗口宽度变化经 AppBarService 与系统协商 (物理像素)。 + public void TogglePanel() + { + PanelOpen = !PanelOpen; + _appBar.SetExpanded(PanelOpen); + } + + public void OpenLink(LinkItem link) + { + try + { + _shell.OpenExternal(new Uri(link.Url)); + } + catch (Exception) + { + // 白名单外域名拒绝 (B2); UI 提示于 Wave C 通知系统 + } + } +} diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml new file mode 100644 index 0000000..3a38395 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs new file mode 100644 index 0000000..197a046 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs @@ -0,0 +1,57 @@ +using Avalonia.Controls; +using AvaloniaVisual = global::Avalonia.Visual; +using Avalonia.VisualTree; +using SmartSideBAR.Avalonia.ViewModels; +using SmartSideBAR.Windows.AppBar; + +namespace SmartSideBAR.Avalonia.Views; + +public partial class SidebarWindow : Window +{ + private readonly AppBarEdge _side; + + // 供运行时 XAML 加载器可达; 应用路径一律走带 edge 的构造 + public SidebarWindow() : this(AppBarEdge.Right) { } + + public SidebarWindow(AppBarEdge side) + { + _side = side; + InitializeComponent(); + } + + /// 系统最近授予的总宽度 (物理像素, 含 rail)。 + public int LastGrantedWidthPx { get; private set; } + + public void OnAppBarGeometry(int grantedWidthPx) + { + LastGrantedWidthPx = grantedWidthPx; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + LoadSvgIcons(this); + if (DataContext is ViewModels.SidebarViewModel vm) vm.RefreshIme(); + } + + /// Tag="svg:名称" → Assets/icons/名称.svg (1.1 图标, 深色); "svgw:" → 白色变体。 + internal static void LoadSvgIcons(AvaloniaVisual root) + { + foreach (var image in root.GetVisualDescendants().OfType()) + { + if (image.Tag is not string tag) continue; + var (prefix, dir) = tag.StartsWith("svgw:") ? ("svgw:", "icons-white") : ("svg:", "icons"); + if (!tag.StartsWith(prefix)) continue; + var name = tag[prefix.Length..]; + try + { + var uri = new Uri($"avares://SmartSideBAR.Avalonia/Assets/{dir}/{name}.svg"); + image.Source = new global::Avalonia.Svg.Skia.SvgImage { Source = new global::Avalonia.Svg.Skia.SvgSource(uri) }; + } + catch + { + // 图标缺失不阻断 UI + } + } + } +} diff --git a/src/SmartSideBAR.Avalonia/WindowManager.cs b/src/SmartSideBAR.Avalonia/WindowManager.cs new file mode 100644 index 0000000..4d9d8ac --- /dev/null +++ b/src/SmartSideBAR.Avalonia/WindowManager.cs @@ -0,0 +1,375 @@ +// 窗口集中管理 (方案 §4.1): 侧栏 AppBar 轨窗口的创建、常驻服务挂接与全部动作路由。 +// 附录 A 的 window:*/floatball:*/capture:* 等 invoke 通道在本进程内直达类型安全服务。 +using Avalonia; +using Avalonia.Controls; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Avalonia.Ui; +using SmartSideBAR.Avalonia.Views; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Links; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Capture; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Recording; +using SmartSideBAR.Windows.Shell; + +namespace SmartSideBAR.Avalonia; + +public sealed class WindowManager( + ConfigService config, + LinksService links, + AppBarService appBar, + HotkeyService hotkeys, + IImeService ime, + IUsbWatcher usb, + IPrinterMonitor printer, + IShellService shell, + ICaptureApi capture, + ILongshotService longshot, + IRecorderApi recorder, + IDiagnosticsService diagnostics, + IEventBus bus, + ILogger log) +{ + // v1.1 设计基准 (DIP): rail 52 + 面板 380, dock 收起方块 52×52; 物理像素 = DIP × 窗口 DPI 缩放 + public const double RailWidthDip = 52; + public const double PanelWidthDip = 380; + public const double DockSizeDip = 52; + + private SidebarWindow? _sidebar; + private DockWindow? _dockWindow; + private FloatBallWindow? _floatBall; + private SettingsWindow? _settings; + private int _dockPx = 78; + private bool _docked; + private AppBarEdge _side = AppBarEdge.Right; + private (nint Hwnd, int RailPx, int ExpandedPx) _attachInfo; + + public SidebarWindow CreateSidebar() + { + var cfg = config.Current; + var side = cfg.Display.SidebarSide == SidebarSide.Left ? AppBarEdge.Left : AppBarEdge.Right; + _side = side; + + var win = new SidebarWindow(side) + { + DataContext = new ViewModels.SidebarViewModel(appBar, ime, shell, links.List(), Dispatch), + }; + _sidebar = win; + win.Opened += (_, _) => + { + var handle = win.TryGetPlatformHandle(); + if (handle is null) + { + log.LogWarning("[Window] 未能获取平台句柄, 侧栏以 Topmost 模式运行"); + win.Topmost = true; + return; + } + var hwnd = handle.Handle; + var scale = Windows.Native.Win32Display.GetScaling(hwnd); + var railPx = (int)Math.Round(RailWidthDip * scale); + var expandedPx = (int)Math.Round((RailWidthDip + PanelWidthDip) * scale); + _dockPx = (int)Math.Round(DockSizeDip * scale); + _attachInfo = (hwnd, railPx, expandedPx); + + // §5.4: AppBar 注册失败回退 alwaysOnTop (v1.2.0 同策略) + if (appBar.Attach(hwnd, side, railPx, expandedPx)) + { + win.Topmost = false; + } + else + { + win.Topmost = true; + log.LogWarning("[Window] AppBar 注册失败 → Topmost 回退"); + } + + // ---- 常驻服务挂接 (同一 WndProcHook 基础设施) ---- + hotkeys.Attach(hwnd); + RegisterConfiguredHotkeys(); + usb.Start(hwnd); + printer.Start(); + + // 首启 → OOBE + if (!config.Current.Oobe.Completed && !config.Current.Oobe.Skipped) + { + ShowOobe(); + } + else + { + ShowFloatBall(); + } + }; + win.Closed += (_, _) => appBar.Detach(); + + // 热键 → 动作 + bus.Subscribe(e => Dispatch(e.Slot)); + + // 设备事件 → Toast + bus.Subscribe(e => ToastService.Instance.Show($"U 盘已接入: {e.Info.Drive} {e.Info.Label ?? ""} {e.Info.SizeGb}GB", "info")); + bus.Subscribe(e => ToastService.Instance.Show($"U 盘已移除: {e.Info.Drive}", "warn")); + bus.Subscribe(e => + { + var abnormal = e.Printers.Where(p => p.WireState is not "ok"); + foreach (var p in abnormal) + { + ToastService.Instance.Show($"打印机「{p.Name}」状态: {p.WireState}", "warn"); + } + }); + bus.Subscribe(e => + ToastService.Instance.Show(e.Recording ? $"开始录屏: {e.FilePath}" : $"录屏完成: {e.FilePath}", e.Recording ? "info" : "success")); + bus.Subscribe(e => + ToastService.Instance.Show(e.Error is null ? $"长截图完成: {e.FilePath}" : $"长截图失败: {e.Error}", e.Error is null ? "success" : "error")); + + // 几何事件: 面板宽度以系统授予为准 + bus.Subscribe(g => + { + win.OnAppBarGeometry(g.Rect.W); + log.LogDebug("[Window] AppBar 几何: ({X},{Y}) {W}x{H}px", g.Rect.X, g.Rect.Y, g.Rect.W, g.Rect.H); + }); + + log.LogInformation("[Window] 侧栏窗口已创建 ({Side} 侧, rail={Rail} DIP, 胶囊高度=工作区 84%)", side, RailWidthDip); + return win; + } + + public void RegisterConfiguredHotkeys() + { + var cfg = config.Current; + if (!config.Current.Policy.DisabledModules.Contains("capture", StringComparer.OrdinalIgnoreCase)) + { + hotkeys.Register("capture", cfg.Capture.Hotkey); + } + if (!config.Current.Policy.DisabledModules.Contains("annotate", StringComparer.OrdinalIgnoreCase)) + { + hotkeys.Register("annotate", cfg.Capture.AnnotateHotkey); + } + if (!config.Current.Policy.DisabledModules.Contains("longshot", StringComparer.OrdinalIgnoreCase)) + { + hotkeys.Register("longshot", cfg.Capture.LongshotHotkey); + } + hotkeys.Register("floatball", cfg.FloatBall.Hotkey); + hotkeys.Register("ime", "Ctrl+Shift+I"); + } + + /// 动作总路由: rail 按钮 / 悬浮球 / 热键 / 托盘共用。 + public void Dispatch(string action) + { + try + { + switch (action) + { + case "capture" or "capture-hotkey": + EnsureCaptureDirs(); + new OverlayWindow(capture, config.Current.Capture.Dir, config.Current.Capture.Format).Show(); + break; + + case "annotate" or "annotate-hotkey": + EnsureCaptureDirs(); + StartAnnotate(); + break; + + case "longshot" or "longshot-hotkey": + StartLongshotPicker(); + break; + + case "record" or "record-hotkey": + _ = ToggleRecorderAsync(); + break; + + case "ime": + _ = ime.Toggle(); + break; + + case "taskmgr": + shell.OpenTaskManager(); + break; + + case "sidebar" or "floatball": + ToggleFloatBall(); + break; + + case "sidebar-toggle": + ToggleSidebar(); + break; + + case "dock": + SetDocked(!_docked); + break; + + case "settings": + OpenSettings(); + break; + + case "usb" or "printer": + ShowDiagnostics(action); + break; + + default: + log.LogWarning("[Window] 未知动作: {Action}", action); + break; + } + } + catch (Exception ex) + { + log.LogError(ex, "[Window] 动作执行失败: {Action}", action); + ToastService.Instance.Show($"{action} 失败: {ex.Message}", "error"); + } + } + + private void EnsureCaptureDirs() + { + Directory.CreateDirectory(config.Current.Capture.Dir); + } + + private void StartAnnotate() + { + byte[]? background = null; + try + { + // 打开瞬间抓全屏底图 (WithBackground 导出用); 失败允许仅笔迹 + using var bmp = capture.CaptureMonitor(0); + if (bmp is not null) + { + using var ms = new MemoryStream(); + bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + background = ms.ToArray(); + } + } + catch (Exception ex) + { + log?.LogWarning(ex, "[Window] 批注底图抓取失败"); + } + new AnnotateWindow(background, config.Current.Capture.Dir, config.Current.Capture.Format).Show(); + } + + private void StartLongshotPicker() + { + var targets = longshot.EnumTargets(); + if (targets.Count == 0) + { + ToastService.Instance.Show("未找到可截图的窗口", "warn"); + return; + } + var picker = new WindowPickerWindow(targets); + picker.Closed += (_, _) => + { + if (picker.Selected is { } target) + { + _ = Task.Run(() => longshot.CaptureAsync(target, config.Current.Capture.Dir, config.Current.Capture.Format)); + } + }; + picker.Show(); + } + + private async Task ToggleRecorderAsync() + { + if (recorder.IsRecording) + { + await recorder.StopAsync(); + return; + } + if (!recorder.IsSupported()) + { + ToastService.Instance.Show("此系统不支持录屏 (需 Win10 1803+)", "error"); + return; + } + var dir = config.Current.Recorder.Dir; + Directory.CreateDirectory(dir); + var fps = Math.Clamp(config.Current.Recorder.Fps, 5, 60); + var path = Path.Combine(dir, $"record_{DateTime.Now:yyyyMMdd_HHmmss}.mp4"); + await recorder.StartAsync(new RecorderOptions(path, fps, 2000)); + } + + public void ShowFloatBall() + { + if (_floatBall is { IsVisible: true }) return; + if (config.Current.Policy.DisabledModules.Contains("floatball", StringComparer.OrdinalIgnoreCase)) return; + var wa = Windows.Native.Win32Display.PrimaryWorkAreaPx(); + _floatBall = new FloatBallWindow(config.Current.FloatBall, + new Core.FloatBall.RectLike(wa.X, wa.Y, wa.W, wa.H), Dispatch, ToggleSidebar); + _floatBall.Show(); + } + + public void ToggleFloatBall() + { + if (_floatBall is null) { ShowFloatBall(); return; } + if (_floatBall.IsVisible) _floatBall.Hide(); + else _floatBall.Show(); + } + + /// v1.1 dock/undock: 收起为工作区底角小方块 / 恢复玻璃胶囊。 + /// 勘误: 不复用侧栏窗口缩放 (透明窗外部 MoveWindow 缩放后 Avalonia 不出帧), + /// dock 用独立 DockWindow; AppBar 先注销 (WorkArea 完整归还, v1.1 同语义), + /// undock 时对侧栏窗口重新 Attach (含孤儿注册自愈)。 + public void SetDocked(bool docked) + { + if (docked == _docked) return; + _docked = docked; + if (_sidebar is not { } sidebar) return; + + if (docked) + { + appBar.Detach(); + sidebar.Hide(); + if (_dockWindow is null) + { + _dockWindow = new DockWindow(() => SetDocked(false)); + } + var wa = Windows.Native.Win32Display.PrimaryWorkAreaPx(); + var sizePx = (int)Math.Round(DockSizeDip * Windows.Native.Win32Display.GetScaling(_attachInfo.Hwnd)); + var x = _side == AppBarEdge.Right ? wa.X + wa.W - sizePx : wa.X; + _dockWindow.Position = new PixelPoint(x, wa.Y + wa.H - sizePx); + _dockWindow.Show(); + log.LogInformation("[Window] 侧栏已收起为底角方块 ({X},{Y}) {Size}px", x, wa.Y + wa.H - sizePx, sizePx); + } + else + { + _dockWindow?.Hide(); + sidebar.Show(); + _ = appBar.Attach(_attachInfo.Hwnd, _side, _attachInfo.RailPx, _attachInfo.ExpandedPx); + sidebar.Topmost = !appBar.IsRegistered; // Attach 失败回退置顶 + log.LogInformation("[Window] 侧栏已展开为玻璃胶囊"); + } + } + + public void ToggleSidebar() + { + if (_sidebar is { } win) + { + if (!win.IsVisible) + { + win.Show(); + win.WindowState = WindowState.Normal; + } + else + { + win.Hide(); + } + } + } + + public void OpenSettings() + { + if (_settings is { IsVisible: true }) return; + _settings = new SettingsWindow(config, shell, hotkeys, diagnostics); + _settings.Closed += (_, _) => RegisterConfiguredHotkeys(); // 热键改动即时生效 + _settings.Show(); + } + + public void ShowOobe() + { + var oobe = new OobeWindow(config, ime, printer); + oobe.Closed += (_, _) => ShowFloatBall(); + oobe.Show(); + } + + /// 设备按钮 → 轻量状态弹窗 (usb/printer 即时查询)。 + private void ShowDiagnostics(string kind) + { + var info = kind == "usb" + ? string.Join('\n', usb.ListRemovable().Select(d => $"{d.Drive} {d.Label ?? "(无卷标)"} {d.SizeGb}GB")) is { Length: > 0 } s ? s : "未检测到可移动盘" + : string.Join('\n', printer.Query().Select(p => $"{p.Name}: {p.WireState}")) is { Length: > 0 } s2 ? s2 : "无打印机"; + ToastService.Instance.Show(info, "info"); + } +} diff --git a/src/SmartSideBAR.Avalonia/app.manifest b/src/SmartSideBAR.Avalonia/app.manifest new file mode 100644 index 0000000..763d8a6 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + PerMonitorV2 + true/pm + + + diff --git a/src/SmartSideBAR.Core/Capture/LongshotStitcher.cs b/src/SmartSideBAR.Core/Capture/LongshotStitcher.cs new file mode 100644 index 0000000..c193615 --- /dev/null +++ b/src/SmartSideBAR.Core/Capture/LongshotStitcher.cs @@ -0,0 +1,44 @@ +// Core/Capture/LongshotStitcher.cs —— §6.4: 线性增量拼接 (C3/P1-1 根修: O(n) 不再 O(n²))。 +// 纯函数: 调用方提供行哈希 (Windows 层从位图采样计算), Core 决定重叠与拼接偏移。 +namespace SmartSideBAR.Core.Capture; + +public static class LongshotStitcher +{ + /// + /// 求新帧与已拼接尾部的最大行重叠 (帧顶 k 行 == 尾部末 k 行)。 + /// 算法: 从大到小扫描 k, 借助从尾部行哈希构建的倒序位置索引, 仅比较候选位置 —— 整体 O(行数)。 + /// 返回 0 表示无重叠 (不拼接, 由调用方决定重试/终止)。 + /// + /// 已拼接图像最后 maxOverlap 行的哈希 (顺序 = 从上到下)。 + /// 新帧逐行哈希 (顺序 = 从上到下)。 + /// 允许的最大重叠行数。 + public static int FindOverlap(IReadOnlyList tailRowHashes, IReadOnlyList frameRowHashes, int maxOverlap) + { + var tailLen = tailRowHashes.Count; + var frameLen = frameRowHashes.Count; + var maxK = Math.Min(Math.Min(maxOverlap, tailLen), frameLen); + if (maxK <= 0) return 0; + + // 尾部末 maxK 行 → 倒序哈希 → 索引 (倒序位置 0 = 尾部最后一行) + var tailReversed = new long[maxK]; + for (var i = 0; i < maxK; i++) tailReversed[i] = tailRowHashes[tailLen - 1 - i]; + + var frameReversed = new long[frameLen]; + for (var i = 0; i < frameLen; i++) frameReversed[i] = frameRowHashes[frameLen - 1 - i]; + + // k 重叠 ⇔ tailReversed[0..k) == frameReversed[0..k) 的前 k 项 (帧末 k 行与尾部末 k 行一致) + // 逐 k 检查代价 O(k²) 最坏; 实际滚动步长稳定, k 通常远小于 maxOverlap —— 加首行短路。 + var lastTail = tailReversed[0]; + for (var k = maxK; k >= 1; k--) + { + if (frameReversed[0] != lastTail) continue; // 帧末行必须与尾行相同才可能有重叠 + var ok = true; + for (var i = 1; i < k; i++) + { + if (tailReversed[i] != frameReversed[i]) { ok = false; break; } + } + if (ok) return k; + } + return 0; + } +} diff --git a/src/SmartSideBAR.Core/Configuration/AppearanceConfig.cs b/src/SmartSideBAR.Core/Configuration/AppearanceConfig.cs new file mode 100644 index 0000000..cd66bf5 --- /dev/null +++ b/src/SmartSideBAR.Core/Configuration/AppearanceConfig.cs @@ -0,0 +1,65 @@ +// 与 shared/appearance.ts 同构 —— 仅取纯数据与钳制逻辑; CSS 变量计算留在 UI 层 (Wave C)。 +namespace SmartSideBAR.Core.Configuration; + +public sealed record AppearanceConfig +{ + public string Theme { get; init; } = "auto"; // light|dark|auto + public bool LiquidGlass { get; init; } = true; + public string Material { get; init; } = "acrylic"; // acrylic|mica|blur|none + public double Blur { get; init; } = 22; + public double Opacity { get; init; } = 0.62; + public double Saturate { get; init; } = 1.6; + public double Refraction { get; init; } = 14; + public double Specular { get; init; } = 0.55; + public double Aberration { get; init; } = 2; + public string Accent { get; init; } = "#2B6EE0"; + public double Radius { get; init; } = 16; + public bool ReduceMotion { get; init; } +} + +public static class AppearanceNormalize +{ + private static readonly string[] ThemeModes = ["light", "dark", "auto"]; + private static readonly string[] Materials = ["acrylic", "mica", "blur", "none"]; + + public static double ClampNum(double v, double min, double max, double fallback) => + double.IsFinite(v) ? Math.Min(max, Math.Max(min, v)) : fallback; + + public static string NormalizeAccent(string? input, string fallback = "#2B6EE0") + { + if (input is null) return fallback; + var s = input.Trim(); + // 与 appearance.ts 正则一致: #RGB 与 #RRGGBB 均需合法 hex 字符 + if (s.Length == 4 && s[0] == '#' && s[1..].All(char.IsAsciiHexDigit)) + { + return $"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}".ToLowerInvariant(); + } + if (s.Length == 7 && s[0] == '#' && + s[1..].All(c => char.IsAsciiHexDigit(c))) + { + return s.ToLowerInvariant(); + } + return fallback; + } + + public static AppearanceConfig Clamp(AppearanceConfig? input, AppearanceConfig? baseValue = null) + { + var b = baseValue ?? new AppearanceConfig(); + if (input is null) return b; + return input with + { + Theme = ThemeModes.Contains(input.Theme) ? input.Theme : b.Theme, + LiquidGlass = input.LiquidGlass, + Material = Materials.Contains(input.Material) ? input.Material : b.Material, + Blur = ClampNum(input.Blur, 8, 40, b.Blur), + Opacity = ClampNum(input.Opacity, 0.3, 0.96, b.Opacity), + Saturate = ClampNum(input.Saturate, 1, 2.2, b.Saturate), + Refraction = ClampNum(input.Refraction, 0, 40, b.Refraction), + Specular = ClampNum(input.Specular, 0, 1, b.Specular), + Aberration = ClampNum(input.Aberration, 0, 8, b.Aberration), + Accent = NormalizeAccent(input.Accent, b.Accent), + Radius = ClampNum(input.Radius, 0, 28, b.Radius), + ReduceMotion = input.ReduceMotion, + }; + } +} diff --git a/src/SmartSideBAR.Core/Configuration/ConfigJsonContext.cs b/src/SmartSideBAR.Core/Configuration/ConfigJsonContext.cs new file mode 100644 index 0000000..90d9f7c --- /dev/null +++ b/src/SmartSideBAR.Core/Configuration/ConfigJsonContext.cs @@ -0,0 +1,19 @@ +// JSON 源生成 (AOT/Trim 友好, 方案 §9 风险缓解) —— camelCase 与 Electron config.json 互认。 +using System.Text.Json.Serialization; +using SmartSideBAR.Core.Configuration; + +namespace SmartSideBAR.Core.Configuration; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] +[JsonSerializable(typeof(SidekickConfig))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(OobeState))] +[JsonSerializable(typeof(AppearanceConfig))] +[JsonSerializable(typeof(FloatBallConfig))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(bool))] +public sealed partial class ConfigJsonContext : JsonSerializerContext; diff --git a/src/SmartSideBAR.Core/Configuration/ConfigService.cs b/src/SmartSideBAR.Core/Configuration/ConfigService.cs new file mode 100644 index 0000000..333a0d7 --- /dev/null +++ b/src/SmartSideBAR.Core/Configuration/ConfigService.cs @@ -0,0 +1,249 @@ +// Core/Configuration/ConfigService.cs —— 三层合并 (附录 B.2): 默认 → ProgramData → 用户层 +// 语义对齐 v1.2.0 services/config.ts: deepMerge(null 值跳过) / 未知字段保留 / migrateConfig / +// {Pictures} 路径展开 / appearance+floatBall 钳制。持久化以 JsonNode 为准以保留未知字段。 +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Messaging; + +namespace SmartSideBAR.Core.Configuration; + +public sealed record ConfigPaths( + string UserConfigPath, + string ProgramDataConfigPath, + string? LegacyUserConfigPath, + string? LegacyProgramDataConfigPath) +{ + /// 生产路径: %APPDATA%\SmartSideBAR + %ProgramData%\SmartSideBAR; + /// 遗留兼容: 开发态 %APPDATA%\smartsidebar (package.json name) 与 %ProgramData%\SeewoSidekick。 + public static ConfigPaths CreateDefault() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var programData = Environment.GetEnvironmentVariable("ProgramData") ?? @"C:\ProgramData"; + return new ConfigPaths( + UserConfigPath: Path.Combine(appData, "SmartSideBAR", "config.json"), + ProgramDataConfigPath: Path.Combine(programData, "SmartSideBAR", "config.json"), + LegacyUserConfigPath: Path.Combine(appData, "smartsidebar", "config.json"), + LegacyProgramDataConfigPath: Path.Combine(programData, "SeewoSidekick", "config.json")); + } +} + +public sealed record ConfigChanged(SidekickConfig Snapshot); + +public sealed class ConfigService( + ConfigPaths paths, + IEventBus? bus = null, + ILogger? log = null) +{ + private readonly object _gate = new(); + private JsonNode _root = new JsonObject(); + private SidekickConfig _current = new(); + + public SidekickConfig Current { get { lock (_gate) return _current; } } + + /// 持久化根 (含未知字段)。直接改写请走 以走白名单与落盘。 + internal JsonNode RootForTests { get { lock (_gate) return _root.DeepClone(); } } + + public void Load() + { + lock (_gate) + { + _root = JsonSerializer.SerializeToNode(CreateDefault(), ConfigJsonContext.Default.SidekickConfig)!.DeepClone(); + + // 层2 策略层: 新目录优先, 不存在则兼容读取 SeewoSidekick (附录 B.3) + var policyPath = File.Exists(paths.ProgramDataConfigPath) + ? paths.ProgramDataConfigPath + : paths.LegacyProgramDataConfigPath; + if (policyPath is not null && File.Exists(policyPath)) + { + if (TryParseNode(File.ReadAllText(policyPath), out var policy)) + { + if (policy is JsonObject policyObj) DeepMerge((JsonObject)_root, policyObj); + log?.LogInformation("[Config] 已加载策略层 {Path}", policyPath); + } + else log?.LogWarning("[Config] 策略层解析失败, 忽略: {Path}", policyPath); + } + + // 层3 用户层: 首启执行旧 userData 迁移 (旧文件 → 新路径, 旧文件改名 .migrated.bak) + EnsureUserConfigMigrated(); + if (File.Exists(paths.UserConfigPath)) + { + if (TryParseNode(File.ReadAllText(paths.UserConfigPath), out var user)) + { + if (user is JsonObject userObj) DeepMerge((JsonObject)_root, userObj); + log?.LogInformation("[Config] 已加载用户配置 {Path}", paths.UserConfigPath); + } + else log?.LogWarning("[Config] 用户配置解析失败, 使用默认值: {Path}", paths.UserConfigPath); + } + else + { + SaveUserConfigNoLock(); + log?.LogInformation("[Config] 已创建默认用户配置"); + } + + RefreshTypedNoLock(); + + // F8/migrateConfig: version 落后时迁移并落盘 + var fromVersion = _current.Version; + if (fromVersion < SidekickConfig.CurrentVersion) + { + _root["version"] = SidekickConfig.CurrentVersion; + RefreshTypedNoLock(); + SaveUserConfigNoLock(); + log?.LogInformation("[Config] 配置 schema v{From} → v{To} 迁移完成", + fromVersion, SidekickConfig.CurrentVersion); + } + + bus?.Publish(new ConfigChanged(_current)); + } + } + + /// 按点分路径写入 (如 "capture.format" / "reminderSound.volume")。 + /// B3 根修: 键白名单 —— 路径必须在默认 schema 中存在; 成功后落盘并广播 ConfigChanged。 + public bool Set(string dotPath, JsonNode? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dotPath); + lock (_gate) + { + if (!IsKnownKey(dotPath)) + { + log?.LogWarning("[Config] 拒绝写入未知配置键: {Key}", dotPath); + return false; + } + var segments = dotPath.Split('.'); + var node = _root; + for (var i = 0; i < segments.Length - 1; i++) + { + node = node[segments[i]]!; // 白名单已保证存在 + } + node[segments[^1]] = value?.DeepClone(); + + RefreshTypedNoLock(); + var ok = SaveUserConfigNoLock(); + if (ok) bus?.Publish(new ConfigChanged(_current)); + return ok; + } + } + + public bool Set(string dotPath, T value) + { + if (value is null) return Set(dotPath, (JsonNode?)null); + var node = value is JsonNode n ? n : JsonSerializer.SerializeToNode(value, typeof(T), ConfigJsonContext.Default.Options); + return Set(dotPath, node); + } + + /// C1 语义: 变更检测落盘 —— 仅 reminders 内容实际变化时写盘。返回是否落盘。 + public bool SetRemindersIfChanged(IReadOnlyList reminders) + { + lock (_gate) + { + var before = JsonSerializer.Serialize(_current.Reminders, ConfigJsonContext.Default.Options); + var after = JsonSerializer.Serialize(reminders, ConfigJsonContext.Default.Options); + if (before == after) return false; + } + return Set("reminders", reminders); + } + + // ---- 内部 ---- + + private void EnsureUserConfigMigrated() + { + var legacy = paths.LegacyUserConfigPath; + if (legacy is null || !File.Exists(legacy) || File.Exists(paths.UserConfigPath)) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(paths.UserConfigPath)!); + File.Copy(legacy, paths.UserConfigPath); + File.Move(legacy, legacy + ".migrated.bak"); + log?.LogInformation("[Config] 旧用户配置已迁移: {Legacy} → {New}", legacy, paths.UserConfigPath); + } + catch (Exception ex) + { + log?.LogWarning(ex, "[Config] 旧配置迁移失败, 将使用默认值"); + } + } + + private void RefreshTypedNoLock() + { + var typed = _root.Deserialize(ConfigJsonContext.Default.SidekickConfig) ?? new SidekickConfig(); + // 路径变量展开与钳制 (与 config.ts 加载尾段一致) + typed = typed with + { + Capture = typed.Capture with { Dir = ExpandPath(typed.Capture.Dir) }, + Recorder = typed.Recorder with { Dir = ExpandPath(typed.Recorder.Dir) }, + Appearance = AppearanceNormalize.Clamp(typed.Appearance), + FloatBall = FloatBallNormalize.Clamp(typed.FloatBall), + }; + _current = typed; + } + + private bool SaveUserConfigNoLock() + { + try + { + var dir = Path.GetDirectoryName(paths.UserConfigPath); + if (dir is not null) Directory.CreateDirectory(dir); + var tmp = paths.UserConfigPath + ".tmp"; + File.WriteAllText(tmp, _root.ToJsonString(ConfigJsonContext.Default.Options)); + File.Move(tmp, paths.UserConfigPath, overwrite: true); + return true; + } + catch (Exception ex) + { + log?.LogError(ex, "[Config] 落盘失败: {Path}", paths.UserConfigPath); + return false; + } + } + + private bool IsKnownKey(string dotPath) + { + var node = _root; + foreach (var seg in dotPath.Split('.')) + { + if (node is not JsonObject obj || !obj.ContainsKey(seg)) return false; + node = obj[seg]!; + } + return true; + } + + private static void DeepMerge(JsonObject target, JsonObject overlay) + { + foreach (var (key, overlayVal) in overlay) + { + // config.ts 语义: undefined/null 覆盖值跳过, 保留基线 + if (overlayVal is null) continue; + if (overlayVal is JsonObject overlayObj) + { + if (target[key] is JsonObject targetObj) DeepMerge(targetObj, overlayObj); + else target[key] = overlayObj.DeepClone(); + } + else + { + target[key] = overlayVal.DeepClone(); // 数组/标量整体替换 + } + } + } + + private static bool TryParseNode(string json, out JsonNode? node) + { + try { node = JsonNode.Parse(json); return node is not null; } + catch (JsonException) { node = null; return false; } + } + + private static SidekickConfig CreateDefault() => new() + { + Links = + [ + new LinkItem { Id = "l1", Name = "国家中小学智慧教育平台", Url = "https://www.zxx.edu.cn", Enabled = true }, + new LinkItem { Id = "l2", Name = "希沃白板", Url = "https://easinote.seewo.com", Enabled = true }, + new LinkItem { Id = "l3", Name = "学科网", Url = "https://www.zxxk.com", Enabled = true }, + new LinkItem { Id = "l4", Name = "百度文库", Url = "https://wenku.baidu.com", Enabled = false }, + new LinkItem { Id = "l5", Name = "中国知网", Url = "https://www.cnki.net", Enabled = false }, + ], + }; + + private static string ExpandPath(string p) => p + .Replace("{Pictures}", Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)) + .Replace("{Videos}", Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)) + .Replace("{Home}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); +} diff --git a/src/SmartSideBAR.Core/Configuration/FloatBallConfig.cs b/src/SmartSideBAR.Core/Configuration/FloatBallConfig.cs new file mode 100644 index 0000000..54a5a3c --- /dev/null +++ b/src/SmartSideBAR.Core/Configuration/FloatBallConfig.cs @@ -0,0 +1,44 @@ +// 与 shared/floatball-layout.ts 同构 —— 配置 + 钳制; 布局算法见 FloatBall/FloatBallLayout.cs +namespace SmartSideBAR.Core.Configuration; + +public sealed record FloatBallConfig +{ + public bool Enabled { get; init; } = true; + public int Size { get; init; } = 56; + public double IdleOpacity { get; init; } = 0.55; + public int IdleDelayMs { get; init; } = 4000; + public int SnapThreshold { get; init; } = 24; + /// 相对 workArea 的偏移; <0 表示未设置 → 默认位置 + public int X { get; init; } = -1; + public int Y { get; init; } = -1; + public List Actions { get; init; } = ["capture", "annotate", "record", "ime", "longshot", "sidebar"]; + public string Hotkey { get; init; } = "Alt+Q"; + public string DoubleClick { get; init; } = "toggleSidebar"; // toggleSidebar|capture|none +} + +public static class FloatBallNormalize +{ + public static readonly string[] KnownActions = + ["capture", "annotate", "longshot", "record", "ime", "taskmgr", "sidebar", "settings"]; + + public static FloatBallConfig Clamp(FloatBallConfig? input, FloatBallConfig? baseValue = null) + { + var b = baseValue ?? new FloatBallConfig(); + if (input is null) return b; + var actions = input.Actions is { Count: > 0 } + ? input.Actions.Distinct().Where(KnownActions.Contains).Take(8).ToList() + : b.Actions; + return input with + { + Size = (int)Math.Round(AppearanceNormalize.ClampNum(input.Size, 40, 96, b.Size)), + IdleOpacity = AppearanceNormalize.ClampNum(input.IdleOpacity, 0.15, 1, b.IdleOpacity), + IdleDelayMs = (int)Math.Round(AppearanceNormalize.ClampNum(input.IdleDelayMs, 0, 60000, b.IdleDelayMs)), + SnapThreshold = (int)Math.Round(AppearanceNormalize.ClampNum(input.SnapThreshold, 0, 120, b.SnapThreshold)), + X = double.IsFinite((double)input.X) ? input.X : b.X, + Y = double.IsFinite((double)input.Y) ? input.Y : b.Y, + Actions = actions.Count > 0 ? actions : [.. (baseValue ?? new FloatBallConfig()).Actions], + Hotkey = string.IsNullOrWhiteSpace(input.Hotkey) ? b.Hotkey : input.Hotkey.Trim(), + DoubleClick = input.DoubleClick is "toggleSidebar" or "capture" or "none" ? input.DoubleClick : b.DoubleClick, + }; + } +} diff --git a/src/SmartSideBAR.Core/Configuration/SidekickConfig.cs b/src/SmartSideBAR.Core/Configuration/SidekickConfig.cs new file mode 100644 index 0000000..86e322a --- /dev/null +++ b/src/SmartSideBAR.Core/Configuration/SidekickConfig.cs @@ -0,0 +1,145 @@ +// Core/Configuration/SidekickConfig.cs —— 附录 B.1: 与 v1.2.0 src/shared/types.ts 逐字段同构 +// JSON 命名 camelCase, 与 Electron 版 config.json 互认 (ADR-M8)。 +using System.Text.Json.Serialization; + +namespace SmartSideBAR.Core.Configuration; + +public sealed record SidekickConfig +{ + public const int CurrentVersion = 2; + + public int Version { get; init; } = CurrentVersion; + public ImeConfig Ime { get; init; } = new(); + public CaptureConfig Capture { get; init; } = new(); + public RecorderConfig Recorder { get; init; } = new(); + public UsbConfig Usb { get; init; } = new(); + public PrinterConfig Printer { get; init; } = new(); + public DisplayConfig Display { get; init; } = new(); + public AppearanceConfig Appearance { get; init; } = new(); + public FloatBallConfig FloatBall { get; init; } = new(); + public List Links { get; init; } = []; + public List Reminders { get; init; } = []; + public ReminderSoundConfig ReminderSound { get; init; } = new(); + public OobeState Oobe { get; init; } = new(); + public PolicyConfig Policy { get; init; } = new(); +} + +public sealed record ImeConfig +{ + public string Slot1 { get; init; } = "Microsoft Pinyin"; + public string Slot2 { get; init; } = "US"; + public bool FallbackSwap { get; init; } = true; +} + +public sealed record CaptureConfig +{ + public string Hotkey { get; init; } = "Ctrl+Shift+A"; + public string AnnotateHotkey { get; init; } = "Ctrl+Shift+D"; + public string LongshotHotkey { get; init; } = "Ctrl+Shift+L"; + public string Format { get; init; } = "PNG"; // png|jpg + public string Dir { get; init; } = "{Pictures}/Sidekick"; +} + +public sealed record RecorderConfig +{ + public int Fps { get; init; } = 15; + public string Bitrate { get; init; } = "2M"; + public bool Mic { get; init; } + public string Dir { get; init; } = "{Videos}/Sidekick"; +} + +public sealed record UsbConfig +{ + public bool Enabled { get; init; } = true; + public List IgnoreTypes { get; init; } = ["phone", "carplay"]; +} + +public sealed record PrinterConfig +{ + public int PollIntervalSec { get; init; } = 10; +} + +public sealed record DisplayConfig +{ + public string SidebarMonitor { get; init; } = "primary"; + public SidebarSide SidebarSide { get; init; } = SidebarSide.Right; + public bool FitWindowsToWorkArea { get; init; } = true; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SidebarSide { Left, Right } + +public sealed record PolicyConfig +{ + public List DisabledModules { get; init; } = []; +} + +// ---- 与 shared/types.ts 同构的值类型 ---- + +public sealed record LinkItem +{ + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string Url { get; init; } = ""; + public string? Icon { get; init; } + public bool Enabled { get; init; } = true; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReminderKind { Once, Interval, Hourly } + +public sealed record Reminder +{ + public string Id { get; init; } = ""; + public ReminderKind Kind { get; init; } = ReminderKind.Once; + /// epoch 毫秒 —— 与 v1.2.0 Reminder.at 语义一致 + public long At { get; init; } + public string? Note { get; init; } + public int? RepeatMin { get; init; } + public long? SnoozedUntil { get; init; } +} + +public sealed record ReminderSoundConfig +{ + public string Preset { get; init; } = "default"; // default|bell|chime|alarm + public string? Mp3Path { get; init; } + public double Volume { get; init; } = 0.8; // 0~1 + public int Repeat { get; init; } = 3; // 1~5 + public int RepeatInterval { get; init; } = 800; // ms +} + +public sealed record OobePrefs +{ + public bool Ime { get; init; } = true; + public bool Usb { get; init; } = true; + public bool Shot { get; init; } = true; + public bool Recorder { get; init; } + public bool Printer { get; init; } = true; +} + +public sealed record OobeEnvScreen +{ + public int W { get; init; } = 1920; + public int H { get; init; } = 1080; + public double Scale { get; init; } = 1; + public bool Touch { get; init; } = true; +} + +public sealed record OobeEnv +{ + public OobeEnvScreen Screen { get; init; } = new(); + public int PrinterCount { get; init; } + public bool ImeOk { get; init; } = true; + public string Os { get; init; } = "win32"; +} + +public sealed record OobeState +{ + public bool Completed { get; init; } + public string? CompletedAt { get; init; } // ISO 字符串, 与 v1.2.0 一致 + public bool Skipped { get; init; } + public string? Role { get; init; } // teacher|admin|null + public int LastStepIndex { get; init; } + public OobePrefs Prefs { get; init; } = new(); + public OobeEnv Env { get; init; } = new(); +} diff --git a/src/SmartSideBAR.Core/Devices/PrinterStateMapper.cs b/src/SmartSideBAR.Core/Devices/PrinterStateMapper.cs new file mode 100644 index 0000000..1ce7652 --- /dev/null +++ b/src/SmartSideBAR.Core/Devices/PrinterStateMapper.cs @@ -0,0 +1,62 @@ +// Core/Devices/PrinterStateMapper.cs —— E4 根修 (方案 §6.7): 用 DetectedErrorState 枚举做状态判定。 +// 与 v1.2.0 的「PrinterState===3 判 ok」错误映射不同, 此处字段语义逐一对齐 Win32_Printer 文档: +// DetectedErrorState: 0=Unknown 1=Other 2=NoError 3=LowPaper 4=NoPaper 5=LowToner 6=NoToner +// 7=DoorOpen 8=Jammed 9=Offline 10=ServiceRequested 11=OutputBinFull +// PrinterStatus: 1=Other 2=Unknown 3=Idle 4=Printing 5=WarmingUp 6=StoppedPrinting 7=Offline +namespace SmartSideBAR.Core.Devices; + +public enum PrinterState +{ + Ok, + Idle, + OutOfPaper, + LowPaper, + LowInk, + Jammed, + Offline, + DoorOpen, + ServiceRequired, + Unknown, +} + +public static class PrinterStateMapper +{ + /// 与 types.ts PrinterStatus.state 联合类型对齐的对外字串(camelCase)。 + public static string ToWire(PrinterState state) => state switch + { + PrinterState.Ok => "ok", + PrinterState.Idle => "ok", // Idle 亦视为正常可用 (v1.2.0 状态族无 idle) + PrinterState.OutOfPaper => "out_of_paper", + PrinterState.LowPaper => "out_of_paper", + PrinterState.LowInk => "low_ink", + PrinterState.Jammed => "jammed", + PrinterState.Offline => "offline", + PrinterState.DoorOpen => "jammed", // 门开归入卡纸族提示 + PrinterState.ServiceRequired => "unknown", + _ => "unknown", + }; + + /// 纯净映射函数 —— 单测锁定语义。 + public static PrinterState Map(uint? detectedErrorState, ushort? printerStatus) + { + switch (detectedErrorState) + { + case 3: return PrinterState.LowPaper; + case 4: return PrinterState.OutOfPaper; + case 5 or 6: return PrinterState.LowInk; + case 7: return PrinterState.DoorOpen; + case 8: return PrinterState.Jammed; + case 9: return PrinterState.Offline; + case 10 or 11: return PrinterState.ServiceRequired; + } + return printerStatus switch + { + 3 => PrinterState.Idle, + 4 => PrinterState.Ok, // 打印中 = 在线可用 + 5 => PrinterState.Ok, // 预热 + 7 => PrinterState.Offline, + 2 or null => PrinterState.Unknown, + _ => PrinterState.Ok, // 1=Other 等按可用处理, 不误报 + }; + } +} diff --git a/src/SmartSideBAR.Core/FloatBall/FloatBallLayout.cs b/src/SmartSideBAR.Core/FloatBall/FloatBallLayout.cs new file mode 100644 index 0000000..074952a --- /dev/null +++ b/src/SmartSideBAR.Core/FloatBall/FloatBallLayout.cs @@ -0,0 +1,130 @@ +// shared/floatball-layout.ts 的逐函数移植 —— 纯算法, 无平台依赖 (附录 A floatball:layout 落点)。 +namespace SmartSideBAR.Core.FloatBall; + +public readonly record struct RectLike(int X, int Y, int Width, int Height); + +public static class FloatBallLayout +{ + public const double FanStartDeg = 4; + public const double FanSweepDeg = 92; + public const int FanItemSize = 46; + public const int FanItemGap = 12; + public const int FanMargin = 12; + public const int FanRadiusMin = 96; + public const int FanRadiusMax = 260; + + public static (int X, int Y) ClampToArea((int X, int Y) pos, (int W, int H) size, RectLike area) + { + var maxX = area.X + area.Width - size.W; + var maxY = area.Y + area.Height - size.H; + return ( + Math.Min(Math.Max(pos.X, area.X), Math.Max(area.X, maxX)), + Math.Min(Math.Max(pos.Y, area.Y), Math.Max(area.Y, maxY))); + } + + public static (int X, int Y, string? Edge) SnapToEdges( + (int X, int Y) pos, (int W, int H) size, RectLike area, int threshold) + { + var p = ClampToArea(pos, size, area); + if (threshold <= 0) return (p.X, p.Y, null); + var dLeft = p.X - area.X; + var dRight = area.X + area.Width - (p.X + size.W); + var dTop = p.Y - area.Y; + var dBottom = area.Y + area.Height - (p.Y + size.H); + var nearest = Math.Min(Math.Min(dLeft, dRight), Math.Min(dTop, dBottom)); + if (nearest > threshold) return (p.X, p.Y, null); + if (nearest == dLeft) return (area.X, p.Y, "left"); + if (nearest == dRight) return (area.X + area.Width - size.W, p.Y, "right"); + if (nearest == dTop) return (p.X, area.Y, "top"); + return (p.X, area.Y + area.Height - size.H, "bottom"); + } + + public static (int X, int Y) DefaultBallPosition(RectLike area, int ballDip, string side) + { + var inset = (int)Math.Round(ballDip * 0.28); + var x = side == "right" ? area.X + area.Width - ballDip - inset : area.X + inset; + var y = area.Y + (int)Math.Round(area.Height * 0.62); + return ClampToArea((x, y), (ballDip, ballDip), area); + } + + /// cfg.x/y 为相对 workArea 的偏移; <0 表示未设置 → 默认位置 + public static (int X, int Y) ResolveBallPosition((int X, int Y) cfg, RectLike area, int ballDip, string side) => + cfg.X < 0 || cfg.Y < 0 + ? DefaultBallPosition(area, ballDip, side) + : ClampToArea((area.X + cfg.X, area.Y + cfg.Y), (ballDip, ballDip), area); + + public static (int X, int Y) ToRelativePosition((int X, int Y) pos, RectLike area) => + (Math.Max(0, (int)Math.Round((double)pos.X - area.X)), Math.Max(0, (int)Math.Round((double)pos.Y - area.Y))); + + public static int FanRadius(int count, int itemSize = FanItemSize, int gap = FanItemGap, double sweepDeg = FanSweepDeg) + { + if (count <= 1) return FanRadiusMin; + var step = sweepDeg * Math.PI / 180 / (count - 1); + var needed = (itemSize + gap) / step; + return (int)Math.Round(Math.Min(FanRadiusMax, Math.Max(FanRadiusMin, needed))); + } + + public static (double X, double Y) FanItemOffset( + int index, int count, int radius, (double X, double Y) dir, + double startDeg = FanStartDeg, double sweepDeg = FanSweepDeg) + { + var startRad = startDeg * Math.PI / 180; + var sweepRad = sweepDeg * Math.PI / 180; + var angle = count <= 1 ? startRad + sweepRad / 2 : startRad + sweepRad / (count - 1) * index; + return (dir.X * radius * Math.Cos(angle), dir.Y * radius * Math.Sin(angle)); + } +} + +public readonly record struct FanLayout(bool Expanded, int BallSize, (int X, int Y) BallOffset, + (double X, double Y) Dir, int Radius, int ItemSize); + +public static class FanLayoutComputer +{ + public static FanLayout Collapsed(int ballSize, int itemSize = FloatBallLayout.FanItemSize) => + new(false, ballSize, (0, 0), (1, 1), 0, itemSize); + + public static (FanLayout Layout, RectLike Window) ComputeFanLayout( + (int X, int Y) ball, int ballSize, RectLike area, int count, double uiScale, + int itemSize = FloatBallLayout.FanItemSize, int gap = FloatBallLayout.FanItemGap) + { + var scale = uiScale > 0 ? uiScale : 1; + var ballDip = (int)Math.Round(ballSize * scale); + var radius = FloatBallLayout.FanRadius(count, itemSize, gap); + var roomLeft = ball.X - area.X; + var roomRight = area.X + area.Width - (ball.X + ballDip); + var roomTop = ball.Y - area.Y; + var roomBottom = area.Y + area.Height - (ball.Y + ballDip); + var dir = (roomRight >= roomLeft ? 1d : -1d, roomBottom >= roomTop ? 1d : -1d); + + var half = ballSize / 2; + var itemHalf = itemSize / 2; + double minX = -half, maxX = half, minY = -half, maxY = half; + for (var i = 0; i < count; i++) + { + var o = FloatBallLayout.FanItemOffset(i, count, radius, dir); + minX = Math.Min(minX, o.X - itemHalf); + maxX = Math.Max(maxX, o.X + itemHalf); + minY = Math.Min(minY, o.Y - itemHalf); + maxY = Math.Max(maxY, o.Y + itemHalf); + } + var panelW = (int)Math.Ceiling(maxX - minX + FloatBallLayout.FanMargin * 2); + var panelH = (int)Math.Ceiling(maxY - minY + FloatBallLayout.FanMargin * 2); + var centerX = (int)Math.Round(-minX + FloatBallLayout.FanMargin); + var centerY = (int)Math.Round(-minY + FloatBallLayout.FanMargin); + var ballOffset = (centerX - half, centerY - half); + + var winW = (int)Math.Round(panelW * scale); + var winH = (int)Math.Round(panelH * scale); + var desired = (ball.X - (int)Math.Round(ballOffset.Item1 * scale), + ball.Y - (int)Math.Round(ballOffset.Item2 * scale)); + var placed = FloatBallLayout.ClampToArea(desired, (winW, winH), area); + var compensatedX = ballOffset.Item1 + (int)Math.Round((double)(desired.Item1 - placed.X) / scale); + var compensatedY = ballOffset.Item2 + (int)Math.Round((double)(desired.Item2 - placed.Y) / scale); + var safeOffset = ( + Math.Min(Math.Max(compensatedX, 0), Math.Max(0, panelW - ballSize)), + Math.Min(Math.Max(compensatedY, 0), Math.Max(0, panelH - ballSize))); + + return (new FanLayout(true, ballSize, safeOffset, dir, radius, itemSize), + new RectLike(placed.X, placed.Y, winW, winH)); + } +} diff --git a/src/SmartSideBAR.Core/Input/HotkeyParser.cs b/src/SmartSideBAR.Core/Input/HotkeyParser.cs new file mode 100644 index 0000000..2dcf814 --- /dev/null +++ b/src/SmartSideBAR.Core/Input/HotkeyParser.cs @@ -0,0 +1,102 @@ +// Core/Input/HotkeyParser.cs —— "Ctrl+Shift+A" 文本 ↔ Win32 修饰键+虚拟键 (方案 §6.13)。 +// 解析/替代建议为纯函数可单测; 冲突事实由 RegisterHotKey 返回值给出 (系统级, 非启发式)。 +namespace SmartSideBAR.Core.Input; + +public readonly record struct HotkeyCombo(uint Modifiers, uint VirtualKey) +{ + public override string ToString() + { + var parts = new List(); + if ((Modifiers & ModAlt) != 0) parts.Add("Alt"); + if ((Modifiers & ModCtrl) != 0) parts.Add("Ctrl"); + if ((Modifiers & ModShift) != 0) parts.Add("Shift"); + if ((Modifiers & ModWin) != 0) parts.Add("Win"); + parts.Add(VirtualKey switch + { + >= 0x30 and <= 0x39 => ((char)VirtualKey).ToString(), + >= 0x41 and <= 0x5A => ((char)VirtualKey).ToString(), + >= 0x70 and <= 0x7B => $"F{VirtualKey - 0x6F}", + 0x20 => "Space", + 0x2D => "Insert", + 0x2E => "Delete", + 0x21 => "PageUp", + 0x22 => "PageDown", + _ => $"0x{VirtualKey:X2}", + }); + return string.Join("+", parts); + } + + public const uint ModAlt = 0x0001; + public const uint ModCtrl = 0x0002; + public const uint ModShift = 0x0004; + public const uint ModWin = 0x0008; +} + +public static class HotkeyParser +{ + private static readonly Dictionary ModMap = new(StringComparer.OrdinalIgnoreCase) + { + ["ctrl"] = HotkeyCombo.ModCtrl, + ["control"] = HotkeyCombo.ModCtrl, + ["shift"] = HotkeyCombo.ModShift, + ["alt"] = HotkeyCombo.ModAlt, + ["win"] = HotkeyCombo.ModWin, + ["super"] = HotkeyCombo.ModWin, + }; + + public static bool TryParse(string? text, out HotkeyCombo combo) + { + combo = default; + if (string.IsNullOrWhiteSpace(text)) return false; + uint mods = 0; + uint? vk = null; + foreach (var raw in text.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (ModMap.TryGetValue(raw, out var m)) + { + mods |= m; + } + else if (raw.Length == 1 && char.IsAsciiLetterOrDigit(raw[0])) + { + var c = char.ToUpperInvariant(raw[0]); + vk = c switch + { + >= 'A' and <= 'Z' => (uint)c, + >= '0' and <= '9' => (uint)c, + _ => null, + }; + } + else if (raw.Length >= 2 && raw[0] is 'F' or 'f' && int.TryParse(raw[1..], out var f) && f is >= 1 and <= 12) + { + vk = (uint)(0x70 + f - 1); + } + else if (raw.Equals("Space", StringComparison.OrdinalIgnoreCase)) vk = 0x20; + else if (raw.Equals("Insert", StringComparison.OrdinalIgnoreCase)) vk = 0x2D; + else if (raw.Equals("Delete", StringComparison.OrdinalIgnoreCase)) vk = 0x2E; + else return false; + } + if (vk is null) return false; + combo = new HotkeyCombo(mods, vk.Value); + return true; + } + + /// 冲突替代建议 (P2-2): 依序尝试换 Win→Ctrl、加/去 Shift、F 键邻位; + /// 仅生成候选, 是否真冲突由系统 RegisterHotKey 判定。 + public static IReadOnlyList SuggestAlternatives(HotkeyCombo original) + { + var list = new List(); + void Add(uint mods, uint vk) + { + var c = new HotkeyCombo(mods, vk); + if (!list.Contains(c) && c != original) list.Add(c); + } + var (m, vk) = (original.Modifiers, original.VirtualKey); + if ((m & HotkeyCombo.ModWin) != 0) Add((m & ~HotkeyCombo.ModWin) | HotkeyCombo.ModCtrl, vk); + Add(m ^ HotkeyCombo.ModShift, vk); // 加/去 Shift + if ((m & HotkeyCombo.ModCtrl) != 0) Add(m | HotkeyCombo.ModAlt, vk); + if (vk is >= 0x70 and <= 0x7B) Add(m, vk + 1); // F 邻位 + else if (char.IsAsciiLetter((char)vk)) Add(m, (uint)(((char)vk - 'A' + 1) % 26 + 'A')); + Add(m | HotkeyCombo.ModWin, vk); + return list; + } +} diff --git a/src/SmartSideBAR.Core/Links/LinksService.cs b/src/SmartSideBAR.Core/Links/LinksService.cs new file mode 100644 index 0000000..d3495e0 --- /dev/null +++ b/src/SmartSideBAR.Core/Links/LinksService.cs @@ -0,0 +1,74 @@ +// Core/Links/LinksService.cs —— E1/P1-5 语义固化: 过滤仅作用于读取视图, 保存全量回写。 +using SmartSideBAR.Core.Configuration; + +namespace SmartSideBAR.Core.Links; + +public sealed class LinksService(ConfigService config) +{ + /// 侧栏展示视图: 仅 enabled 且 url 非空 (排序保持存储顺序)。 + public IReadOnlyList List() => config.Current.Links + .Where(l => l.Enabled && !string.IsNullOrWhiteSpace(l.Url)) + .ToArray(); + + /// 设置界面视图: 全量, 含禁用项。 + public IReadOnlyList ListAll() => config.Current.Links.ToArray(); + + public LinkItem Add(string name, string url, string? icon = null, bool enabled = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(url); + var item = new LinkItem + { + Id = "l" + Guid.NewGuid().ToString("N")[..10], + Name = name.Trim(), + Url = url.Trim(), + Icon = icon, + Enabled = enabled, + }; + Persist(all => { all.Add(item); return true; }); + return item; + } + + public bool Update(string id, string? name = null, string? url = null, string? icon = null, bool? enabled = null) + { + return Persist(all => + { + var idx = all.FindIndex(l => l.Id == id); + if (idx < 0) return false; + var item = all[idx] with + { + Name = name?.Trim() ?? all[idx].Name, + Url = url?.Trim() ?? all[idx].Url, + Icon = icon ?? all[idx].Icon, + Enabled = enabled ?? all[idx].Enabled, + }; + all[idx] = item; + return true; + }); + } + + public bool Remove(string id) => Persist(all => all.RemoveAll(l => l.Id == id) > 0); + + /// 调整顺序 (目标索引为全量列表索引)。 + public bool Move(string id, int targetIndex) + { + return Persist(all => + { + var idx = all.FindIndex(l => l.Id == id); + if (idx < 0 || targetIndex < 0 || targetIndex >= all.Count) return false; + var item = all[idx]; + all.RemoveAt(idx); + all.Insert(targetIndex, item); + return true; + }); + } + + /// 核心不变式 (E1 根修): 变更计算在全量列表上进行, 回写也是全量列表 —— + /// 禁用/空链接项永不丢失。返回 Persist 运算是否生效。 + private bool Persist(Func, bool> mutate) + { + var all = config.Current.Links.ToList(); + if (!mutate(all)) return false; + return config.Set("links", all); + } +} diff --git a/src/SmartSideBAR.Core/Messaging/EventBus.cs b/src/SmartSideBAR.Core/Messaging/EventBus.cs new file mode 100644 index 0000000..791ffbd --- /dev/null +++ b/src/SmartSideBAR.Core/Messaging/EventBus.cs @@ -0,0 +1,65 @@ +// 替代 Electron IPC 事件推送 (附录 A): 同进程类型安全发布/订阅。 +namespace SmartSideBAR.Core.Messaging; + +public interface IEventBus +{ + /// 订阅某类型事件; Dispose 即退订。返回时快照已含本次订阅。 + IDisposable Subscribe(Action handler); + + /// 发布事件到当前已注册的 T 处理器; 处理器异常不阻断其他处理器。 + void Publish(T evt); +} + +public sealed class EventBus : IEventBus +{ + private readonly object _gate = new(); + private Dictionary> _handlers = []; + + public IDisposable Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + lock (_gate) + { + if (!_handlers.TryGetValue(typeof(T), out var list)) + { + list = []; + _handlers[typeof(T)] = list; + } + list.Add(handler); + } + return new Unsubscription(this, typeof(T), handler); + } + + public void Publish(T evt) + { + Delegate[] snapshot; + lock (_gate) + { + if (!_handlers.TryGetValue(typeof(T), out var list) || list.Count == 0) return; + snapshot = [.. list]; + } + foreach (var d in snapshot) + { + try { ((Action)d)(evt); } + catch + { + // 单个处理器异常不得阻断事件链 (侧栏常驻场景); 具体日志由宿主注入的处理器自理 + } + } + } + + private sealed class Unsubscription(EventBus bus, Type eventType, Delegate handler) : IDisposable + { + public void Dispose() + { + lock (bus._gate) + { + if (bus._handlers.TryGetValue(eventType, out var list)) + { + list.Remove(handler); + if (list.Count == 0) bus._handlers.Remove(eventType); + } + } + } + } +} diff --git a/src/SmartSideBAR.Core/Policy/PolicyService.cs b/src/SmartSideBAR.Core/Policy/PolicyService.cs new file mode 100644 index 0000000..383cb1e --- /dev/null +++ b/src/SmartSideBAR.Core/Policy/PolicyService.cs @@ -0,0 +1,30 @@ +// Core/Policy/PolicyService.cs —— disabledModules 白名单 (P2-1/P2-3 语义): +// 驱动服务启动跳过 + rail 图标过滤。模块标识与 v1.2.0 对齐。 +using SmartSideBAR.Core.Configuration; + +namespace SmartSideBAR.Core.Policy; + +public static class ModuleIds +{ + public const string Ime = "ime"; + public const string Capture = "capture"; + public const string Annotate = "annotate"; + public const string Longshot = "longshot"; + public const string Recorder = "recorder"; + public const string Usb = "usb"; + public const string Printer = "printer"; + public const string TaskMgr = "taskmgr"; + public const string Links = "links"; + public const string Reminder = "reminder"; + public const string FloatBall = "floatball"; + public const string Settings = "settings"; + public const string Diagnostics = "diagnostics"; +} + +public sealed class PolicyService(ConfigService config) +{ + public bool IsDisabled(string moduleId) => + config.Current.Policy.DisabledModules.Contains(moduleId, StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList DisabledModules() => config.Current.Policy.DisabledModules.ToArray(); +} diff --git a/src/SmartSideBAR.Core/Scheduling/SchedulerService.cs b/src/SmartSideBAR.Core/Scheduling/SchedulerService.cs new file mode 100644 index 0000000..df5183f --- /dev/null +++ b/src/SmartSideBAR.Core/Scheduling/SchedulerService.cs @@ -0,0 +1,209 @@ +// Core/Scheduling/SchedulerService.cs —— TimeProvider 可测调度 (方案 §6.10) +// 行为基线 = v1.2.0 scheduler.ts: once 触发即移除 / interval 顺延 / hourly 对齐整点 / 贪睡 / +// C1 变更检测落盘。改进: 启动时过期周期提醒**不补触发**(直接推进到未来槽位) —— 方案明确要求。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Messaging; + +namespace SmartSideBAR.Core.Scheduling; + +public sealed record ReminderDue(string Id, ReminderKind Kind, string? Note, long DueAtEpochMs); + +public sealed class SchedulerService( + ConfigService config, + IEventBus bus, + TimeProvider time, + ILogger? log = null) : IDisposable +{ + private const long OnceExpireGraceMs = 3_600_000; // 与 v1.2.0 一致: 过期 >1h 的 once 启动时清理 + + private readonly Lock _gate = new(); + private ITimer? _timer; + private List _reminders = []; + + public void Start() + { + lock (_gate) + { + if (_timer is not null) return; + _reminders = [.. config.Current.Reminders]; + + var now = NowMs(); + var changed = false; + + // once 过期 >1h → 清理不触发 (v1.2.0 语义); ≤1h → 保留, 由 tick 补触发一次 + var expired = _reminders + .Where(r => r.Kind == ReminderKind.Once && r.SnoozedUntil is null && now - r.At > OnceExpireGraceMs) + .ToList(); + if (expired.Count > 0) + { + _reminders.RemoveAll(expired.Contains); + changed = true; + log?.LogInformation("[Scheduler] 清理 {Count} 条过期 once 提醒", expired.Count); + } + + // 方案 §6.10: 过期周期提醒启动时不补触发 —— 直接推进到下一个未来槽位 + foreach (var r in _reminders.ToArray()) // 快照遍历: 循环体内会原位替换元素 + { + if (r.Kind == ReminderKind.Once || EffectiveAt(r) > now) continue; + var advanced = r with { At = NextSlotAfter(r, now), SnoozedUntil = null }; + var idx = _reminders.IndexOf(r); + _reminders[idx] = advanced; + changed = true; + log?.LogInformation("[Scheduler] 启动追赶: {Id} 推进到未来槽位(不补触发)", r.Id); + } + + if (changed) PersistIfChanged(); + + _timer = time.CreateTimer(OnTick, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + log?.LogInformation("[Scheduler] 已启动, {Count} 条提醒", _reminders.Count); + } + } + + public void Stop() + { + lock (_gate) + { + _timer?.Dispose(); + _timer = null; + log?.LogInformation("[Scheduler] 已停止"); + } + } + + public IReadOnlyList ListReminders() { lock (_gate) return _reminders.ToArray(); } + + public Reminder AddReminder(ReminderKind kind, long atEpochMs, string? note = null, int? repeatMin = null) + { + var reminder = new Reminder + { + Id = $"r{Guid.NewGuid().ToString("N")[..10]}", + Kind = kind, + At = atEpochMs, + Note = note, + RepeatMin = kind == ReminderKind.Interval ? Math.Max(1, repeatMin ?? 5) : repeatMin, + }; + lock (_gate) + { + _reminders.Add(reminder); + PersistIfChanged(); + } + return reminder; + } + + public bool RemoveReminder(string id) + { + lock (_gate) + { + var removed = _reminders.RemoveAll(r => r.Id == id) > 0; + if (removed) PersistIfChanged(); + return removed; + } + } + + public bool Snooze(string id, int minutes) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minutes, 1); + lock (_gate) + { + var idx = _reminders.FindIndex(r => r.Id == id); + if (idx < 0) return false; + var r = _reminders[idx]; + _reminders[idx] = r with { SnoozedUntil = NowMs() + minutes * 60_000L }; + PersistIfChanged(); + return true; + } + } + + private void OnTick(object? state) + { + var dueEvents = new List(); + lock (_gate) + { + var now = NowMs(); + var changed = false; + var toRemove = new List(); + + foreach (var original in _reminders.ToArray()) + { + var r = original; + // 贪睡中 — 跳过; 贪睡已过期 — 清除标记 + if (r.SnoozedUntil is { } snoozed) + { + if (now < snoozed) continue; + var idx = _reminders.IndexOf(original); + r = r with { SnoozedUntil = null }; + _reminders[idx] = r; + changed = true; + } + + if (EffectiveAt(r) > now) continue; + + dueEvents.Add(new ReminderDue(r.Id, r.Kind, r.Note, r.At)); + switch (r.Kind) + { + case ReminderKind.Once: + toRemove.Add(r.Id); // 触发即移除 (v1.2.0: firedOnceIds + auto-remove) + break; + case ReminderKind.Interval: + Replace(r, r with { At = NextSlotAfter(r, now) }); + changed = true; + break; + case ReminderKind.Hourly: + Replace(r, r with { At = NextFullHour() }); + changed = true; + break; + } + } + + if (toRemove.Count > 0) + { + _reminders.RemoveAll(r => toRemove.Contains(r.Id)); + changed = true; + log?.LogInformation("[Scheduler] 自动移除 {Count} 条已触发 once 提醒", toRemove.Count); + } + if (changed) PersistIfChanged(); + } + + foreach (var evt in dueEvents) bus.Publish(evt); // 锁外广播 + + void Replace(Reminder old, Reminder @new) + { + var idx = _reminders.IndexOf(old); + if (idx >= 0) _reminders[idx] = @new; + } + } + + private void PersistIfChanged() + { + // C1: 仅 reminders 实际变化时写盘 (落盘走 Set 的白名单+ConfigChanged 广播路径) + if (config.SetRemindersIfChanged(_reminders)) + { + log?.LogDebug("[Scheduler] reminders 变更已落盘 ({Count} 条)", _reminders.Count); + } + } + + private static long EffectiveAt(Reminder r) => Math.Max(r.At, r.SnoozedUntil ?? long.MinValue); + + private static long NextSlotAfter(Reminder r, long now) + { + var step = r.Kind == ReminderKind.Hourly + ? 3_600_000 + : Math.Max(1, r.RepeatMin ?? 5) * 60_000L; + var next = r.At; + while (next <= now) next += step; + return next; + } + + /// 下一个本地整点 (v1.2.0: setHours(+1,0,0,0) 本地时间语义) + private long NextFullHour() + { + var localNow = time.GetLocalNow(); + var nextHour = new DateTime(localNow.Year, localNow.Month, localNow.Day, localNow.Hour, 0, 0) + .AddHours(1); + return new DateTimeOffset(nextHour, localNow.Offset).ToUnixTimeMilliseconds(); + } + + private long NowMs() => time.GetUtcNow().ToUnixTimeMilliseconds(); + + public void Dispose() => Stop(); +} diff --git a/src/SmartSideBAR.Core/SmartSideBAR.Core.csproj b/src/SmartSideBAR.Core/SmartSideBAR.Core.csproj new file mode 100644 index 0000000..75a53fc --- /dev/null +++ b/src/SmartSideBAR.Core/SmartSideBAR.Core.csproj @@ -0,0 +1,13 @@ + + + + + net10.0 + SmartSideBAR.Core + + + + + + + diff --git a/src/SmartSideBAR.Windows/AppBar/AppBarApi.cs b/src/SmartSideBAR.Windows/AppBar/AppBarApi.cs new file mode 100644 index 0000000..f3ab963 --- /dev/null +++ b/src/SmartSideBAR.Windows/AppBar/AppBarApi.cs @@ -0,0 +1,153 @@ +// Windows/AppBar/AppBarApi.cs —— SHAppBarMessage 真实现; QUERYPOS 语义逐行对齐 src/native/appbar.cc +// 所有调用经 PhysicalDpiScope 强制 PMv2 线程上下文 —— 宿主线程上下文不确定, 物理像素必须自证 (K4)。 +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.AppBar; + +public sealed class AppBarApi(ILogger? log = null) : IAppBarApi +{ + /// PMv2 线程上下文作用域: 块内所有 Win32 坐标为物理像素。 + public readonly struct PhysicalDpiScope : IDisposable + { + private readonly nint _prev; + public PhysicalDpiScope() + { + _prev = AppBarNative.SetThreadDpiAwarenessContext( + AppBarNative.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + public void Dispose() + { + if (_prev != 0) AppBarNative.SetThreadDpiAwarenessContext(_prev); + } + } + + public bool Register(nint hwnd, uint callbackMsg) + { + if (hwnd == 0) return false; + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = hwnd, + uCallbackMessage = callbackMsg, + }; + var ok = AppBarNative.SHAppBarMessage(AppBarNative.ABM_NEW, ref abd) != 0; + log?.LogInformation("[AppBar] ABM_NEW hwnd=0x{Hwnd:X} → {Ok}", hwnd, ok); + return ok; + } + + public void Remove(nint hwnd) + { + if (hwnd == 0) return; + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = hwnd, + }; + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_REMOVE, ref abd); + log?.LogInformation("[AppBar] ABM_REMOVE hwnd=0x{Hwnd:X}", hwnd); + } + + public AppBarRect QueryAndSetPos(nint hwnd, uint edge, AppBarRect want) + { + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = hwnd, + uEdge = edge, + rc = new AppBarNative.RECT + { + Left = want.X, + Top = want.Y, + Right = want.X + want.W, + Bottom = want.Y + want.H, + }, + }; + + // Query: 系统按任务栏/其他 AppBar 调整区间 (只动区间, 不保尺寸) + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_QUERYPOS, ref abd); + + // 与 appbar.cc 一致: 按提议边恢复尺寸 (沿边维度保留系统裁剪 —— 自动避开任务栏) + switch (abd.uEdge) + { + case AppBarNative.ABE_LEFT: abd.rc.Right = abd.rc.Left + want.W; break; + case AppBarNative.ABE_RIGHT: abd.rc.Left = abd.rc.Right - want.W; break; + case AppBarNative.ABE_TOP: abd.rc.Bottom = abd.rc.Top + want.H; break; + case AppBarNative.ABE_BOTTOM: abd.rc.Top = abd.rc.Bottom - want.H; break; + } + + // Set: 系统更新内部 WorkArea + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_SETPOS, ref abd); + + return new AppBarRect(abd.rc.Left, abd.rc.Top, abd.rc.W, abd.rc.H); + } + + public void Activate(nint hwnd) + { + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = hwnd, + }; + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_ACTIVATE, ref abd); + } + + public void WindowPosChanged(nint hwnd) + { + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = hwnd, + }; + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_WINDOWPOSCHANGED, ref abd); + } + + public bool MoveWindow(nint hwnd, AppBarRect rect) + { + using var scope = new PhysicalDpiScope(); + return AppBarNative.MoveWindow(hwnd, rect.X, rect.Y, rect.W, rect.H, true); + } + + public TaskbarInfo? GetTaskbarPos() + { + using var scope = new PhysicalDpiScope(); + var abd = new AppBarNative.APPBARDATA { cbSize = (uint)Marshal.SizeOf() }; + _ = AppBarNative.SHAppBarMessage(AppBarNative.ABM_GETTASKBARPOS, ref abd); + if (abd.rc.W <= 0 || abd.rc.H <= 0) return null; + var rect = new AppBarRect(abd.rc.Left, abd.rc.Top, abd.rc.W, abd.rc.H); + return new TaskbarInfo(InferEdge(rect), rect); // 系统不填 uEdge, 由矩形对显示器推断 + } + + public MonitorRects GetMonitorOf(nint hwnd) + { + using var scope = new PhysicalDpiScope(); + var hMonitor = AppBarNative.MonitorFromWindow(hwnd, AppBarNative.MONITOR_DEFAULTTONEAR); + var mi = new AppBarNative.MONITORINFO { cbSize = (uint)Marshal.SizeOf() }; + if (!AppBarNative.GetMonitorInfo(hMonitor, ref mi)) + { + // 兜底: 主显示器 96DPI 假设 (仅极端失败路径) + log?.LogWarning("[AppBar] GetMonitorInfo 失败, 使用主屏兜底矩形"); + return new MonitorRects(new AppBarRect(0, 0, 1920, 1080), new AppBarRect(0, 0, 1920, 1040)); + } + return new MonitorRects( + new AppBarRect(mi.rcMonitor.Left, mi.rcMonitor.Top, mi.rcMonitor.W, mi.rcMonitor.H), + new AppBarRect(mi.rcWork.Left, mi.rcWork.Top, mi.rcWork.W, mi.rcWork.H)); + } + + private static uint InferEdge(AppBarRect rect) + { + var hMonitor = AppBarNative.MonitorFromPoint(new AppBarNative.POINT(rect.X, rect.Y), 1 /*MONITOR_DEFAULTTOPRIMARY*/); + var mi = new AppBarNative.MONITORINFO { cbSize = (uint)Marshal.SizeOf() }; + if (!AppBarNative.GetMonitorInfo(hMonitor, ref mi)) return AppBarNative.ABE_BOTTOM; + if (rect.Y == mi.rcMonitor.Top && rect.H != mi.rcMonitor.H) return AppBarNative.ABE_TOP; + if (rect.Bottom == mi.rcMonitor.Bottom && rect.H != mi.rcMonitor.H) return AppBarNative.ABE_BOTTOM; + if (rect.X == mi.rcMonitor.Left) return AppBarNative.ABE_LEFT; + return AppBarNative.ABE_RIGHT; + } +} diff --git a/src/SmartSideBAR.Windows/AppBar/AppBarNative.cs b/src/SmartSideBAR.Windows/AppBar/AppBarNative.cs new file mode 100644 index 0000000..8410c21 --- /dev/null +++ b/src/SmartSideBAR.Windows/AppBar/AppBarNative.cs @@ -0,0 +1,104 @@ +// Windows/AppBar/AppBarNative.cs —— §5.2 P/Invoke 层 (方案附录蓝本落地) +// 物理(K4): APPBARDATA/RECT/MONITORINFO 全部物理像素 —— 与系统协商的唯一坐标轨。 +// 参考: https://learn.microsoft.com/zh-cn/windows/win32/shell/application-desktop-toolbars +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace SmartSideBAR.Windows.AppBar; + +internal static partial class AppBarNative +{ + // ---- ABM_* 消息 (SHAppBarMessage 第一参数) ---- + public const uint ABM_NEW = 0x0000; + public const uint ABM_REMOVE = 0x0001; + public const uint ABM_QUERYPOS = 0x0002; + public const uint ABM_SETPOS = 0x0003; + public const uint ABM_GETTASKBARPOS = 0x0005; + public const uint ABM_ACTIVATE = 0x0006; // K2: WM_ACTIVATE 时上报 + public const uint ABM_GETAUTOHIDEBAR = 0x0007; + public const uint ABM_SETAUTOHIDEBAR = 0x0008; // K6: 自动隐藏 (P2) + public const uint ABM_WINDOWPOSCHANGED = 0x0009; // K2: WM_WINDOWPOSCHANGED 时上报 + + // ---- ABN_* 通知码 (回调消息 wParam) ---- + public const uint ABN_STATECHANGE = 0x0000; + public const uint ABN_POSCHANGED = 0x0001; // K1 + public const uint ABN_FULLSCREENAPP = 0x0002; // K3 + public const uint ABN_WINDOWARRANGE = 0x0003; + + // ---- 边缘 ---- + public const uint ABE_LEFT = 0, ABE_TOP = 1, ABE_RIGHT = 2, ABE_BOTTOM = 3; + + // ---- 消息 ---- + public const uint WM_ACTIVATE = 0x0006; + public const uint WM_DISPLAYCHANGE = 0x007E; // K7: 显示器变化 + public const uint WM_DPICHANGED = 0x02E0; + public const uint WM_DEVICECHANGE = 0x0219; // Wave B UsbWatcher 复用 + + /// 应用栏回调消息: WM_APP + 0xBAB (应用自定义区; 刻意避开 v1.2.0 的 WM_USER+1, + /// 防止与任何遗留注册/其他子类链冲突, 同时便于孤儿注册自愈识别)。 + public const uint AppBarCallbackMsg = 0x8000 + 0x0BAB; + + public const uint MONITOR_DEFAULTTONEAR = 2; + + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public POINT(int x, int y) { X = x; Y = y; } + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int Left, Top, Right, Bottom; + + public readonly int W => Right - Left; + public readonly int H => Bottom - Top; + } + + [StructLayout(LayoutKind.Sequential)] + public struct APPBARDATA + { + public uint cbSize; + public nint hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public nint lParam; + } + + [StructLayout(LayoutKind.Sequential)] + public struct MONITORINFO + { + public uint cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + [LibraryImport("shell32.dll")] + internal static partial nuint SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); + + public static readonly nint DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4; + + /// 切换调用线程 DPI 感知上下文, 返回原上下文 (恢复用)。 + /// 宿主 (Avalonia) 线程上下文不受我们控制 —— AppBar 协商一律临时切到 PMv2, + /// 保证 MonitorInfo/MoveWindow 全部物理像素 (K4 单轨的硬保证)。 + [LibraryImport("user32.dll")] + internal static partial nint SetThreadDpiAwarenessContext(nint context); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool MoveWindow(nint hWnd, int x, int y, int w, int h, [MarshalAs(UnmanagedType.Bool)] bool repaint); + + [LibraryImport("user32.dll")] + internal static partial nint MonitorFromWindow(nint hwnd, uint flags); + + [LibraryImport("user32.dll")] + internal static partial nint MonitorFromPoint(POINT pt, uint flags); + + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetMonitorInfo(nint hMonitor, ref MONITORINFO lpmi); +} diff --git a/src/SmartSideBAR.Windows/AppBar/AppBarService.cs b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs new file mode 100644 index 0000000..6cc802f --- /dev/null +++ b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs @@ -0,0 +1,207 @@ +// Windows/AppBar/AppBarService.cs —— §5.3 生命周期 + 通知状态机 (v1.2.0 缺口 K1/K2/K3/K4/K7 根修) +// 通知链: 系统 → WndProcHook(子类化) → OnWndProc 状态机 → Reposition/EventBus。 +// 坐标 (K4): 与系统协商及窗口定位全链路物理像素 —— MoveWindow 直达, 无 DIP 换算。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.AppBar; + +// ---- EventBus 事件 (UI 层订阅以同步面板布局) ---- +public sealed record AppBarGeometryChanged(AppBarRect Rect, int WidthPx); +public sealed record FullscreenAppChanged(bool Active); +public sealed record WindowArrangePending(bool Hiding); + +/// 侧栏应用栏编排器。 +/// 生命周期: Attach(自愈+注册+首次定位) ⇄ OnAppBarNotification(自适应) → Detach(注销)。 +public sealed class AppBarService( + IAppBarApi api, + IWndProcHook hook, + IEventBus bus, + ILogger? log = null) : IDisposable +{ + /// v1.1 悬浮玻璃胶囊: 上下各留 8% 工作区高度 (不顶到屏幕上下缘)。 + public const double CapsuleMarginRatio = 0.08; + + private nint _hwnd; + private uint _edge = AppBarNative.ABE_RIGHT; + private int _railPx = 64; // rail 收起宽度 (物理像素) + private int _expandedPx = 420; // 面板展开宽度 (物理像素) + private bool _registered; + private bool _fullscreen; // K3: 全屏应用在场标志 + private bool _userExpanded; // 用户偏好; 全屏结束后据此恢复 (V5) + private bool _docked; // v1.1 dock 语义: 收起为角落小方块 + + public bool IsRegistered => _registered; + public AppBarRect? GrantedRect { get; private set; } + public bool FullscreenActive => _fullscreen; + public int CurrentWidthPx => _userExpanded ? _expandedPx : _railPx; + public bool Docked => _docked; + + /// 注册 + 首次定位。失败返回 false (调用方回退 Topmost 置顶, 与 v1.2.0 同策略)。 + public bool Attach(nint hwnd, AppBarEdge edge, int railWidthPx, int expandedWidthPx) + { + if (_registered) Detach(); + _hwnd = hwnd; + _edge = Map(edge); + _railPx = Math.Max(2, railWidthPx); + _expandedPx = Math.Max(_railPx, expandedWidthPx); + _userExpanded = false; + _fullscreen = false; + + // V6 自愈: 上次强杀可能残留孤儿注册 —— ABM_REMOVE 对未注册 hwnd 无副作用, 先补发一次 + api.Remove(hwnd); + + if (!api.Register(hwnd, AppBarNative.AppBarCallbackMsg)) + { + log?.LogWarning("[AppBar] 注册失败 (可能与他栏冲突), 回退 alwaysOnTop 模式"); + return false; + } + _registered = true; + + // K1/K2/K3 核心: 挂接 WndProc —— v1.2.0 完全缺失的通知闭环 + hook.Add(hwnd, OnWndProc); + Reposition(CurrentWidthPx); + log?.LogInformation("[AppBar] 已注册 edge={Edge} rail={Rail}px expanded={Expanded}px", + edge, _railPx, _expandedPx); + return true; + } + + /// 展开/收起 (window:resize 通道落点)。 + public void SetExpanded(bool expanded) + { + _userExpanded = expanded; + Reposition(CurrentWidthPx); + } + + /// v1.1 dock 语义: docked = 指定边距尺寸的小方块停靠在工作区底角。 + public void SetDocked(bool docked, int dockSizePx) + { + _docked = docked; + _dockSizePx = Math.Max(2, dockSizePx); + Reposition(CurrentWidthPx); + } + private int _dockSizePx = 78; + + /// 重排: 注册后 / 展开收起 / ABN_POSCHANGED / 分辨率与任务栏变化 (K1/K4)。 + /// 锚点必须是 rcMonitor 全屏矩形: rcWork 已扣除自身 AppBar 占位, 若以它为锚, + /// 每次 Reposition 都会向屏幕内侧漂移一个带宽 —— 对方案 §5.3 蓝本的勘误。 + /// 垂直范围 = 工作区高度 84% 的悬浮胶囊 (上下各留 8%, 对齐 v1.1 玻璃胶囊设计); + /// 水平锚定显示器边缘, 沿边裁剪交给 ABM_QUERYPOS。 + public void Reposition(int widthPx) + { + if (!_registered || _hwnd == 0) return; + var mon = api.GetMonitorOf(_hwnd); + + // v1.1 dock 语义: 工作区底角小方块 (side-aware) + if (_docked && (_edge is AppBarNative.ABE_LEFT or AppBarNative.ABE_RIGHT)) + { + // 先注销自身旧占位: QUERYPOS 会为已注册 AppBar(含我们自己)让位, 否则方块逐次左漂。 + // ABM_REMOVE 后系统 WorkArea 恢复滞后一步 —— 收敛循环直至授予位==期望位。 + var dockWant = _edge == AppBarNative.ABE_RIGHT + ? new AppBarRect(mon.Work.X + mon.Work.W - _dockSizePx, + mon.Work.Y + mon.Work.H - _dockSizePx, _dockSizePx, _dockSizePx) + : new AppBarRect(mon.Work.X, mon.Work.Y + mon.Work.H - _dockSizePx, _dockSizePx, _dockSizePx); + var dockGranted = dockWant; + for (var attempt = 0; attempt < 4; attempt++) + { + api.Remove(_hwnd); + dockGranted = api.QueryAndSetPos(_hwnd, _edge, dockWant); + if (Math.Abs(dockGranted.X - dockWant.X) <= 2 && Math.Abs(dockGranted.Y - dockWant.Y) <= 2) + { + break; + } + } + api.WindowPosChanged(_hwnd); + if (!api.MoveWindow(_hwnd, dockGranted)) + { + log?.LogWarning("[AppBar] MoveWindow 失败 (hwnd=0x{Hwnd:X})", _hwnd); + } + GrantedRect = dockGranted; + bus.Publish(new AppBarGeometryChanged(dockGranted, _dockSizePx)); + log?.LogInformation("[AppBar] dock 重排 → ({X},{Y}) {W}x{H}px", dockGranted.X, dockGranted.Y, dockGranted.W, dockGranted.H); + return; + } + + var capsuleH = (int)Math.Round(mon.Work.H * (1 - 2 * CapsuleMarginRatio)); + var capsuleY = mon.Work.Y + (int)Math.Round(mon.Work.H * CapsuleMarginRatio); + var want = _edge switch + { + AppBarNative.ABE_LEFT => new AppBarRect(mon.Monitor.X, capsuleY, widthPx, capsuleH), + AppBarNative.ABE_RIGHT => new AppBarRect(mon.Monitor.X + mon.Monitor.W - widthPx, capsuleY, widthPx, capsuleH), + AppBarNative.ABE_TOP => new AppBarRect(mon.Monitor.X, mon.Monitor.Y, mon.Monitor.W, widthPx), + _ => new AppBarRect(mon.Monitor.X, mon.Monitor.Y + mon.Monitor.H - widthPx, mon.Monitor.W, widthPx), + }; + var granted = api.QueryAndSetPos(_hwnd, _edge, want); // 系统审批 (避让任务栏/他栏) + api.WindowPosChanged(_hwnd); // K2: 位置变更上报 + if (!api.MoveWindow(_hwnd, granted)) // K4: 物理像素直达, 无 DIP 换算 + { + log?.LogWarning("[AppBar] MoveWindow 失败 (hwnd=0x{Hwnd:X})", _hwnd); + } + GrantedRect = granted; + bus.Publish(new AppBarGeometryChanged(granted, widthPx)); + log?.LogInformation("[AppBar] 重排 → ({X},{Y}) {W}x{H}px", granted.X, granted.Y, granted.W, granted.H); + } + + /// WndProc 状态机 —— K1/K2/K3/K7 的通知闭环。 + private nint OnWndProc(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled) + { + if (msg == AppBarNative.AppBarCallbackMsg) + { + handled = true; + switch (wParam) + { + case AppBarNative.ABN_POSCHANGED: // 任务栏换边/他栏变化/WorkArea 变 + Reposition(CurrentWidthPx); + break; + + case AppBarNative.ABN_FULLSCREENAPP: // 授课视频等全屏应用开/关 (K3) + _fullscreen = lParam != 0; + bus.Publish(new FullscreenAppChanged(_fullscreen)); + // 策略: 全屏在场保持注册 (教师仍可唤出) 但先收起为 rail; 关闭后按用户偏好恢复 (V5) + Reposition(_fullscreen ? _railPx : CurrentWidthPx); + break; + + case AppBarNative.ABN_STATECHANGE: // 任务栏「自动隐藏/总在顶部」切换 + Reposition(CurrentWidthPx); + break; + + case AppBarNative.ABN_WINDOWARRANGE: // 层叠/平铺: TRUE=即将排列 FALSE=完成 + bus.Publish(new WindowArrangePending(wParam != 0)); + break; + } + return 0; + } + + if (!_registered) return 0; + if (msg == AppBarNative.WM_ACTIVATE) + { + api.Activate(_hwnd); // K2: 激活状态上报 (多 AppBar z 序维护) + } + else if (msg is AppBarNative.WM_DISPLAYCHANGE or AppBarNative.WM_DPICHANGED) + { + Reposition(CurrentWidthPx); // K7: 分辨率/DPI 变化重协商 (ABN_POSCHANGED 通常已带一次, 幂等) + } + return 0; // handled=false → 其余消息交还原窗口过程 + } + + /// 退出/窗口销毁前必须调用 —— 否则系统 WorkArea 泄漏 (v1.2.0 语义保留)。 + public void Detach() + { + if (!_registered) return; + hook.Remove(_hwnd, OnWndProc); + api.Remove(_hwnd); + _registered = false; + log?.LogInformation("[AppBar] 已注销, 系统 WorkArea 恢复"); + } + + public void Dispose() => Detach(); + + private static uint Map(AppBarEdge edge) => edge switch + { + AppBarEdge.Left => AppBarNative.ABE_LEFT, + AppBarEdge.Top => AppBarNative.ABE_TOP, + AppBarEdge.Right => AppBarNative.ABE_RIGHT, + _ => AppBarNative.ABE_BOTTOM, + }; +} diff --git a/src/SmartSideBAR.Windows/AppBar/IAppBarApi.cs b/src/SmartSideBAR.Windows/AppBar/IAppBarApi.cs new file mode 100644 index 0000000..5d0b119 --- /dev/null +++ b/src/SmartSideBAR.Windows/AppBar/IAppBarApi.cs @@ -0,0 +1,41 @@ +// Windows/AppBar/IAppBarApi.cs —— Win32 AppBar API 抽象: 测试注入假实现 (方案 §5.3 / §7.1) +namespace SmartSideBAR.Windows.AppBar; + +public enum AppBarEdge { Left, Top, Right, Bottom } + +public readonly record struct AppBarRect(int X, int Y, int W, int H) +{ + public static readonly AppBarRect Empty = new(0, 0, 0, 0); + public readonly int Right => X + W; + public readonly int Bottom => Y + H; +} + +public readonly record struct TaskbarInfo(uint Edge, AppBarRect Rect); + +public readonly record struct MonitorRects(AppBarRect Monitor, AppBarRect Work); + +public interface IAppBarApi +{ + /// ABM_NEW —— 返回 false = 与他栏冲突/不可用, 调用方回退 alwaysOnTop。 + bool Register(nint hwnd, uint callbackMsg); + + /// ABM_REMOVE —— 未注册时调用无副作用 (孤儿注册自愈利用此点)。 + void Remove(nint hwnd); + + /// ABM_QUERYPOS → 按边恢复尺寸 → ABM_SETPOS; 返回系统授予矩形 (语义对齐 appbar.cc)。 + AppBarRect QueryAndSetPos(nint hwnd, uint edge, AppBarRect want); + + /// ABM_ACTIVATE —— WM_ACTIVATE 时上报 (K2, 多 AppBar z 序维护)。 + void Activate(nint hwnd); + + /// ABM_WINDOWPOSCHANGED —— 位置变更上报 (K2)。 + void WindowPosChanged(nint hwnd); + + /// 以物理像素直接落位窗口 (K4 单轨: 与系统协商后由本层统一执行)。 + bool MoveWindow(nint hwnd, AppBarRect rect); + + TaskbarInfo? GetTaskbarPos(); + + /// hwnd 所在显示器的全屏矩形与工作区矩形 (物理像素)。 + MonitorRects GetMonitorOf(nint hwnd); +} diff --git a/src/SmartSideBAR.Windows/Audio/SoundService.cs b/src/SmartSideBAR.Windows/Audio/SoundService.cs new file mode 100644 index 0000000..86d94ff --- /dev/null +++ b/src/SmartSideBAR.Windows/Audio/SoundService.cs @@ -0,0 +1,71 @@ +// Windows/Audio/SoundService.cs —— 提醒铃声: winmm 直调 (PlaySound 别名 + MCI 播 MP3)。 +// 替代 v1.2.0 的 PowerShell SystemSounds 子进程; 消费 reminderSound 全字段 (音量/重复/间隔)。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Audio; + +public interface ISoundService +{ + /// 异步播放; 重复次数与间隔来自配置。返回播放任务 (fire-and-forget 亦可)。 + Task PlayReminderAsync(ReminderSoundConfig cfg, CancellationToken ct = default); +} + +public sealed class SoundService(ILogger? log = null) : ISoundService +{ + private static readonly Dictionary PresetAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["default"] = "SystemAsterisk", + ["bell"] = "SystemAsterisk", + ["chime"] = "SystemHand", + ["alarm"] = "SystemExclamation", + }; + + public async Task PlayReminderAsync(ReminderSoundConfig cfg, CancellationToken ct = default) + { + var repeat = Math.Clamp(cfg.Repeat, 1, 5); + for (var i = 0; i < repeat; i++) + { + ct.ThrowIfCancellationRequested(); + if (cfg.Preset == "custom" && !string.IsNullOrEmpty(cfg.Mp3Path) && File.Exists(cfg.Mp3Path)) + { + PlayMp3(cfg.Mp3Path, cfg.Volume); + } + else + { + var alias = PresetAliases.GetValueOrDefault(cfg.Preset, "SystemAsterisk"); + _ = Winmm.PlaySound(alias, 0, Winmm.SND_ALIAS | Winmm.SND_ASYNC); + } + if (i < repeat - 1) + { + await Task.Delay(Math.Max(100, cfg.RepeatInterval), ct).ConfigureAwait(false); + } + } + } + + /// MCI 播放 MP3 (音量 0~1 → 0~1000); fire-and-forget, 结束自动关闭别名。 + private void PlayMp3(string path, double volume) + { + var alias = "ssbReminder"; + var open = Winmm.mciSendString($"open \"{path}\" type mpegvideo alias {alias}", null, 0, 0); + if (open != 0) + { + log?.LogWarning("[Sound] MCI open 失败 ({Code}): {Path}", open, path); + return; + } + var vol = Math.Clamp(volume, 0, 1); + _ = Winmm.mciSendString($"setaudio {alias} volume to {(int)(vol * 1000)}", null, 0, 0); + _ = Winmm.mciSendString($"play {alias}", null, 0, 0); + // 播放时长未知: 5s 兜底关闭 (课堂铃声场景足够) + _ = Task.Run(async () => + { + try + { + await Task.Delay(5000).ConfigureAwait(false); + Winmm.mciSendString($"close {alias}", null, 0, 0); + } + catch { /* 尽力而为 */ } + }); + } +} diff --git a/src/SmartSideBAR.Windows/Capture/GdiCapture.cs b/src/SmartSideBAR.Windows/Capture/GdiCapture.cs new file mode 100644 index 0000000..438d24a --- /dev/null +++ b/src/SmartSideBAR.Windows/Capture/GdiCapture.cs @@ -0,0 +1,103 @@ +// Windows/Capture/GdiCapture.cs —— §6.2: BitBlt 物理像素直接裁剪 —— 与选框同一坐标系, +// A4 类多屏/DPI 偏移在结构上不可能复现。保存编码走 System.Drawing (进程内, 零子进程)。 +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Capture; + +public interface ICaptureApi +{ + /// 按物理像素矩形截屏 (PMv2 上下文保证坐标物理语义)。 + Bitmap? CaptureRegion(AppBarRect regionPx); + + /// 截取 hwnd 所在显示器全屏。 + Bitmap? CaptureMonitor(nint hwnd); + + string SaveImage(Bitmap bmp, string dir, string format); +} + +public sealed class GdiCapture(ILogger? log = null) : ICaptureApi +{ + public Bitmap? CaptureRegion(AppBarRect regionPx) + { + if (regionPx.W <= 0 || regionPx.H <= 0) return null; + try + { + var bmp = new Bitmap(regionPx.W, regionPx.H, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bmp); + var hdcDest = g.GetHdc(); + var hdcSrc = NativeCapture.GetDC(0); + try + { + _ = NativeCapture.BitBlt(hdcDest, 0, 0, regionPx.W, regionPx.H, + hdcSrc, regionPx.X, regionPx.Y, + NativeCapture.SRCCOPY | NativeCapture.CAPTUREBLT); + } + finally + { + g.ReleaseHdc(hdcDest); + _ = NativeCapture.ReleaseDC(0, hdcSrc); + } + return bmp; + } + catch (Exception ex) + { + log?.LogError(ex, "[Capture] 区域截图失败 ({X},{Y} {W}x{H})", regionPx.X, regionPx.Y, regionPx.W, regionPx.H); + return null; + } + } + + public Bitmap? CaptureMonitor(nint hwnd) + { + using var scope = new AppBarApi.PhysicalDpiScope(); + var mon = AppBarApiProbe.MonitorRectOf(hwnd); + return CaptureRegion(mon); + } + public string SaveImage(Bitmap bmp, string dir, string format) + { + Directory.CreateDirectory(dir); + var ext = format.Equals("jpg", StringComparison.OrdinalIgnoreCase) ? "jpg" : "png"; + var name = $"shot_{DateTime.Now:yyyyMMdd_HHmmss}.{ext}"; + var path = Path.Combine(dir, name); + var encoder = ext == "jpg" ? ImageFormat.Jpeg : ImageFormat.Png; + bmp.Save(path, encoder); + log?.LogInformation("[Capture] 已保存 {Path} ({W}x{H})", path, bmp.Width, bmp.Height); + return path; + } +} + +/// AppBarApi 内部 PMv2 作用域的对外复用点 (显示器物理矩形查询)。 +public static class AppBarApiProbe +{ + public static AppBarRect MonitorRectOf(nint hwnd) + { + var hMonitor = AppBarNative.MonitorFromWindow(hwnd, AppBarNative.MONITOR_DEFAULTTONEAR); + var mi = new AppBarNative.MONITORINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!AppBarNative.GetMonitorInfo(hMonitor, ref mi)) + { + return new AppBarRect(0, 0, 1920, 1080); + } + return new AppBarRect(mi.rcMonitor.Left, mi.rcMonitor.Top, mi.rcMonitor.W, mi.rcMonitor.H); + } +} + +/// GDI 位块传输 P/Invoke。 +internal static partial class NativeCapture +{ + public const int SRCCOPY = 0x00CC0020; + public const int CAPTUREBLT = 0x40000000; + + [LibraryImport("user32.dll")] + internal static partial nint GetDC(nint hwnd); + + [LibraryImport("user32.dll")] + internal static partial int ReleaseDC(nint hwnd, nint hdc); + + [LibraryImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool BitBlt(nint hdcDest, int x, int y, int w, int h, nint hdcSrc, int xSrc, int ySrc, int rop); +} diff --git a/src/SmartSideBAR.Windows/Capture/LongshotService.cs b/src/SmartSideBAR.Windows/Capture/LongshotService.cs new file mode 100644 index 0000000..4cec565 --- /dev/null +++ b/src/SmartSideBAR.Windows/Capture/LongshotService.cs @@ -0,0 +1,258 @@ +// Windows/Capture/LongshotService.cs —— §6.4 长截图: EnumWindows 定位 + BitBlt 滚动帧 + 线性拼接。 +// 跨屏/DPI 用 DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) 物理矩形 (A5 根修)。 +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Capture; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.AppBar; + +namespace SmartSideBAR.Windows.Capture; + +public sealed record LongshotTarget(nint Hwnd, string Title); +public sealed record LongshotProgress(int HeightPx, int Frames); +public sealed record LongshotCountdown(int Seconds); +public sealed record LongshotCompleted(string? FilePath, string? Error); + +public interface ILongshotService +{ + IReadOnlyList EnumTargets(); + Task CaptureAsync(LongshotTarget target, string dir, string format, CancellationToken ct = default); +} + +public sealed partial class LongshotService(IEventBus bus, ILogger? log = null) : ILongshotService +{ + private const int MaxHeightPx = 24000; + private const int MaxFrames = 120; + private const int ScrollWaitMs = 550; + private const uint VkNext = 0x22; + private const uint WmKeyDown = 0x0100; + private const uint WmKeyUp = 0x0101; + + public IReadOnlyList EnumTargets() + { + var list = new List(); + using var scope = new AppBarApi.PhysicalDpiScope(); + EnumWindows((hwnd, _) => + { + if (!IsWindowVisible(hwnd)) return true; + if (GetWindowTextLength(hwnd) == 0) return true; + if (GetAncestor(hwnd, GA_ROOTOWNER) != GetLastActivePopup(hwnd)) return true; + // DWM cloaked (UWP 挂起窗口) 过滤 + if (IsCloaked(hwnd)) return true; + var sb = new System.Text.StringBuilder(256); + _ = GetWindowText(hwnd, sb, 256); + var title = sb.ToString().Trim(); + if (title.Length == 0) return true; + if (GetWindowRect(hwnd, out var r) && r.Width < 200) return true; + list.Add(new LongshotTarget(hwnd, title)); + return true; + }, 0); + return list; + } + + public async Task CaptureAsync(LongshotTarget target, string dir, string format, CancellationToken ct = default) + { + try + { + // 3 秒倒计时 (longshot:countdown 对应事件), 给用户切窗时间 + for (var s = 3; s > 0; s--) + { + bus.Publish(new LongshotCountdown(s)); + await Task.Delay(1000, ct).ConfigureAwait(false); + } + + _ = SetForegroundWindow(target.Hwnd); + await Task.Delay(250, ct).ConfigureAwait(false); + + var rect = GetExtendedFrameBounds(target.Hwnd); + if (rect.W <= 0 || rect.H <= 0) throw new InvalidOperationException("目标窗口矩形不可用"); + + Bitmap canvas; + using (var scope = new AppBarApi.PhysicalDpiScope()) + { + var first = CaptureRect(rect); + canvas = first ?? throw new InvalidOperationException("首帧捕获失败"); + } + + var frames = 1; + var committed = canvas.Height; + while (frames < MaxFrames && committed < MaxHeightPx) + { + ct.ThrowIfCancellationRequested(); + _ = PostMessage(target.Hwnd, WmKeyDown, VkNext, 0); + _ = PostMessage(target.Hwnd, WmKeyUp, VkNext, 0); + await Task.Delay(ScrollWaitMs, ct).ConfigureAwait(false); + + Bitmap frame; + using (var scope = new AppBarApi.PhysicalDpiScope()) + { + var captured = CaptureRect(rect); + if (captured is null) break; + frame = captured; + } + + var tailHashes = RowHashes(canvas, Math.Min(canvas.Height, frame.Height)); + var frameHashes = RowHashes(frame, frame.Height); + var overlap = LongshotStitcher.FindOverlap(tailHashes, frameHashes, Math.Min(tailHashes.Length, frame.Height)); + if (overlap >= frame.Height) + { + frame.Dispose(); + break; // 已到底 + } + canvas = Stitch(canvas, frame, overlap); + committed = canvas.Height; + frames++; + bus.Publish(new LongshotProgress(committed, frames)); + } + + Directory.CreateDirectory(dir); + var ext = format.Equals("jpg", StringComparison.OrdinalIgnoreCase) ? "jpg" : "png"; + var path = Path.Combine(dir, $"longshot_{DateTime.Now:yyyyMMdd_HHmmss}.{ext}"); + canvas.Save(path, ext == "jpg" ? ImageFormat.Jpeg : ImageFormat.Png); + canvas.Dispose(); + bus.Publish(new LongshotCompleted(path, null)); + log?.LogInformation("[Longshot] 完成 {Path} ({Frames} 帧)", path, frames); + return path; + } + catch (OperationCanceledException) + { + bus.Publish(new LongshotCompleted(null, "已取消")); + return null; + } + catch (Exception ex) + { + log?.LogError(ex, "[Longshot] 失败"); + bus.Publish(new LongshotCompleted(null, ex.Message)); + return null; + } + } + + private static Bitmap? CaptureRect(AppBarRect r) + { + var bmp = new Bitmap(r.W, r.H, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bmp); + var hdcDest = g.GetHdc(); + var hdcSrc = GetDC(0); + _ = BitBlt(hdcDest, 0, 0, r.W, r.H, hdcSrc, r.X, r.Y, SRCCOPY | CAPTUREBLT); + g.ReleaseHdc(hdcDest); + _ = ReleaseDC(0, hdcSrc); + return bmp; + } + + private static Bitmap Stitch(Bitmap tail, Bitmap frame, int overlap) + { + var newH = tail.Height + frame.Height - overlap; + var stitched = new Bitmap(Math.Max(tail.Width, frame.Width), newH, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(stitched); + g.DrawImageUnscaled(tail, 0, 0); + g.DrawImageUnscaled(frame, 0, tail.Height - overlap); + tail.Dispose(); + frame.Dispose(); + return stitched; + } + + /// 行哈希: 逐行采样求和 (快而稳; 完全一致才计重叠)。 + internal static long[] RowHashes(Bitmap bmp, int rowCount) + { + var hashes = new long[rowCount]; + var rect = new Rectangle(0, 0, bmp.Width, Math.Min(rowCount, bmp.Height)); + var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + unsafe + { + var stride = data.Stride; + var basePtr = (byte*)data.Scan0; + var step = Math.Max(1, bmp.Width / 64); // 每行最多采样 64 点 + for (var y = 0; y < rowCount; y++) + { + long h = 0; + var row = basePtr + y * stride; + for (var x = 0; x < bmp.Width; x += step) + { + var p = row + x * 4; + h = h * 31 + (p[0] + p[1] * 3 + p[2] * 7); + } + hashes[y] = h; + } + } + bmp.UnlockBits(data); + return hashes; + } + + private static AppBarRect GetExtendedFrameBounds(nint hwnd) + { + var hr = DwmGetWindowAttributeRect(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, out var physical, Marshal.SizeOf()); + if (hr == 0) return new AppBarRect(physical.Left, physical.Top, physical.Width, physical.Height); + _ = GetWindowRect(hwnd, out var fallback); + return new AppBarRect(fallback.Left, fallback.Top, fallback.Width, fallback.Height); + } + + private static bool IsCloaked(nint hwnd) => + DwmGetWindowAttributeInt(hwnd, DWMWA_CLOAKED, out var cloaked, 4) == 0 && cloaked != 0; + + private const int DWMWA_CLOAKED = 14; + private const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + private const int GA_ROOTOWNER = 3; + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeRect + { + public int Left, Top, Right, Bottom; + public readonly int Width => Right - Left; + public readonly int Height => Bottom - Top; + } + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool EnumWindows(EnumWindowsProc cb, nint lParam); + + private delegate bool EnumWindowsProc(nint hwnd, nint lParam); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsWindowVisible(nint hwnd); + + [LibraryImport("user32.dll")] + private static partial int GetWindowTextLength(nint hwnd); + + [DllImport("user32.dll", EntryPoint = "GetWindowTextW", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(nint hwnd, System.Text.StringBuilder text, int maxCount); + + [LibraryImport("user32.dll")] + private static partial nint GetAncestor(nint hwnd, uint flags); + + [LibraryImport("user32.dll")] + private static partial nint GetLastActivePopup(nint hwnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetWindowRect(nint hwnd, out NativeRect rect); + + [LibraryImport("user32.dll", EntryPoint = "PostMessageW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool PostMessage(nint hwnd, uint msg, nuint wParam, nint lParam); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint hwnd); + + [LibraryImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool BitBlt(nint hdcDest, int x, int y, int w, int h, nint hdcSrc, int xSrc, int ySrc, int rop); + + [LibraryImport("user32.dll")] + private static partial nint GetDC(nint hwnd); + + [LibraryImport("user32.dll")] + private static partial int ReleaseDC(nint hwnd, nint hdc); + + private const int SRCCOPY = 0x00CC0020; + private const int CAPTUREBLT = 0x40000000; + + [LibraryImport("dwmapi.dll")] + private static partial int DwmGetWindowAttributeRect(nint hwnd, int attr, out NativeRect rect, int cbSize); + + [LibraryImport("dwmapi.dll")] + private static partial int DwmGetWindowAttributeInt(nint hwnd, int attr, out int value, int cbSize); +} diff --git a/src/SmartSideBAR.Windows/Devices/PrinterMonitor.cs b/src/SmartSideBAR.Windows/Devices/PrinterMonitor.cs new file mode 100644 index 0000000..1110749 --- /dev/null +++ b/src/SmartSideBAR.Windows/Devices/PrinterMonitor.cs @@ -0,0 +1,94 @@ +// Windows/Devices/PrinterMonitor.cs —— §6.7: System.Management 同源 WMI, E4 状态映射根修。 +// 轮询间隔消费 config.printer.pollIntervalSec; 状态变化 → bus.Publish(PrinterChanged)。 +using System.Management; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Devices; +using SmartSideBAR.Core.Messaging; + +namespace SmartSideBAR.Windows.Devices; + +public sealed record PrinterInfo(string Name, PrinterState State, string WireState); + +public sealed record PrinterChanged(IReadOnlyList Printers); + +public interface IPrinterMonitor +{ + void Start(); + void Stop(); + IReadOnlyList Query(); +} + +public sealed class PrinterMonitor( + ConfigService config, + IEventBus bus, + ILogger? log = null) : IPrinterMonitor, IDisposable +{ + private System.Threading.Timer? _timer; + private string _lastHash = ""; + private readonly object _gate = new(); + + public void Start() + { + lock (_gate) + { + if (_timer is not null) return; + var interval = TimeSpan.FromSeconds(Math.Max(3, config.Current.Printer.PollIntervalSec)); + _timer = new System.Threading.Timer(_ => PollOnce(), null, TimeSpan.Zero, interval); + log?.LogInformation("[Printer] 已启动轮询 ({IntervalSec}s)", interval.TotalSeconds); + } + } + + public void Stop() + { + lock (_gate) + { + _timer?.Dispose(); + _timer = null; + log?.LogInformation("[Printer] 已停止轮询"); + } + } + + public IReadOnlyList Query() + { + var list = new List(); + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT Name, PrinterStatus, DetectedErrorState FROM Win32_Printer"); + foreach (var mo in searcher.Get()) + { + var name = mo["Name"] as string ?? "(未知)"; + uint? detected = mo["DetectedErrorState"] is null ? null : Convert.ToUInt32(mo["DetectedErrorState"]); + ushort? status = mo["PrinterStatus"] is null ? null : Convert.ToUInt16(mo["PrinterStatus"]); + var state = PrinterStateMapper.Map(detected, status); + list.Add(new PrinterInfo(name, state, PrinterStateMapper.ToWire(state))); + } + } + catch (Exception ex) + { + // WMI 服务异常 (被禁用/权限) 不阻断常驻; 下轮重试 + log?.LogWarning(ex, "[Printer] WMI 查询失败"); + } + return list; + } + + private void PollOnce() + { + try + { + var printers = Query(); + var hash = string.Join("|", printers.Select(p => $"{p.Name}:{p.WireState}")); + if (hash == _lastHash) return; // 变更检测: 无变化不广播 + _lastHash = hash; + bus.Publish(new PrinterChanged(printers)); + log?.LogInformation("[Printer] 状态变化: {Count} 台", printers.Count); + } + catch (Exception ex) + { + log?.LogWarning(ex, "[Printer] 轮询异常"); + } + } + + public void Dispose() => Stop(); +} diff --git a/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs b/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs new file mode 100644 index 0000000..d9e5bbb --- /dev/null +++ b/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs @@ -0,0 +1,146 @@ +// Windows/Devices/UsbDeviceWatcher.cs —— §6.6: 直接读取系统事件。 +// WM_DEVICECHANGE 广播 (VOLUME 到达/移除 + DEVNODES_CHANGED) → 驱动器快照差异扫描。 +// 勘误: DBT_DEVTYP_VOLUME 不能经 RegisterDeviceNotification 过滤注册 (err=13); +// 卷事件本就广播给所有顶层窗口, 差异扫描兜底 NODES_CHANGED-only 的设备 (读卡器等)。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Devices; + +public sealed record UsbDriveInfo(string Drive, string? Label, double SizeGb, bool Removable); + +public sealed record UsbArrived(UsbDriveInfo Info); +public sealed record UsbRemoved(UsbDriveInfo Info); + +public interface IUsbWatcher +{ + void Start(nint hostHwnd); + void Stop(); + IReadOnlyList ListRemovable(); +} + +public sealed class UsbDeviceWatcher( + IWndProcHook hook, + IEventBus bus, + ConfigService config, + ILogger? log = null) : IUsbWatcher, IDisposable +{ + private const uint WM_DEVICECHANGE = 0x0219; + private const uint DBT_DEVICEARRIVAL = 0x8000; + private const uint DBT_DEVICEREMOVECOMPLETE = 0x8004; + private const uint DBT_DEVNODES_CHANGED = 0x0007; + + private readonly object _gate = new(); + private nint _hwnd; + private WndProcCallback? _callback; + private Dictionary _lastDrives = []; + + public void Start(nint hostHwnd) + { + if (_hwnd != 0) return; + _hwnd = hostHwnd; + _lastDrives = Snapshot(); + _callback = OnDeviceChange; + hook.Add(hostHwnd, _callback); + log?.LogInformation("[USB] 已监听 WM_DEVICECHANGE (系统广播 + 差异扫描, 基线 {Count} 个可移动盘)", _lastDrives.Count); + } + + public void Stop() + { + if (_hwnd == 0) return; + if (_callback is not null) hook.Remove(_hwnd, _callback); + _hwnd = 0; + log?.LogInformation("[USB] 已停止监听"); + } + + public IReadOnlyList ListRemovable() => + Snapshot().Values.ToArray(); + + private nint OnDeviceChange(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled) + { + if (msg != WM_DEVICECHANGE) return 0; + handled = true; + // 到达/移除/设备节点变化 三类都值得重扫; 卷挂载后文件系统可能未就绪, 延迟再扫一次 + RescanWithDiff(); + _ = Task.Run(async () => + { + try + { + await Task.Delay(900).ConfigureAwait(false); + RescanWithDiff(); + } + catch { /* 尽力而为 */ } + }); + return 0; + } + + private void RescanWithDiff() + { + List added; + List removed; + lock (_gate) + { + var now = Snapshot(); + (added, removed) = DiffDrives(_lastDrives, now); + _lastDrives = now; + } + foreach (var info in added) + { + bus.Publish(new UsbArrived(info)); + log?.LogInformation("[USB] 接入 {Drive} ({Label}, {Gb}GB)", info.Drive, info.Label, info.SizeGb); + } + foreach (var info in removed) + { + bus.Publish(new UsbRemoved(info)); + log?.LogInformation("[USB] 移除 {Drive}", info.Drive); + } + } + + /// 驱动器快照差异 (纯函数, 单测锁定)。 + internal static (List Added, List Removed) DiffDrives( + Dictionary before, Dictionary after) + { + var added = after.Values.Where(d => !before.ContainsKey(d.Drive)).ToList(); + var removed = before.Values.Where(d => !after.ContainsKey(d.Drive)).ToList(); + return (added, removed); + } + + private Dictionary Snapshot() + { + if (!config.Current.Usb.Enabled) return []; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var d in DriveInfo.GetDrives()) + { + if (d.DriveType is not (DriveType.Removable or DriveType.CDRom)) continue; + result[d.Name] = ToInfo(d); + } + } + catch (Exception ex) + { + log?.LogWarning(ex, "[USB] 驱动器扫描异常"); + } + return result; + } + + private static UsbDriveInfo ToInfo(DriveInfo d) + { + double gb = 0; + string? label = null; + try + { + if (d.IsReady) + { + gb = Math.Round(d.TotalSize / 1073741824.0, 1); + label = string.IsNullOrWhiteSpace(d.VolumeLabel) ? null : d.VolumeLabel; + } + } + catch { /* 移除竞态: 元数据尽力而为 */ } + return new UsbDriveInfo(d.Name, label, gb, d.DriveType == DriveType.Removable); + } + + public void Dispose() => Stop(); +} diff --git a/src/SmartSideBAR.Windows/Input/HotkeyService.cs b/src/SmartSideBAR.Windows/Input/HotkeyService.cs new file mode 100644 index 0000000..928e2ed --- /dev/null +++ b/src/SmartSideBAR.Windows/Input/HotkeyService.cs @@ -0,0 +1,130 @@ +// Windows/Input/HotkeyService.cs —— §6.13 RegisterHotKey: 冲突检测从启发式变为系统事实 (P2-2)。 +// WM_HOTKEY 经 §5.3 同一 WndProcHook 基础设施路由 → bus.Publish(HotkeyPressed)。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Input; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Input; + +public sealed record HotkeyPressed(string Slot); +public sealed record HotkeySlotInfo(string Slot, HotkeyCombo? Combo, bool Conflicted, IReadOnlyList Suggestions); + +public interface IHotkeyService +{ + /// 注册槽位; 冲突返回 false 并给出系统级冲突事实下的替代建议。 + bool Register(string slot, string hotkeyText); + + void Unregister(string slot); + IReadOnlyList GetSlots(); +} + +public sealed class HotkeyService : IHotkeyService, IDisposable +{ + private static int _nextId = 1; + + private readonly IWndProcHook _hook; + private readonly IEventBus _bus; + private readonly ILogger? _log; + private readonly object _gate = new(); + private readonly Dictionary _active = []; + private readonly Dictionary> _suggestions = []; + private nint _hwnd; + private WndProcCallback? _callback; + + public HotkeyService(IWndProcHook hook, IEventBus bus, ILogger? log = null) + { + _hook = hook; + _bus = bus; + _log = log; + } + + /// 宿主窗口就绪后调用 (所有热键挂在同一消息窗口上)。 + public void Attach(nint hwnd) + { + lock (_gate) + { + if (_hwnd != 0) return; + _hwnd = hwnd; + _callback = OnWndProc; + _hook.Add(hwnd, _callback); + } + } + + public bool Register(string slot, string hotkeyText) + { + if (!HotkeyParser.TryParse(hotkeyText, out var combo)) + { + _log?.LogWarning("[Hotkey] 无法解析热键文本: {Slot}={Text}", slot, hotkeyText); + return false; + } + lock (_gate) + { + if (_hwnd == 0) { _log?.LogWarning("[Hotkey] Attach 未调用, 忽略注册 {Slot}", slot); return false; } + Unregister(slot); + var id = _nextId++; + if (!Win32Input.RegisterHotKey(_hwnd, id, combo.Modifiers, combo.VirtualKey)) + { + var suggestions = HotkeyParser.SuggestAlternatives(combo); + _suggestions[slot] = suggestions; + _log?.LogWarning("[Hotkey] 注册冲突 (系统事实): {Slot}={Combo}, 建议 {Count} 个", slot, combo, suggestions.Count); + return false; + } + _active[slot] = (id, combo); + _suggestions.Remove(slot); + _log?.LogInformation("[Hotkey] 已注册 {Slot}={Combo}", slot, combo); + return true; + } + } + + public void Unregister(string slot) + { + lock (_gate) + { + if (_hwnd == 0 || !_active.Remove(slot, out var entry)) return; + _ = Win32Input.UnregisterHotKey(_hwnd, entry.Id); + } + } + + public IReadOnlyList GetSlots() + { + lock (_gate) + { + var slots = _active.Keys.Union(_suggestions.Keys); + return slots.Select(s => _active.TryGetValue(s, out var e) + ? new HotkeySlotInfo(s, e.Combo, false, []) + : new HotkeySlotInfo(s, null, true, _suggestions[s])).ToArray(); + } + } + + private nint OnWndProc(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled) + { + if (msg == Win32Input.WM_HOTKEY) + { + handled = true; + var id = (int)wParam; + lock (_gate) + { + foreach (var (slot, entry) in _active) + { + if (entry.Id != id) continue; + _bus.Publish(new HotkeyPressed(slot)); + break; + } + } + return 0; + } + return 0; // 不拦截其余消息 + } + + public void Dispose() + { + lock (_gate) + { + if (_hwnd == 0) return; + foreach (var slot in _active.Keys.ToArray()) Unregister(slot); + if (_callback is not null) _hook.Remove(_hwnd, _callback); + _hwnd = 0; + } + } +} diff --git a/src/SmartSideBAR.Windows/Input/ImeService.cs b/src/SmartSideBAR.Windows/Input/ImeService.cs new file mode 100644 index 0000000..b07f594 --- /dev/null +++ b/src/SmartSideBAR.Windows/Input/ImeService.cs @@ -0,0 +1,86 @@ +// Windows/Input/ImeService.cs —— §6.1 (ADR-M6): Win32 键盘布局直调, 零 PowerShell 子进程。 +// v1.2.0 常驻 PS 守护 (<50ms) → 消息直发 (<5ms); config.ime.slot1/slot2 首次被消费 (审计 #1 遗留)。 +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Input; + +public sealed record ImeState(string Locale, bool IsChinese); + +public sealed record ImeChanged(string Locale, bool IsChinese); + +public interface IImeService +{ + ImeState GetState(); + bool Toggle(); + bool SelectChinese(); + bool SelectEnglish(); +} + +public sealed class ImeService(IEventBus bus, ILogger? log = null) : IImeService +{ + private const ushort LangZh = 0x0004; + private const ushort LangEn = 0x0009; + + public ImeState GetState() + { + var fg = Win32Input.GetForegroundWindow(); + var thread = Win32Input.GetWindowThreadProcessId(fg, out _); + var hkl = Win32Input.GetKeyboardLayout(thread); + return FromHkl(hkl); + } + + public bool Toggle() + { + var current = GetState(); + return current.IsChinese ? SelectEnglish() : SelectChinese(); + } + + public bool SelectChinese() => SelectByLanguage(LangZh); + public bool SelectEnglish() => SelectByLanguage(LangEn); + + private static ImeState FromHkl(nint hkl) + { + var lang = (ushort)(hkl & 0xFFFF); + var isZh = (lang & 0x3FF) == LangZh; + var locale = isZh + ? $"zh-{(lang >> 10) switch { 1 => "CN", 2 => "TW", 4 => "SG", 5 => "HK", _ => "CN" }}" + : $"{lang & 0x3FF:x2}"; + return new ImeState(locale, isZh); + } + + private bool SelectByLanguage(ushort primaryLang) + { + var layouts = Win32Input.GetKeyboardLayouts(); + if (layouts.Length == 0) + { + log?.LogWarning("[IME] 未枚举到键盘布局"); + return false; + } + nint target = 0; + foreach (var hkl in layouts) + { + if (((ushort)(hkl & 0xFFFF) & 0x3FF) == primaryLang) { target = hkl; break; } + } + if (target == 0) + { + // 无目标语言布局: 回退到与当前不同的第一个布局 (轮换语义, fallbackSwap) + var current = Win32Input.GetKeyboardLayout(Win32Input.GetWindowThreadProcessId(Win32Input.GetForegroundWindow(), out _)); + target = layouts.FirstOrDefault(h => h != current, layouts[0]); + } + + // 激活目标布局并向前台窗口广播切换请求 (KLID = 8 位十六进制: 高 4 位设备, 低 4 位语言) + var klid = $"{(uint)target >> 16:X4}{(ushort)target:X4}"; + _ = Win32Input.LoadKeyboardLayout(klid, Win32Input.KLF_ACTIVATE); + var fg = Win32Input.GetForegroundWindow(); + var ok = fg != 0 && Win32Input.PostMessage(fg, Win32Input.WM_INPUTLANGCHANGEREQUEST, 0, target); + if (ok) + { + var state = FromHkl(target); + bus.Publish(new ImeChanged(state.Locale, state.IsChinese)); + log?.LogInformation("[IME] 切换 → {Locale}", state.Locale); + } + return ok; + } +} diff --git a/src/SmartSideBAR.Windows/Native/IWndProcHook.cs b/src/SmartSideBAR.Windows/Native/IWndProcHook.cs new file mode 100644 index 0000000..25665ae --- /dev/null +++ b/src/SmartSideBAR.Windows/Native/IWndProcHook.cs @@ -0,0 +1,16 @@ +// Windows/Native/IWndProcHook.cs —— WndProc 子类化钩子抽象 (单测注入假件) +namespace SmartSideBAR.Windows.Native; + +/// +/// WndProc 回调委托。返回值: handled=true 时作为窗口过程结果直接返回; false 时继续传递给原过程。 +/// +public delegate nint WndProcCallback(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled); + +public interface IWndProcHook +{ + /// 对 hwnd 安装钩子; 必须在窗口所属 UI 线程调用。可对同一 hwnd 叠加多个回调。 + void Add(nint hwnd, WndProcCallback callback); + + /// 移除该 hwnd 上由 Add 安装的 callback; 最后一个移除时恢复原窗口过程。 + void Remove(nint hwnd, WndProcCallback callback); +} diff --git a/src/SmartSideBAR.Windows/Native/Win32Display.cs b/src/SmartSideBAR.Windows/Native/Win32Display.cs new file mode 100644 index 0000000..959f91e --- /dev/null +++ b/src/SmartSideBAR.Windows/Native/Win32Display.cs @@ -0,0 +1,48 @@ +// Windows/Native/Win32Display.cs —— 显示器/DPI 公共助手 (UI 层与互操作层的共享工具) +using System.Runtime.InteropServices; + +namespace SmartSideBAR.Windows.Native; + +public static partial class Win32Display +{ + [LibraryImport("user32.dll")] + private static partial uint GetDpiForWindow(nint hwnd); + + /// 窗口 DPI 缩放因子 (96 = 1.0)。物理像素 ↔ DIP 的唯一换算依据 (K4 单轨)。 + public static double GetScaling(nint hwnd) => + hwnd != 0 ? GetDpiForWindow(hwnd) / 96.0 : 1.0; + + /// 主屏工作区矩形 (物理像素, PMv2 上下文)。 + public static (int X, int Y, int W, int H) PrimaryWorkAreaPx() + { + var hMonitor = MonitorFromPoint(new NativePoint { x = 0, y = 0 }, 1 /*MONITOR_DEFAULTTOPRIMARY*/); + var mi = new MONITORINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!GetMonitorInfo(hMonitor, ref mi)) + { + return (0, 0, 1920, 1040); + } + return (mi.rcWork.Left, mi.rcWork.Top, mi.rcWork.W, mi.rcWork.H); + } + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORINFO + { + public uint cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int Left, Top, Right, Bottom; public readonly int W => Right - Left; public readonly int H => Bottom - Top; } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativePoint { public int x, y; } + + [LibraryImport("user32.dll")] + private static partial nint MonitorFromPoint(NativePoint pt, uint flags); + + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetMonitorInfo(nint hMonitor, ref MONITORINFO lpmi); +} diff --git a/src/SmartSideBAR.Windows/Native/Win32InputDevice.cs b/src/SmartSideBAR.Windows/Native/Win32InputDevice.cs new file mode 100644 index 0000000..ecb6980 --- /dev/null +++ b/src/SmartSideBAR.Windows/Native/Win32InputDevice.cs @@ -0,0 +1,90 @@ +// Windows/Native/Win32InputDevice.cs —— Wave B 服务族共享的 P/Invoke 集合 +// (IME 键盘布局 / 全局热键 / 设备通知 / winmm 声音) +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace SmartSideBAR.Windows.Native; + +internal static partial class Win32Input +{ + // ---- 前台窗口 / 线程键盘布局 ---- + [LibraryImport("user32.dll")] + internal static partial nint GetForegroundWindow(); + + [LibraryImport("user32.dll")] + internal static partial uint GetWindowThreadProcessId(nint hwnd, out uint lpdwProcessId); + + [LibraryImport("user32.dll")] + internal static partial nint GetKeyboardLayout(uint idThread); + + [LibraryImport("user32.dll")] + private static partial uint GetKeyboardLayoutList(int nBuff, [Out] nint[] lpList); + + internal static nint[] GetKeyboardLayouts() + { + var n = (int)GetKeyboardLayoutList(0, []); + if (n <= 0) return []; + var buf = new nint[n]; + _ = GetKeyboardLayoutList(n, buf); + return buf; + } + + [LibraryImport("user32.dll", EntryPoint = "LoadKeyboardLayoutW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial nint LoadKeyboardLayout(string pwszKLID, uint flags); + + [LibraryImport("user32.dll", EntryPoint = "PostMessageW")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool PostMessage(nint hwnd, uint msg, nuint wParam, nint lParam); + + public const uint WM_INPUTLANGCHANGEREQUEST = 0x0050; + public const uint KLF_ACTIVATE = 0x0001; + + // ---- 全局热键 ---- + public const uint WM_HOTKEY = 0x0312; + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool RegisterHotKey(nint hWnd, int id, uint fsModifiers, uint vk); + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool UnregisterHotKey(nint hWnd, int id); + + // ---- 设备通知 (USB) ---- + public const uint WM_DEVICECHANGE = 0x0219; + public const uint DBT_DEVICEARRIVAL = 0x8000; + public const uint DBT_DEVICEREMOVECOMPLETE = 0x8004; + public const uint DBT_DEVTYP_VOLUME = 0x0002; + public const uint DEVICE_NOTIFY_WINDOW_HANDLE = 0x0000; + + [StructLayout(LayoutKind.Sequential)] // dbt.h 默认对齐: 原生尺寸 20 + public struct DEV_BROADCAST_VOLUME + { + public uint dbcv_size; + public uint dbcv_devicetype; + public uint dbcv_reserved; + public uint dbcv_unitmask; + public ushort dbcv_flags; + } + + [DllImport("user32.dll", EntryPoint = "RegisterDeviceNotificationW", SetLastError = true)] + internal static extern nint RegisterDeviceNotification(nint hRecipient, ref DEV_BROADCAST_VOLUME notification, uint flags); + + [DllImport("user32.dll", SetLastError = true)] + internal static extern bool UnregisterDeviceNotification(nint handle); +} + +/// winmm 声音播放 (零子进程; 替代 v1.2.0 的 PowerShell SystemSounds 守护)。 +internal static partial class Winmm +{ + public const uint SND_ALIAS = 0x00010000; + public const uint SND_ASYNC = 0x0001; + + [LibraryImport("winmm.dll", EntryPoint = "PlaySoundW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool PlaySound(string? pszSound, nint hmod, uint fdwSound); + + [LibraryImport("winmm.dll", EntryPoint = "mciSendStringW", StringMarshalling = StringMarshalling.Utf16, + SetLastError = true)] + internal static partial int mciSendString(string lpstrCommand, char[]? lpstrReturnString, int cchReturn, nint hwndCallback); +} diff --git a/src/SmartSideBAR.Windows/Native/WndProcHook.cs b/src/SmartSideBAR.Windows/Native/WndProcHook.cs new file mode 100644 index 0000000..a36230c --- /dev/null +++ b/src/SmartSideBAR.Windows/Native/WndProcHook.cs @@ -0,0 +1,122 @@ +// Windows/Native/WndProcHook.cs —— K1 通知闭环的基础设施。 +// 实现方式: SetWindowLongPtr(GWLP_WNDPROC) 经典子类化 —— 方案 §5.3 原想用 Avalonia 的 +// AddWndProcCallback, 但该 API 未公开(为 Avalonia 内部实现); 子类化等效且零 UI 框架耦合, +// 同一钩子可被 AppBar / 全局热键(WM_HOTKEY) / USB(WM_DEVICECHANGE) 共用 (Wave B 复用点)。 +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace SmartSideBAR.Windows.Native; + +[UnmanagedFunctionPointer(CallingConvention.StdCall)] +internal delegate nint WndProcThunk(nint hwnd, uint msg, nuint wParam, nint lParam); + +public sealed partial class WndProcHook : IWndProcHook +{ + private const int GWLP_WNDPROC = -4; + + private static readonly Lock Gate = new(); + private static readonly Dictionary Subclasses = []; + + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static partial nint GetWindowLongPtr64(nint hWnd, int nIndex); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtr64(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongW")] + private static partial int GetWindowLong32(nint hWnd, int nIndex); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongW")] + private static partial int SetWindowLong32(nint hWnd, int nIndex, int dwNewLong); + + [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] + private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + public void Add(nint hwnd, WndProcCallback callback) + { + ArgumentNullException.ThrowIfNull(callback); + lock (Gate) + { + if (Subclasses.TryGetValue(hwnd, out var existing)) + { + existing.AddCallback(callback); + return; + } + var prevProc = Environment.Is64BitProcess + ? GetWindowLongPtr64(hwnd, GWLP_WNDPROC) + : GetWindowLong32(hwnd, GWLP_WNDPROC); + var subclass = new Subclass(prevProc); + subclass.AddCallback(callback); + Subclasses[hwnd] = subclass; + var newProc = Marshal.GetFunctionPointerForDelegate(subclass.Thunk); + _ = Environment.Is64BitProcess + ? SetWindowLongPtr64(hwnd, GWLP_WNDPROC, newProc) + : SetWindowLong32(hwnd, GWLP_WNDPROC, unchecked((int)newProc)); + } + } + + public void Remove(nint hwnd, WndProcCallback callback) + { + lock (Gate) + { + if (!Subclasses.TryGetValue(hwnd, out var subclass)) return; + if (!subclass.RemoveCallback(callback)) return; + if (subclass.CallbackCount == 0) + { + _ = Environment.Is64BitProcess + ? SetWindowLongPtr64(hwnd, GWLP_WNDPROC, subclass.PrevProc) + : SetWindowLong32(hwnd, GWLP_WNDPROC, unchecked((int)subclass.PrevProc)); + Subclasses.Remove(hwnd); + } + } + } + + private sealed class Subclass(nint prevProc) + { + private readonly object _gate = new(); + private List _callbacks = []; + public readonly WndProcThunk Thunk = Dispatch; + + public nint PrevProc { get; } = prevProc; + + public int CallbackCount { get { lock (_gate) return _callbacks.Count; } } + + public void AddCallback(WndProcCallback callback) + { + lock (_gate) _callbacks.Add(callback); + } + + public bool RemoveCallback(WndProcCallback callback) + { + lock (_gate) return _callbacks.Remove(callback); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static nint Dispatch(nint hwnd, uint msg, nuint wParam, nint lParam) + { + // WndProc 在窗口线程被系统回调; 此处只读快照, 不持锁 + Subclass? subclass; + lock (Gate) + { + if (!Subclasses.TryGetValue(hwnd, out subclass)) + return DefWindowProc(hwnd, msg, wParam, lParam); + } + + foreach (var callback in subclass.Snapshot()) + { + var handled = false; + var result = callback(hwnd, msg, wParam, lParam, ref handled); + if (handled) return result; + } + return CallWindowProc(subclass.PrevProc, hwnd, msg, wParam, lParam); + } + + private WndProcCallback[] Snapshot() + { + lock (_gate) return [.. _callbacks]; + } + } + + [LibraryImport("user32.dll", EntryPoint = "DefWindowProcW")] + private static partial nint DefWindowProc(nint hWnd, uint msg, nuint wParam, nint lParam); +} diff --git a/src/SmartSideBAR.Windows/Recording/GraphicsCaptureRecorder.cs b/src/SmartSideBAR.Windows/Recording/GraphicsCaptureRecorder.cs new file mode 100644 index 0000000..2bc274f --- /dev/null +++ b/src/SmartSideBAR.Windows/Recording/GraphicsCaptureRecorder.cs @@ -0,0 +1,301 @@ +// Windows/Recording/GraphicsCaptureRecorder.cs —— §6.5 (ADR-M4): 系统级捕获 → H.264 MP4 直出。 +// 管线: GraphicsCaptureItem → Direct3D11CaptureFramePool(GPU 纹理零拷贝) → MediaStreamSource +// → MediaTranscoder(系统 H.264 编码器) → MP4。全 WinRT 投影, 无 COM vtable 手写。 +// 注意: file-scoped namespace 下 `Windows.*` 会被解析为 SmartSideBAR.Windows.* —— 一律 global:: 别名。 +// config.recorder.fps/bitrate 消费; 麦克风混流列 Wave D 增强 (ADR-M4 最小闭环)。 +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Messaging; +using WinRT; +using WinMediaCore = global::Windows.Media.Core; +using WinProps = global::Windows.Media.MediaProperties; +using WinTranscoding = global::Windows.Media.Transcoding; +using WinCapture = global::Windows.Graphics.Capture; +using WinDirectX = global::Windows.Graphics.DirectX; +using WinDirectX11 = global::Windows.Graphics.DirectX.Direct3D11; +using WinGraphics = global::Windows.Graphics; + +namespace SmartSideBAR.Windows.Recording; + +public sealed record RecorderOptions(string FilePath, int Fps, int BitrateKbps); + +public sealed record RecorderStatusChanged(bool Recording, string? FilePath, string? Error); + +public interface IRecorderApi +{ + bool IsSupported(); + Task StartAsync(RecorderOptions opt, CancellationToken ct = default); + Task StopAsync(); + bool IsRecording { get; } +} + +public sealed class GraphicsCaptureRecorder(IEventBus bus, ILogger? log = null) + : IRecorderApi, IDisposable +{ + private readonly SemaphoreSlim _frameAvailable = new(0); + private readonly object _gate = new(); + private WinCapture.Direct3D11CaptureFramePool? _framePool; + private WinCapture.GraphicsCaptureSession? _session; + private WinCapture.Direct3D11CaptureFrame? _pending; + private WinMediaCore.MediaStreamSource? _mss; + private WinTranscoding.PrepareTranscodeResult? _prepared; + private string? _filePath; + private volatile bool _recording; + private volatile bool _stopRequested; + private int _fps = 15; + + public bool IsRecording => _recording; + + public bool IsSupported() + { + try { return WinCapture.GraphicsCaptureSession.IsSupported(); } + catch { return false; } + } + + public async Task StartAsync(RecorderOptions opt, CancellationToken ct = default) + { + if (_recording) throw new InvalidOperationException("已在录制中"); + if (!IsSupported()) throw new PlatformNotSupportedException("系统不支持 Windows.Graphics.Capture (需 Win10 1803+)"); + + // 主屏物理尺寸 (PMv2 上下文; TFM 已含 WinRT 投影) + var hMonitor = GraphicsCaptureItemProbe.PrimaryMonitorHandle(); + var item = GraphicsCaptureItemProbe.CreateForMonitor(hMonitor); + var size = item.Size; + _filePath = opt.FilePath; + _fps = Math.Clamp(opt.Fps, 5, 60); + + var device = D3D11Helper.CreateDevice(); + _framePool = WinCapture.Direct3D11CaptureFramePool.CreateFreeThreaded( + device, WinDirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, size); + + // MP4 容器 + H.264 视频轨 (无音频轨; mic 混流为 Wave D 增强) + var profile = WinProps.MediaEncodingProfile.CreateMp4( + WinProps.VideoEncodingQuality.HD720p); + profile.Video = WinProps.VideoEncodingProperties.CreateH264(); + profile.Video.Width = (uint)size.Width; + profile.Video.Height = (uint)size.Height; + profile.Video.Bitrate = (uint)Math.Max(500_000, opt.BitrateKbps * 1000); + profile.Video.FrameRate.Numerator = (uint)_fps; + profile.Video.FrameRate.Denominator = 1; + profile.Audio = null; + + var props = WinProps.VideoEncodingProperties.CreateUncompressed( + WinProps.MediaEncodingSubtypes.Rgb32, (uint)size.Width, (uint)size.Height); + _mss = new WinMediaCore.MediaStreamSource(new WinMediaCore.VideoStreamDescriptor(props)) + { + BufferTime = TimeSpan.Zero, + }; + _mss.Starting += OnStarting; + _mss.SampleRequested += OnSampleRequested; + + var transcoder = new WinTranscoding.MediaTranscoder { HardwareAccelerationEnabled = true }; + _pending = null; + _stopRequested = false; + _recording = true; + _session = _framePool.CreateCaptureSession(item); + _session.IsCursorCaptureEnabled = true; + _framePool.FrameArrived += OnFrameArrived; + _session.StartCapture(); + + // 转码泵: SampleRequested 阻塞取帧, StopAsync 交付 null 使其自然收尾 (MP4 moov 完整) + File.Create(opt.FilePath).Dispose(); // 先建文件供 WinRT StorageFile 打开 + var storageFile = await global::Windows.Storage.StorageFile.GetFileFromPathAsync(opt.FilePath).AsTask(ct).ConfigureAwait(false); + var stream = await storageFile.OpenAsync(global::Windows.Storage.FileAccessMode.ReadWrite).AsTask(ct).ConfigureAwait(false); + var prepared = await transcoder.PrepareMediaStreamSourceTranscodeAsync(_mss, stream, profile).AsTask(ct).ConfigureAwait(false); + _prepared = prepared; + _ = Task.Run(async () => + { + try { await prepared.TranscodeAsync().AsTask().ConfigureAwait(false); } + catch (Exception ex) { log?.LogWarning(ex, "[Recorder] 转码泵退出"); } + }, CancellationToken.None); + + bus.Publish(new RecorderStatusChanged(true, opt.FilePath, null)); + log?.LogInformation("[Recorder] 开始 {Path} ({Fps}fps/{Bitrate}kbps {W}x{H})", + opt.FilePath, _fps, opt.BitrateKbps, size.Width, size.Height); + } + + public async Task StopAsync() + { + if (!_recording) return null; + _stopRequested = true; + _frameAvailable.Release(); // 唤醒阻塞的取帧点 → 交付 null → 转码收尾 + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(6); + while (_recording && DateTime.UtcNow < deadline) + { + await Task.Delay(100).ConfigureAwait(false); + } + Cleanup(); + bus.Publish(new RecorderStatusChanged(false, _filePath, null)); + log?.LogInformation("[Recorder] 完成 {Path}", _filePath); + return _filePath; + } + + private void OnFrameArrived(WinCapture.Direct3D11CaptureFramePool sender, object args) + { + var frame = sender.TryGetNextFrame(); + if (frame is null) return; + lock (_gate) + { + _pending?.Dispose(); + _pending = frame; + } + _ = _frameAvailable.Release(); + } + + private void OnStarting(WinMediaCore.MediaStreamSource sender, WinMediaCore.MediaStreamSourceStartingEventArgs args) + { + args.Request.SetActualStartPosition(TimeSpan.Zero); + } + + private void OnSampleRequested(WinMediaCore.MediaStreamSource sender, WinMediaCore.MediaStreamSourceSampleRequestedEventArgs args) + { + if (_stopRequested) + { + args.Request.Sample = null; // 收尾信号 → TranscodeAsync 完成 + _recording = false; + return; + } + try + { + _frameAvailable.Wait(TimeSpan.FromSeconds(3)); + lock (_gate) + { + var frame = _pending; + _pending = null; + if (frame is null) + { + args.Request.Sample = null; + _recording = false; + return; + } + var sample = WinMediaCore.MediaStreamSample.CreateFromDirect3D11Surface( + frame.Surface, frame.SystemRelativeTime); + sample.Duration = TimeSpan.FromMilliseconds(1000.0 / _fps); + args.Request.Sample = sample; + frame.Dispose(); + } + } + catch (Exception ex) + { + log?.LogWarning(ex, "[Recorder] 取帧失败, 提前收尾"); + args.Request.Sample = null; + _recording = false; + } + } + + private void Cleanup() + { + try + { + if (_mss is { } mss) + { + mss.Starting -= OnStarting; + mss.SampleRequested -= OnSampleRequested; + } + if (_framePool is { } pool) pool.FrameArrived -= OnFrameArrived; + _session?.Dispose(); + _framePool?.Dispose(); + } + catch (Exception ex) + { + log?.LogWarning(ex, "[Recorder] 清理异常"); + } + _session = null; + _framePool = null; + _mss = null; + _prepared = null; + } + + public void Dispose() => _ = StopAsync(); +} + +/// D3D11 设备创建与 WinRT IDirect3DDevice 桥 (官方互操作路径)。 +internal static class D3D11Helper +{ + private const uint D3D11_SDK_VERSION = 7; + private const int D3D_DRIVER_TYPE_HARDWARE = 1; + private const int D3D11_CREATE_DEVICE_BGRA_SUPPORT = 0x20; + private static readonly Guid IID_IDXGIDevice = new("54ec77fa-1377-44e6-8c32-88fd5f44c84c"); + + [DllImport("d3d11.dll", ExactSpelling = true, SetLastError = false)] + private static extern int D3D11CreateDevice( + nint pAdapter, int driverType, uint software, uint flags, + nint pFeatureLevels, uint numFeatureLevels, uint sdkVersion, + out nint ppDevice, out uint featureLevel, out nint ppImmediateContext); + + [DllImport("d3d11.dll", EntryPoint = "CreateDirect3D11DeviceFromDXGIDevice", ExactSpelling = true, SetLastError = false)] + private static extern int CreateDirect3D11DeviceFromDXGIDevice(nint dxgiDevice, out nint graphicsDevice); + + public static WinDirectX11.IDirect3DDevice CreateDevice() + { + var hr = D3D11CreateDevice(0, D3D_DRIVER_TYPE_HARDWARE, 0, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + 0, 0, D3D11_SDK_VERSION, out var d3dDevice, out var featureLevel, out var context); + Marshal.ThrowExceptionForHR(hr); + try + { + var iid = IID_IDXGIDevice; + hr = Marshal.QueryInterface(d3dDevice, in iid, out var dxgiDevice); + Marshal.ThrowExceptionForHR(hr); + try + { + hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice, out var inspectable); + Marshal.ThrowExceptionForHR(hr); + try + { + return MarshalInterface.FromAbi(inspectable); + } + finally { Marshal.Release(inspectable); } + } + finally { Marshal.Release(dxgiDevice); } + } + finally + { + Marshal.Release(d3dDevice); + if (context != 0) Marshal.Release(context); + } + } +} + +/// 主显示器 HMONITOR → GraphicsCaptureItem。 +/// 19041 投影的 GraphicsCaptureItem 仅有 CreateFromVisual —— 监视器捕获必须走 +/// IGraphicsCaptureItemInterop COM 互操作 (官方 CsWinRT 采样同路径)。 +internal static partial class GraphicsCaptureItemProbe +{ + private static readonly Guid GraphicsCaptureItemIid = new("79C3F95B-31F7-4EC2-A464-632EF5D30760"); + + public static long PrimaryMonitorHandle() + { + var hmon = MonitorFromPoint(new NativePoint { x = 0, y = 0 }, 1 /*MONITOR_DEFAULTTOPRIMARY*/); + return hmon; + } + + public static WinCapture.GraphicsCaptureItem CreateForMonitor(long hMonitor) + { + var factory = global::WinRT.ActivationFactory.Get("Windows.Graphics.Capture.GraphicsCaptureItem"); + var interop = factory.AsInterface(); + var iid = GraphicsCaptureItemIid; + var ptr = interop.CreateForMonitor((nint)hMonitor, in iid); + try + { + return WinCapture.GraphicsCaptureItem.FromAbi(ptr); + } + finally + { + Marshal.Release(ptr); + } + } + + [ComImport, Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IGraphicsCaptureItemInterop + { + nint CreateForWindow(nint window, in Guid iid); + + nint CreateForMonitor(nint hmonitor, in Guid iid); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativePoint { public int x, y; } + + [LibraryImport("user32.dll")] + private static partial nint MonitorFromPoint(NativePoint pt, uint flags); +} diff --git a/src/SmartSideBAR.Windows/Shell/DiagnosticsService.cs b/src/SmartSideBAR.Windows/Shell/DiagnosticsService.cs new file mode 100644 index 0000000..6455a12 --- /dev/null +++ b/src/SmartSideBAR.Windows/Shell/DiagnosticsService.cs @@ -0,0 +1,125 @@ +// Windows/Shell/DiagnosticsService.cs —— §6.12: 7 项真实探测聚合 + 诊断包导出 (P1-12 语义)。 +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Recording; + + +namespace SmartSideBAR.Windows.Shell; + +public interface IDiagnosticsService +{ + IReadOnlyDictionary RunFullCheck(); + string ExportPack(string dir); +} + +public sealed partial class DiagnosticsService( + ConfigService config, + AppBarService appBar, + IImeService ime, + IUsbWatcher usb, + IPrinterMonitor printer, + IRecorderApi recorder, + IShellService shell, + ILogger? log = null) : IDiagnosticsService +{ + public IReadOnlyDictionary RunFullCheck() + { + var result = new Dictionary(); + try + { + result["os"] = Environment.OSVersion.VersionString; + result["process"] = Environment.Is64BitProcess ? "x64" : "x86"; + result["screen"] = ScreenProbe(); + result["touch"] = TouchProbe(); + var imeState = ime.GetState(); + result["ime"] = $"{imeState.Locale} (zh={imeState.IsChinese})"; + var usbList = usb.ListRemovable(); + result["usb"] = usbList.Count == 0 ? "无可移动盘" : string.Join(", ", usbList.Select(d => $"{d.Drive} {d.SizeGb}GB")); + var printers = printer.Query(); + result["printers"] = printers.Count == 0 ? "无打印机" : string.Join(", ", printers.Select(p => $"{p.Name}={p.WireState}")); + result["appbar"] = appBar.IsRegistered ? $"已注册 ({appBar.GrantedRect})" : "未注册 (Topmost 回退或未启动)"; + result["recorder"] = recorder.IsSupported() ? "可用 (WGC)" : "不可用 (需 Win10 1803+)"; + result["autolaunch"] = shell.GetAutoLaunch() ? "已启用" : "未启用"; + result["captureDir"] = ProbeWritable(ExpandDir(config.Current.Capture.Dir)); + result["recorderDir"] = ProbeWritable(ExpandDir(config.Current.Recorder.Dir)); + result["configPath"] = config.Current.Version.ToString(); + } + catch (Exception ex) + { + result["error"] = ex.Message; + log?.LogWarning(ex, "[Diag] 巡检部分失败"); + } + return result; + } + + public string ExportPack(string dir) + { + Directory.CreateDirectory(dir); + var report = new Dictionary + { + ["generatedAt"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + ["checks"] = RunFullCheck(), + ["config"] = config.Current, + }; + var json = JsonSerializer.Serialize(report, ConfigJsonContext.Default.Options); + var reportPath = Path.Combine(dir, $"diag_{DateTime.Now:yyyyMMdd_HHmmss}"); + Directory.CreateDirectory(reportPath); + File.WriteAllText(Path.Combine(reportPath, "report.json"), json, Encoding.UTF8); + + // 日志打包 (7d 滚动目录整体复制) + var logsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SmartSideBAR", "logs"); + if (Directory.Exists(logsDir)) + { + var zipPath = Path.Combine(reportPath, "logs.zip"); + ZipFile.CreateFromDirectory(logsDir, zipPath); + } + log?.LogInformation("[Diag] 诊断包已导出 {Path}", reportPath); + return reportPath; + } + + private static string ExpandDir(string p) => p + .Replace("{Pictures}", Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)) + .Replace("{Videos}", Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)) + .Replace("{Home}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + + private static string ProbeWritable(string dir) + { + try + { + Directory.CreateDirectory(dir); + var probe = Path.Combine(dir, $".probe-{Guid.NewGuid():N}"); + File.WriteAllText(probe, "ok"); + File.Delete(probe); + return $"{dir} ✓ 可写"; + } + catch (Exception ex) + { + return $"{dir} ✗ {ex.Message}"; + } + } + + private static string ScreenProbe() + { + using var scope = new AppBarApi.PhysicalDpiScope(); + var hMonitor = AppBarNative.MonitorFromPoint(new AppBarNative.POINT(0, 0), 1); + var mi = new AppBarNative.MONITORINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!AppBarNative.GetMonitorInfo(hMonitor, ref mi)) return "未知"; + return $"{mi.rcMonitor.W}x{mi.rcMonitor.H} (物理), 工作区 {mi.rcWork.W}x{mi.rcWork.H}"; + } + + private static string TouchProbe() + { + var maxTouch = GetSystemMetrics(95 /*SM_MAXIMUMTOUCHES*/); + return maxTouch > 0 ? $"触屏 ({maxTouch} 点)" : "无触屏"; + } + + [System.Runtime.InteropServices.LibraryImport("user32.dll")] + private static partial int GetSystemMetrics(int nIndex); +} diff --git a/src/SmartSideBAR.Windows/Shell/ShellService.cs b/src/SmartSideBAR.Windows/Shell/ShellService.cs new file mode 100644 index 0000000..310b8fa --- /dev/null +++ b/src/SmartSideBAR.Windows/Shell/ShellService.cs @@ -0,0 +1,87 @@ +// Windows/Shell/ShellService.cs —— §6.8: taskmgr / 外链白名单 (B2 根修) / HKCU 自启 (P1-10 asInvoker 路径)。 +using System.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; +using SmartSideBAR.Core.Configuration; + +namespace SmartSideBAR.Windows.Shell; + +public interface IShellService +{ + void OpenTaskManager(); + void OpenExternal(Uri url); + void OpenPath(string path); + void RevealInExplorer(string path); + void SetAutoLaunch(bool enable); + bool GetAutoLaunch(); +} + +public sealed class ShellService(ConfigService config, ILogger? log = null) : IShellService +{ + private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string AppName = "SmartSideBAR"; + + // B2 白名单: https/http + 内置运维域名 + config.links 中用户配置的域名 + private static readonly string[] BuiltinHosts = + [ + "www.zxx.edu.cn", "easinote.seewo.com", "www.zxxk.com", "wenku.baidu.com", "www.cnki.net", + ]; + + public void OpenTaskManager() => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("taskmgr.exe") { UseShellExecute = true }); + + public void OpenExternal(Uri url) + { + if (url.Scheme is not ("http" or "https")) throw new SecurityException($"URL 协议不在白名单: {url.Scheme}"); + if (!IsAllowedHost(url.Host)) throw new SecurityException($"URL 域名不在白名单: {url.Host}"); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url.ToString()) { UseShellExecute = true }); + } + + public void OpenPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Directory.Exists(path) && !File.Exists(path)) throw new FileNotFoundException(path); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + } + + public void RevealInExplorer(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") + { + UseShellExecute = true, + }); + } + + public void SetAutoLaunch(bool enable) + { + using var key = Registry.CurrentUser.CreateSubKey(RunKey); + if (enable) + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) throw new InvalidOperationException("ProcessPath 不可用"); + key.SetValue(AppName, $"\"{exe}\" /silent"); + log?.LogInformation("[Shell] 已启用自启"); + } + else + { + key.DeleteValue(AppName, throwOnMissingValue: false); + log?.LogInformation("[Shell] 已禁用自启"); + } + } + + public bool GetAutoLaunch() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey); + return key?.GetValue(AppName) is string; + } + + private bool IsAllowedHost(string host) + { + var links = config.Current.Links.Select(l => l.Url) + .Select(u => Uri.TryCreate(u, UriKind.Absolute, out var uri) ? uri.Host : "") + .Where(h => h.Length > 0); + return BuiltinHosts.Contains(host, StringComparer.OrdinalIgnoreCase) + || links.Contains(host, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj b/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj new file mode 100644 index 0000000..63c67be --- /dev/null +++ b/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj @@ -0,0 +1,22 @@ + + + + + net10.0-windows10.0.19041.0 + SmartSideBAR.Windows + true + + + + + + + + + + + + + + diff --git a/tests/SmartSideBAR.Core.Tests/ConfigServiceTests.cs b/tests/SmartSideBAR.Core.Tests/ConfigServiceTests.cs new file mode 100644 index 0000000..f1745a6 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/ConfigServiceTests.cs @@ -0,0 +1,197 @@ +// Core/Configuration/ConfigServiceTests —— 三层合并/迁移/白名单/钳制/未知字段保留 +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Messaging; +using System.Text.Json.Nodes; + +namespace SmartSideBAR.Core.Tests; + +public sealed class ConfigServiceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "ssb-cfg-tests-" + Guid.NewGuid().ToString("N")[..8]); + + private ConfigPaths MakePaths( + string? userFile = "config.json", + string? pdFile = "policy.json", + string? legacyUserFile = null, + string? legacyPdFile = null) + { + return new ConfigPaths( + UserConfigPath: userFile is null ? Path.Combine(_dir, "missing-dir-x", "c.json") : Path.Combine(_dir, "user", userFile), + ProgramDataConfigPath: pdFile is null ? Path.Combine(_dir, "missing-dir-y", "p.json") : Path.Combine(_dir, "pd", pdFile), + LegacyUserConfigPath: legacyUserFile is null ? null : Path.Combine(_dir, "legacy", legacyUserFile), + LegacyProgramDataConfigPath: legacyPdFile is null ? null : Path.Combine(_dir, "legacy", legacyPdFile)); + } + + [Fact] + public void Load_NoFiles_WritesDefaultsAndCreatesFile() + { + var svc = new ConfigService(MakePaths(userFile: null)); + svc.Load(); + Assert.Equal(SidekickConfig.CurrentVersion, svc.Current.Version); + Assert.Equal("Microsoft Pinyin", svc.Current.Ime.Slot1); + Assert.Equal(5, svc.Current.Links.Count); + Assert.True(svc.Current.Usb.Enabled); + } + + [Fact] + public void Load_ThreeLayer_Precedence_PdOverUserOverDefault() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "pd")); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.ProgramDataConfigPath, """{"printer":{"pollIntervalSec":30}}"""); + File.WriteAllText(paths.UserConfigPath, """{"ime":{"slot1":"搜狗输入法"}}"""); + + var svc = new ConfigService(paths); + svc.Load(); + + Assert.Equal(30, svc.Current.Printer.PollIntervalSec); // 策略层 + Assert.Equal("搜狗输入法", svc.Current.Ime.Slot1); // 用户层 + Assert.Equal("US", svc.Current.Ime.Slot2); // 默认层未被覆盖的兄弟字段保留 + } + + [Fact] + public void Load_NullOverlayValue_IsSkipped_BaseKept() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.UserConfigPath, """{"ime":{"slot1":null}}"""); + var svc = new ConfigService(paths); + svc.Load(); + Assert.Equal("Microsoft Pinyin", svc.Current.Ime.Slot1); + } + + [Fact] + public void Load_ArrayOverlay_ReplacesWhole() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.UserConfigPath, """{"links":[{"id":"x1","name":"单条","url":"https://a.b","enabled":true}]}"""); + var svc = new ConfigService(paths); + svc.Load(); + Assert.Single(svc.Current.Links); + Assert.Equal("x1", svc.Current.Links[0].Id); + } + + [Fact] + public void Load_UnknownFields_PreservedAcrossSave() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.UserConfigPath, + """{"futureField":{"a":1},"ime":{"slot1":"搜狗输入法"}}"""); + var svc = new ConfigService(paths); + svc.Load(); + Assert.True(svc.Set("printer.pollIntervalSec", 42)); + + var onDisk = JsonNode.Parse(File.ReadAllText(paths.UserConfigPath))!; + Assert.NotNull(onDisk["futureField"]); // 未知名段不丢失 (migrateConfig 语义) + Assert.Equal(42, (int)onDisk["printer"]!["pollIntervalSec"]!); + } + + [Fact] + public void Load_VersionLagged_MigratesAndPersists() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.UserConfigPath, """{"version":1}"""); + var svc = new ConfigService(paths); + svc.Load(); + Assert.Equal(SidekickConfig.CurrentVersion, svc.Current.Version); + var onDisk = JsonNode.Parse(File.ReadAllText(paths.UserConfigPath))!; + Assert.Equal(SidekickConfig.CurrentVersion, (int)onDisk["version"]!); + } + + [Fact] + public void Load_LegacyUserConfig_MigratedAndRenamed() + { + var legacyPath = MakePaths(legacyUserFile: "config.json").LegacyUserConfigPath!; + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + File.WriteAllText(legacyPath, """{"ime":{"slot1":"旧输入法"}}"""); + + var svc = new ConfigService(MakePaths(legacyUserFile: "config.json")); + svc.Load(); + + Assert.Equal("旧输入法", svc.Current.Ime.Slot1); + Assert.False(File.Exists(legacyPath)); + Assert.True(File.Exists(legacyPath + ".migrated.bak")); + } + + [Fact] + public void Load_LegacyProgramData_ReadAsPolicyLayer() + { + var legacyPd = MakePaths(legacyPdFile: "seewo.json").LegacyProgramDataConfigPath!; + Directory.CreateDirectory(Path.GetDirectoryName(legacyPd)!); + File.WriteAllText(legacyPd, """{"printer":{"pollIntervalSec":77}}"""); + var svc = new ConfigService(MakePaths(legacyPdFile: "seewo.json")); + svc.Load(); + Assert.Equal(77, svc.Current.Printer.PollIntervalSec); + } + + [Fact] + public void Load_PathTokens_Expanded() + { + var svc = new ConfigService(MakePaths()); + svc.Load(); + var pictures = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + Assert.StartsWith(pictures, svc.Current.Capture.Dir); + Assert.EndsWith("Sidekick", svc.Current.Capture.Dir); + } + + [Fact] + public void Load_ClampsAppearanceAndFloatBall() + { + var paths = MakePaths(); + Directory.CreateDirectory(Path.Combine(_dir, "user")); + File.WriteAllText(paths.UserConfigPath, + """{"appearance":{"blur":999,"accent":"abc","theme":"neon"},"floatBall":{"size":999,"actions":["capture","bogus","capture"]}}"""); + var svc = new ConfigService(paths); + svc.Load(); + Assert.Equal(40, svc.Current.Appearance.Blur); + Assert.Equal("#2B6EE0", svc.Current.Appearance.Accent); + Assert.Equal("auto", svc.Current.Appearance.Theme); + Assert.Equal(96, svc.Current.FloatBall.Size); + Assert.Equal(["capture"], svc.Current.FloatBall.Actions); + } + + [Fact] + public void Set_UnknownKey_Rejected() + { + var svc = new ConfigService(MakePaths()); + svc.Load(); + Assert.False(svc.Set("evil.section", 1)); + Assert.False(svc.Set("ime.notARealField", 1)); + } + + [Fact] + public void Set_KnownKey_UpdatesPersistsAndPublishes() + { + ConfigChanged? received = null; + var bus = new EventBus(); + bus.Subscribe(e => received = e); + var svc = new ConfigService(MakePaths(), bus); + svc.Load(); + + Assert.True(svc.Set("printer.pollIntervalSec", 42)); + Assert.Equal(42, svc.Current.Printer.PollIntervalSec); + Assert.NotNull(received); + Assert.Equal(42, received!.Snapshot.Printer.PollIntervalSec); + } + + [Fact] + public void SetRemindersIfChanged_OnlyWritesOnActualChange() + { + var svc = new ConfigService(MakePaths()); + svc.Load(); + var empty = Array.Empty(); + Assert.False(svc.SetRemindersIfChanged(empty)); // 无变化不落盘 (C1) + var list = new List { new() { Id = "r1", Kind = ReminderKind.Once, At = 123 } }; + Assert.True(svc.SetRemindersIfChanged(list)); + Assert.False(svc.SetRemindersIfChanged(list)); // 同内容再次跳过 + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* 临时目录清理尽力而为 */ } + } +} diff --git a/tests/SmartSideBAR.Core.Tests/EventBusTests.cs b/tests/SmartSideBAR.Core.Tests/EventBusTests.cs new file mode 100644 index 0000000..5b91967 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/EventBusTests.cs @@ -0,0 +1,65 @@ +// EventBusTests —— IPC 事件推送的结构性替代 (附录 A) +using SmartSideBAR.Core.Messaging; + +namespace SmartSideBAR.Core.Tests; + +public sealed class EventBusTests +{ + [Fact] + public void Publish_ReachesSubscriber() + { + var bus = new EventBus(); + var received = new List(); + using var sub = bus.Subscribe(received.Add); + bus.Publish("hello"); + Assert.Equal(["hello"], received); + } + + [Fact] + public void Dispose_StopsDelivery() + { + var bus = new EventBus(); + var received = new List(); + var sub = bus.Subscribe(received.Add); + bus.Publish("a"); + sub.Dispose(); + bus.Publish("b"); + Assert.Equal(["a"], received); + } + + [Fact] + public void MultipleHandlers_AllReceive() + { + var bus = new EventBus(); + var a = new List(); + var b = new List(); + using var _ = bus.Subscribe(a.Add); + using var __ = bus.Subscribe(b.Add); + bus.Publish(7); + Assert.Equal([7], a); + Assert.Equal([7], b); + } + + [Fact] + public void TypesAreIsolated() + { + var bus = new EventBus(); + var ints = new List(); + var strings = new List(); + using var _ = bus.Subscribe(ints.Add); + using var __ = bus.Subscribe(strings.Add); + bus.Publish(1); + Assert.Empty(strings); + } + + [Fact] + public void HandlerException_DoesNotBlockOthers() + { + var bus = new EventBus(); + var received = new List(); + using var throwing = bus.Subscribe(_ => throw new InvalidOperationException("boom")); + using var normal = bus.Subscribe(received.Add); + bus.Publish(5); + Assert.Equal([5], received); + } +} diff --git a/tests/SmartSideBAR.Core.Tests/FloatBallLayoutTests.cs b/tests/SmartSideBAR.Core.Tests/FloatBallLayoutTests.cs new file mode 100644 index 0000000..d6c2593 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/FloatBallLayoutTests.cs @@ -0,0 +1,94 @@ +// FloatBallLayoutTests —— shared/floatball-layout.ts 逐函数移植的行为对齐验证 +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.FloatBall; + +namespace SmartSideBAR.Core.Tests; + +public sealed class FloatBallLayoutTests +{ + [Fact] + public void FanRadius_Bounds() + { + Assert.Equal(96, FloatBallLayout.FanRadius(1)); + Assert.Equal(96, FloatBallLayout.FanRadius(0)); + Assert.InRange(FloatBallLayout.FanRadius(8), 96, 260); + } + + [Fact] + public void ClampToArea_ClampsInside() + { + var area = new RectLike(0, 0, 1920, 1040); + var p = FloatBallLayout.ClampToArea((5000, -100), (56, 56), area); + Assert.Equal(1920 - 56, p.X); + Assert.Equal(0, p.Y); + } + + [Fact] + public void SnapToEdges_NearLeftSnaps() + { + var area = new RectLike(0, 0, 1920, 1040); + var (x, y, edge) = FloatBallLayout.SnapToEdges((5, 500), (56, 56), area, 24); + Assert.Equal(0, x); + Assert.Equal("left", edge); + } + + [Fact] + public void SnapToEdges_BeyondThreshold_NoSnap() + { + var area = new RectLike(0, 0, 1920, 1040); + var (_, _, edge) = FloatBallLayout.SnapToEdges((500, 500), (56, 56), area, 24); + Assert.Null(edge); + } + + [Fact] + public void SnapToEdges_ZeroThreshold_Disabled() + { + var area = new RectLike(0, 0, 1920, 1040); + var (_, _, edge) = FloatBallLayout.SnapToEdges((1, 1), (56, 56), area, 0); + Assert.Null(edge); + } + + [Fact] + public void ResolveBallPosition_Unset_UsesDefaultNearSide() + { + var area = new RectLike(0, 0, 1920, 1040); + var (x, _) = FloatBallLayout.ResolveBallPosition((-1, -1), area, 56, "right"); + Assert.True(x > 1920 - 56 - 20, $"右侧默认位应贴右缘, 得到 x={x}"); + } + + [Fact] + public void ResolveBallPosition_Configured_Clamped() + { + var area = new RectLike(0, 0, 1920, 1040); + var (x, _) = FloatBallLayout.ResolveBallPosition((9999, 100), area, 56, "right"); + Assert.Equal(1920 - 56, x); + } + + [Fact] + public void Clamp_NormalizesFloatBallConfig() + { + var cfg = new FloatBallConfig + { + Size = 500, + IdleOpacity = 5, + Actions = ["capture", "longshot", "capture", "bogus"], + DoubleClick = "explode", + }; + var clamped = FloatBallNormalize.Clamp(cfg); + Assert.Equal(96, clamped.Size); + Assert.Equal(1, clamped.IdleOpacity); + Assert.Equal(["capture", "longshot"], clamped.Actions); // 去重 + 白名单过滤 + Assert.Equal("toggleSidebar", clamped.DoubleClick); // 非法值回默认 + } + + [Fact] + public void Clamp_AppearanceAccent() + { + Assert.Equal("#2b6ee0", AppearanceNormalize.NormalizeAccent("#2B6EE0")); + // 3 位缩写按 JS 语义逐位复制: #2b6 → #22bb66 + Assert.Equal("#22bb66", AppearanceNormalize.NormalizeAccent("#2b6")); + Assert.Equal("#2B6EE0", AppearanceNormalize.NormalizeAccent("not-a-color")); + Assert.Equal("#2B6EE0", AppearanceNormalize.NormalizeAccent("#zzz")); + Assert.Equal("#2B6EE0", AppearanceNormalize.NormalizeAccent(null)); + } +} diff --git a/tests/SmartSideBAR.Core.Tests/GlobalUsings.cs b/tests/SmartSideBAR.Core.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/SmartSideBAR.Core.Tests/LinksServiceTests.cs b/tests/SmartSideBAR.Core.Tests/LinksServiceTests.cs new file mode 100644 index 0000000..d6f4e33 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/LinksServiceTests.cs @@ -0,0 +1,79 @@ +// LinksServiceTests —— E1/P1-5: 过滤仅在读取视图, 保存全量回写 +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Links; + +namespace SmartSideBAR.Core.Tests; + +public sealed class LinksServiceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "ssb-link-tests-" + Guid.NewGuid().ToString("N")[..8]); + + private (ConfigService Config, LinksService Links, string UserPath) Create() + { + var userPath = Path.Combine(_dir, "user", "config.json"); + var svc = new ConfigService(new ConfigPaths( + userPath, Path.Combine(_dir, "pd", "policy.json"), + LegacyUserConfigPath: null, LegacyProgramDataConfigPath: null)); + svc.Load(); + return (svc, new LinksService(svc), userPath); + } + + [Fact] + public void List_FiltersDisabledAndEmptyUrl_OnlyInView() + { + var (_, links, _) = Create(); + // 默认链接含 l4/l5 两个 disabled —— 全量 5 条, 视图 3 条 + Assert.Equal(5, links.ListAll().Count); + Assert.Equal(3, links.List().Count); + } + + [Fact] + public void Add_PersistsFullList_IncludingDisabled() + { + var (config, links, userPath) = Create(); + links.Add("新链接", "https://new.example.com"); + Assert.Equal(6, config.Current.Links.Count); // 禁用项未被"过滤写回"丢失 + Assert.Contains(config.Current.Links, l => l.Id == "l5"); // disabled 仍在 + } + + [Fact] + public void Remove_WritesBackFullList() + { + var (config, links, _) = Create(); + Assert.True(links.Remove("l1")); + Assert.Equal(4, config.Current.Links.Count); + Assert.DoesNotContain(config.Current.Links, l => l.Id == "l1"); + } + + [Fact] + public void Update_UrlOnly_PreservesOtherFields() + { + var (config, links, _) = Create(); + var before = config.Current.Links.First(l => l.Id == "l2"); + Assert.True(links.Update("l2", url: "https://easinote.seewo.com/v2")); + var after = config.Current.Links.First(l => l.Id == "l2"); + Assert.Equal("https://easinote.seewo.com/v2", after.Url); + Assert.Equal(before.Name, after.Name); + Assert.Equal(before.Enabled, after.Enabled); + } + + [Fact] + public void Move_ReordersInFullList() + { + var (config, links, _) = Create(); + Assert.True(links.Move("l5", 0)); + Assert.Equal("l5", config.Current.Links[0].Id); + } + + [Fact] + public void Persist_Failure_KeepsConsistent() + { + var (_, links, _) = Create(); + Assert.False(links.Update("no-such-id", name: "x")); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } +} diff --git a/tests/SmartSideBAR.Core.Tests/SchedulerServiceTests.cs b/tests/SmartSideBAR.Core.Tests/SchedulerServiceTests.cs new file mode 100644 index 0000000..52bd35f --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/SchedulerServiceTests.cs @@ -0,0 +1,149 @@ +// SchedulerServiceTests —— FakeTimeProvider 驱动的可测调度 (方案 §6.10) +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Core.Scheduling; +using SmartSideBAR.Core.Tests.TestDoubles; + +namespace SmartSideBAR.Core.Tests; + +public sealed class SchedulerServiceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "ssb-sched-tests-" + Guid.NewGuid().ToString("N")[..8]); + + private (SchedulerService Svc, FakeTimeProvider Time, EventBus Bus, List Due) Create( + List? seed = null) + { + var userPath = Path.Combine(_dir, "user", "config.json"); + var config = new ConfigService(new ConfigPaths( + userPath, Path.Combine(_dir, "pd", "policy.json"), null, null)); + config.Load(); + if (seed is { Count: > 0 }) + { + config.Set("reminders", seed); + } + var bus = new EventBus(); + var due = new List(); + bus.Subscribe(due.Add); + var time = new FakeTimeProvider(); + return (new SchedulerService(config, bus, time), time, bus, due); + } + + private static long NowOf(FakeTimeProvider time) => time.GetUtcNow().ToUnixTimeMilliseconds(); + + [Fact] + public void Start_OverduePeriodic_AdvancesWithoutFiring() + { + var time0 = new FakeTimeProvider(); + var overdue = NowOf(time0) - 10 * 60_000L; + var (svc, time, _, due) = Create( + [ + new Reminder { Id = "p1", Kind = ReminderKind.Interval, At = overdue, RepeatMin = 5, Note = "周期" }, + ]); + svc.Start(); + Assert.Empty(due); // 方案 §6.10: 启动不补触发 + var scheduled = svc.ListReminders()[0]; + Assert.True(scheduled.At > NowOf(time)); // 已推进到未来槽位 + svc.Stop(); + } + + [Fact] + public void Start_OnceExpiredOver1h_CleanedSilently() + { + var time0 = new FakeTimeProvider(); + var stale = NowOf(time0) - 2 * 3_600_000L; + var recent = NowOf(time0) - 30 * 60_000L; + var (svc, time, _, due) = Create( + [ + new Reminder { Id = "stale", Kind = ReminderKind.Once, At = stale }, + new Reminder { Id = "recent", Kind = ReminderKind.Once, At = recent }, + ]); + svc.Start(); + time.Advance(TimeSpan.FromSeconds(2)); + // >1h 过期: 清理不触发 (v1.2.0 语义); ≤1h: 首个 tick 补触发一次后移除 + Assert.DoesNotContain("stale", due.Select(d => d.Id)); + Assert.Single(due); + Assert.Equal("recent", due[0].Id); + Assert.Empty(svc.ListReminders()); // stale 被清理 + recent 触发即移除 + svc.Stop(); + } + + [Fact] + public void Once_FiresAndRemoved() + { + var (svc, time, _, due) = Create(); + svc.Start(); + svc.AddReminder(ReminderKind.Once, NowOf(time) + 3_000, note: "上课"); + time.Advance(TimeSpan.FromSeconds(4)); + Assert.Single(due); + Assert.Equal("上课", due[0].Note); + Assert.Empty(svc.ListReminders()); + svc.Stop(); + } + + [Fact] + public void Interval_FiresAndAdvances() + { + var (svc, time, _, due) = Create(); + svc.Start(); + svc.AddReminder(ReminderKind.Interval, NowOf(time) + 2_000, note: "坐姿", repeatMin: 5); + time.Advance(TimeSpan.FromSeconds(3)); + Assert.Single(due); + var r = svc.ListReminders().Single(); + Assert.True(r.At >= NowOf(time)); // 顺延到未来 + time.Advance(TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(2)); + Assert.Equal(2, due.Count); // 下个周期再触发 + svc.Stop(); + } + + [Fact] + public void Hourly_AlignsToNextFullHour() + { + var (svc, time, _, due) = Create(); + svc.Start(); + svc.AddReminder(ReminderKind.Hourly, NowOf(time) + 1_000, note: "眼保健操"); + time.Advance(TimeSpan.FromSeconds(2)); + Assert.Single(due); + var next = DateTimeOffset.FromUnixTimeMilliseconds(svc.ListReminders().Single().At).LocalDateTime; + Assert.Equal(0, next.Minute); + Assert.Equal(0, next.Second); + Assert.Equal(0, next.Millisecond); + svc.Stop(); + } + + [Fact] + public void Snooze_PostponesFiring() + { + var (svc, time, _, due) = Create(); + svc.Start(); + var r = svc.AddReminder(ReminderKind.Once, NowOf(time) + 1_000, note: "贪睡"); + time.Advance(TimeSpan.FromSeconds(2)); + Assert.Single(due); // 到点触发 + Assert.Empty(svc.ListReminders()); // once 触发即移除 → 贪睡语义作用于触发前 + // 触发前贪睡: + var (svc2, time2, _, due2) = Create(); + svc2.Start(); + var r2 = svc2.AddReminder(ReminderKind.Once, NowOf(time2) + 1_000, note: "贪睡2"); + Assert.True(svc2.Snooze(r2.Id, 5)); + time2.Advance(TimeSpan.FromSeconds(5)); + Assert.Empty(due2); // 原时间点不触发 + time2.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(58)); + Assert.Single(due2); // 贪睡到期触发 + svc.Stop(); + svc2.Stop(); + } + + [Fact] + public void AddReminder_PersistsToConfig() + { + var (svc, time, _, _) = Create(); + svc.Start(); + svc.AddReminder(ReminderKind.Once, NowOf(time) + 60_000, note: "落盘验证"); + Assert.Single(svc.ListReminders()); + svc.Stop(); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } +} diff --git a/tests/SmartSideBAR.Core.Tests/SmartSideBAR.Core.Tests.csproj b/tests/SmartSideBAR.Core.Tests/SmartSideBAR.Core.Tests.csproj new file mode 100644 index 0000000..55b917b --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/SmartSideBAR.Core.Tests.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + false + + + + + + + + + + + + + diff --git a/tests/SmartSideBAR.Core.Tests/TestDoubles/FakeTimeProvider.cs b/tests/SmartSideBAR.Core.Tests/TestDoubles/FakeTimeProvider.cs new file mode 100644 index 0000000..8113bab --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/TestDoubles/FakeTimeProvider.cs @@ -0,0 +1,82 @@ +// 测试假件: 可手动推进的 TimeProvider (方案 §6.10 可测调度)。 +namespace SmartSideBAR.Core.Tests.TestDoubles; + +public sealed class FakeTimeProvider : TimeProvider +{ + private sealed class FakeTimer(FakeTimeProvider owner, TimerCallback callback, object? state) + : ITimer + { + public DateTimeOffset NextDue; + public TimeSpan Period = Timeout.InfiniteTimeSpan; + public bool Disposed; + + public void Schedule(TimeSpan dueTime, TimeSpan period) + { + Period = period; + NextDue = owner.UtcNow + (dueTime == Timeout.InfiniteTimeSpan ? TimeSpan.MaxValue : dueTime); + } + + public bool Dispose(bool wait) { Disposed = true; return true; } + public void Dispose() { Disposed = true; } + public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } + public bool Change(TimeSpan dueTime, TimeSpan period) + { + Schedule(dueTime, period); + return true; + } + + public void FireIfDue() + { + while (!Disposed && NextDue <= owner.UtcNow) + { + NextDue += Period == Timeout.InfiniteTimeSpan ? TimeSpan.MaxValue : Period; + callback(state); + } + } + } + + private readonly List _timers = []; + private DateTimeOffset _utcNow = new(2026, 9, 6, 8, 0, 0, TimeSpan.Zero); + + public DateTimeOffset UtcNow => _utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new FakeTimer(this, callback, state); + timer.Schedule(dueTime, period); + _timers.Add(timer); + return timer; + } + + public void Advance(TimeSpan delta) + { + var target = _utcNow + delta; + // 逐周期推进, 保证周期定时器按节奏触发而非一次性追赶 + while (_utcNow < target) + { + _utcNow += TimeSpan.FromMilliseconds(Math.Min(1000, (target - _utcNow).TotalMilliseconds)); + FireDue(); + } + FireDue(); + } + + public void FireDue() + { + // 可能存在触发顺序敏感性, 反复扫描直到无到期 + for (var pass = 0; pass < 10; pass++) + { + var fired = false; + foreach (var t in _timers.ToArray()) + { + if (!t.Disposed && t.NextDue <= _utcNow) + { + t.FireIfDue(); + fired = true; + } + } + if (!fired) break; + } + } +} diff --git a/tests/SmartSideBAR.Core.Tests/WaveBLogicTests.cs b/tests/SmartSideBAR.Core.Tests/WaveBLogicTests.cs new file mode 100644 index 0000000..30e98f2 --- /dev/null +++ b/tests/SmartSideBAR.Core.Tests/WaveBLogicTests.cs @@ -0,0 +1,78 @@ +// Wave B 纯逻辑测试: 打印机映射 (E4 根修) + 热键解析/替代建议 (P2-2) +using SmartSideBAR.Core.Devices; +using SmartSideBAR.Core.Input; + +namespace SmartSideBAR.Core.Tests; + +public sealed class PrinterStateMapperTests +{ + [Theory] + [InlineData(4u, (ushort)3, PrinterState.OutOfPaper)] // NoPaper 优先于 Idle (E4: 不再误判) + [InlineData(8u, (ushort)3, PrinterState.Jammed)] // Jammed + [InlineData(9u, (ushort)3, PrinterState.Offline)] // Offline + [InlineData(5u, (ushort)3, PrinterState.LowInk)] // LowToner + [InlineData(3u, (ushort)3, PrinterState.LowPaper)] + [InlineData(7u, (ushort)3, PrinterState.DoorOpen)] + [InlineData(2u, (ushort)3, PrinterState.Idle)] // NoError + Idle + [InlineData(2u, (ushort)4, PrinterState.Ok)] // 打印中 = 可用 + [InlineData(null, (ushort)7, PrinterState.Offline)] + [InlineData(null, (ushort)2, PrinterState.Unknown)] + public void Map_DetectedErrorStateWins(uint? detected, ushort status, PrinterState expected) + { + Assert.Equal(expected, PrinterStateMapper.Map(detected, status)); + } + + [Fact] + public void ToWire_AlignsTypeScriptUnion() + { + Assert.Equal("ok", PrinterStateMapper.ToWire(PrinterState.Ok)); + Assert.Equal("ok", PrinterStateMapper.ToWire(PrinterState.Idle)); + Assert.Equal("out_of_paper", PrinterStateMapper.ToWire(PrinterState.OutOfPaper)); + Assert.Equal("low_ink", PrinterStateMapper.ToWire(PrinterState.LowInk)); + Assert.Equal("jammed", PrinterStateMapper.ToWire(PrinterState.Jammed)); + Assert.Equal("offline", PrinterStateMapper.ToWire(PrinterState.Offline)); + Assert.Equal("unknown", PrinterStateMapper.ToWire(PrinterState.Unknown)); + } +} + +public sealed class HotkeyParserTests +{ + [Fact] + public void Parse_CtrlShiftA() + { + var ok = HotkeyParser.TryParse("Ctrl+Shift+A", out var combo); + Assert.True(ok); + Assert.Equal(HotkeyCombo.ModCtrl | HotkeyCombo.ModShift, combo.Modifiers); + Assert.Equal((uint)'A', combo.VirtualKey); + Assert.Equal("Ctrl+Shift+A", combo.ToString()); + } + + [Fact] + public void Parse_F5_AndSpace() + { + Assert.True(HotkeyParser.TryParse("f5", out var f5)); + Assert.Equal(0x74u, f5.VirtualKey); + Assert.True(HotkeyParser.TryParse("Alt+Space", out var altSpace)); + Assert.Equal(0x20u, altSpace.VirtualKey); + } + + [Theory] + [InlineData("")] + [InlineData("Ctrl")] + [InlineData("Ctrl++")] + [InlineData("Ctrl+Bad!Key")] + public void Parse_RejectsInvalid(string text) + { + Assert.False(HotkeyParser.TryParse(text, out _)); + } + + [Fact] + public void SuggestAlternatives_NeverReturnsOriginal() + { + HotkeyParser.TryParse("Ctrl+Shift+A", out var original); + var suggestions = HotkeyParser.SuggestAlternatives(original); + Assert.NotEmpty(suggestions); + Assert.DoesNotContain(original, suggestions); + Assert.All(suggestions, s => Assert.True(HotkeyParser.TryParse(s.ToString(), out _))); + } +} diff --git a/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs new file mode 100644 index 0000000..9b062a8 --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs @@ -0,0 +1,214 @@ +// AppBarServiceTests —— 通知状态机全链路断言 (方案 §5.6 验收 V1-V8 的自动化层 + §7.1 示例) +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Tests.TestDoubles; +using Xunit; + +namespace SmartSideBAR.Windows.Tests; + +public sealed class AppBarServiceTests +{ + private const nint Hwnd = 0x1234; + private const uint CallbackMsg = AppBarNative.AppBarCallbackMsg; + private const int Rail = 64; + private const int Expanded = 420; + + private static (nuint WParam, nint LParam) N(uint wp, int lp = 0) => (wp, lp); + + [Fact] + public void Attach_SelfHeals_Registers_AndPositionsRail() + { + var (svc, api, hook, _, geometry, _, _) = TestEnv.CreateService(); + + Assert.True(svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded)); + + // V6 自愈: 注册前先补发一次 ABM_REMOVE 清孤儿注册 + Assert.Single(api.RemoveCalls); + Assert.Equal([("NEW", CallbackMsg)], api.RegisterCalls.Select(r => ("NEW", r.CallbackMsg)).ToList()); + Assert.Contains("QUERYPOS", api.Ops); + Assert.Contains("SETPOS", api.Ops); + Assert.Contains("WINDOWPOSCHANGED", api.Ops); + // 首次以 rail 宽度落位 (物理像素, K4) —— v1.1 悬浮胶囊: 上下各留 8% 工作区 + var first = Assert.Single(api.MoveRequests); + Assert.Equal(Hwnd, first.Hwnd); + Assert.Equal(Rail, first.Rect.W); + Assert.Equal(874, first.Rect.H); // 工作区 84% 胶囊高 + Assert.Equal(83, first.Rect.Y); // 顶部 8% 边距 + Assert.Equal(1920 - Rail, first.Rect.X); // 右缘贴齐 + Assert.True(hook.HasHook(Hwnd)); // K1 闭环的前提: 钩子已挂 + Assert.Single(geometry); // AppBarGeometryChanged 已广播 + Assert.True(svc.IsRegistered); + } + + [Fact] + public void Attach_RegisterFails_FallsBack_NoHookNoMove() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + api.RegisterResult = false; + + Assert.False(svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded)); + Assert.False(svc.IsRegistered); + Assert.False(hook.HasHook(Hwnd)); // 失败不挂钩子 + Assert.Empty(api.MoveRequests); // 失败不落位 + Assert.Single(api.RegisterCalls); + } + + [Fact] + public void Attach_RightEdge_QueryRect_AnchorsToFullMonitor() + { + var (svc, api, _, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var q = Assert.Single(api.QueryCalls); + Assert.Equal(AppBarNative.ABE_RIGHT, q.Edge); + // 勘误 (方案 §5.3): 锚点为 rcMonitor 全屏矩形 —— rcWork 会引发占位带逐次内漂 + Assert.Equal(1920 - Rail, q.Want.X); + Assert.Equal(Rail, q.Want.W); + Assert.Equal(874, q.Want.H); // 胶囊高提议 (工作区 84%) + } + + [Fact] + public void Reposition_IsStable_UnderRepeatedPosChanged() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + + // 模拟注册期 ABN_POSCHANGED 风暴: 授予矩形必须恒定, 不允许向屏幕内侧漂移 + for (var i = 0; i < 6; i++) + { + hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_POSCHANGED, 0); + } + + Assert.Equal(7, api.MoveRequests.Count); + Assert.All(api.MoveRequests, m => + { + Assert.Equal(1920 - Rail, m.Rect.X); + Assert.Equal(Rail, m.Rect.W); + Assert.Equal(874, m.Rect.H); // 反复重排不漂移、不蚕食 + }); + } + + [Fact] + public void AbnPosChanged_RequeriesAndRepositions() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var before = api.QueryCalls.Count; + + // 模拟任务栏换边等 WorkArea 变化 (K1) + var (result, handled) = hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_POSCHANGED, 0); + + Assert.True(handled); + Assert.Equal(0, result); + Assert.Equal(before + 1, api.QueryCalls.Count); // 重协商发生 + Assert.Equal(2, api.MoveRequests.Count); + Assert.Equal(Rail, api.MoveRequests[^1].Rect.W); + } + + [Fact] + public void AbnFullscreen_CollapsesToRail_RestoresUserPrefOnExit() + { + var (svc, api, hook, _, _, fullscreen, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + svc.SetExpanded(true); // 用户偏好: 展开 + Assert.Equal(Expanded, api.MoveRequests[^1].Rect.W); + + // 全屏授课视频启动 (K3 / V5) + hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_FULLSCREENAPP, 1); + Assert.True(svc.FullscreenActive); + Assert.Equal(Rail, api.MoveRequests[^1].Rect.W); // 收起为 rail + Assert.Single(fullscreen); + + // 全屏结束 → 按用户偏好恢复展开 + hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_FULLSCREENAPP, 0); + Assert.False(svc.FullscreenActive); + Assert.Equal(Expanded, api.MoveRequests[^1].Rect.W); + Assert.Equal(2, fullscreen.Count); + } + + [Fact] + public void AbnStateChange_Repositions() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var before = api.MoveRequests.Count; + hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_STATECHANGE, 0); + Assert.Equal(before + 1, api.MoveRequests.Count); + } + + [Fact] + public void AbnWindowArrange_PublishesPending() + { + var (svc, _, hook, _, _, _, arrange) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + hook.Simulate(Hwnd, CallbackMsg, AppBarNative.ABN_WINDOWARRANGE, 1); + Assert.Single(arrange); + Assert.True(arrange[0].Hiding); + } + + [Fact] + public void WmActivate_ReportsActivateToSystem() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + hook.Simulate(Hwnd, AppBarNative.WM_ACTIVATE, 1, 0); + Assert.Single(api.ActivateCalls); // K2: 激活上报 + } + + [Fact] + public void WmDisplayChange_Repositions() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var before = api.QueryCalls.Count; + hook.Simulate(Hwnd, AppBarNative.WM_DISPLAYCHANGE, 0, 0); // K7: 分辨率变化 + Assert.Equal(before + 1, api.QueryCalls.Count); + } + + [Fact] + public void OtherMessages_PassThroughUnhandled() + { + var (svc, _, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var (_, handled) = hook.Simulate(Hwnd, 0x0084 /*WM_NCHITTEST*/, 0, 0); + Assert.False(handled); // 不拦截无关消息 + } + + [Fact] + public void SetExpanded_MovesToExpandedWidth() + { + var (svc, api, _, _, geometry, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var geometryBefore = geometry.Count; + svc.SetExpanded(true); + Assert.Equal(Expanded, api.MoveRequests[^1].Rect.W); + Assert.Equal(geometryBefore + 1, geometry.Count); + Assert.Equal(Expanded, geometry[^1].WidthPx); + svc.SetExpanded(false); + Assert.Equal(Rail, api.MoveRequests[^1].Rect.W); + } + + [Fact] + public void Detach_RestoresSystemWorkArea() + { + var (svc, api, hook, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + var removesAfterAttach = api.RemoveCalls.Count; + + svc.Detach(); + + Assert.False(svc.IsRegistered); + Assert.False(hook.HasHook(Hwnd)); // 钩子已摘 + Assert.Equal(removesAfterAttach + 1, api.RemoveCalls.Count); // V8: 注销 + svc.Detach(); // 幂等 + Assert.Equal(removesAfterAttach + 1, api.RemoveCalls.Count); + } + + [Fact] + public void Dispose_DetachesIdempotently() + { + var (svc, api, _, _, _, _, _) = TestEnv.CreateService(); + svc.Attach(Hwnd, AppBarEdge.Right, Rail, Expanded); + svc.Dispose(); + Assert.False(svc.IsRegistered); + svc.Dispose(); + } +} diff --git a/tests/SmartSideBAR.Windows.Tests/GlobalUsings.cs b/tests/SmartSideBAR.Windows.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj b/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj new file mode 100644 index 0000000..c6fa51b --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj @@ -0,0 +1,20 @@ + + + + + net10.0-windows10.0.19041.0 + false + + + + + + + + + + + + + + diff --git a/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs b/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs new file mode 100644 index 0000000..96eeeb4 --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs @@ -0,0 +1,131 @@ +// Windows.Tests 假件 —— 记录 SHAppBarMessage 调用序列, 供状态机断言 (方案 §7.1) +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Windows.Tests.TestDoubles; + +/// 假 AppBar API: 记录操作序列, 模拟系统授予矩形。 +public sealed class FakeAppBarApi : IAppBarApi +{ + /// 全部操作的时间线: NEW / REMOVE / QUERYPOS / SETPOS / ACTIVATE / WINDOWPOSCHANGED / MOVE + public List Ops { get; } = []; + + public List<(nint Hwnd, uint CallbackMsg)> RegisterCalls { get; } = []; + public List RemoveCalls { get; } = []; + public List<(nint Hwnd, uint Edge, AppBarRect Want)> QueryCalls { get; } = []; + public List ActivateCalls { get; } = []; + public List<(nint Hwnd, AppBarRect Rect)> MoveRequests { get; } = []; + public int WindowPosChangedCalls { get; private set; } + + public bool RegisterResult { get; set; } = true; + + /// 模拟的显示器几何: 1920×1080, 底部 40px 任务栏 → 工作区 1920×1040 + public AppBarRect Monitor { get; set; } = new(0, 0, 1920, 1080); + public AppBarRect Work { get; set; } = new(0, 0, 1920, 1040); + + public bool Register(nint hwnd, uint callbackMsg) + { + Ops.Add("NEW"); + RegisterCalls.Add((hwnd, callbackMsg)); + return RegisterResult; + } + + public void Remove(nint hwnd) + { + Ops.Add("REMOVE"); + RemoveCalls.Add(hwnd); + } + + public AppBarRect QueryAndSetPos(nint hwnd, uint edge, AppBarRect want) + { + Ops.Add("QUERYPOS"); + QueryCalls.Add((hwnd, edge, want)); + Ops.Add("SETPOS"); + // 系统行为模拟: 贴边锚定到显示器缘, 沿边尺寸保留提议 (胶囊矩形在工作区内, 无需裁剪) + return edge switch + { + AppBarNative.ABE_RIGHT => new AppBarRect(Monitor.W - want.W, want.Y, want.W, want.H), + AppBarNative.ABE_LEFT => new AppBarRect(Work.X, want.Y, want.W, want.H), + AppBarNative.ABE_TOP => new AppBarRect(Work.X, Work.Y, Monitor.W, want.H), + _ => new AppBarRect(Work.X, Work.Y + Work.H - want.H, Monitor.W, want.H), + }; + } + + public void Activate(nint hwnd) { Ops.Add("ACTIVATE"); ActivateCalls.Add(hwnd); } + + public void WindowPosChanged(nint hwnd) { Ops.Add("WINDOWPOSCHANGED"); WindowPosChangedCalls++; } + + public bool MoveWindow(nint hwnd, AppBarRect rect) + { + Ops.Add("MOVE"); + MoveRequests.Add((hwnd, rect)); + return true; + } + + public TaskbarInfo? GetTaskbarPos() => new(AppBarNative.ABE_BOTTOM, new AppBarRect(0, 1040, 1920, 40)); + + public MonitorRects GetMonitorOf(nint hwnd) => new(Monitor, Work); +} + +/// 假 WndProc 钩子: 捕获回调, 测试可直接注入系统消息。 +public sealed class FakeWndProcHook : IWndProcHook +{ + private readonly Dictionary> _callbacks = []; + + public bool HasHook(nint hwnd) => _callbacks.ContainsKey(hwnd); + + public void Add(nint hwnd, WndProcCallback callback) + { + if (!_callbacks.TryGetValue(hwnd, out var list)) + { + list = []; + _callbacks[hwnd] = list; + } + list.Add(callback); + } + + public void Remove(nint hwnd, WndProcCallback callback) + { + if (_callbacks.TryGetValue(hwnd, out var list)) + { + list.Remove(callback); + if (list.Count == 0) _callbacks.Remove(hwnd); + } + } + + /// 向 hwnd 投递一条消息 (模拟系统 ABN_* / WM_* 通知)。 + public (nint Result, bool Handled) Simulate(nint hwnd, uint msg, nuint wParam, nint lParam) + { + if (_callbacks.TryGetValue(hwnd, out var list)) + { + foreach (var cb in list.ToArray()) + { + var handled = false; + var result = cb(hwnd, msg, wParam, lParam, ref handled); + if (handled) return (result, true); + } + } + return (0, false); + } +} + +public static class TestEnv +{ + public static (AppBarService Svc, FakeAppBarApi Api, FakeWndProcHook Hook, EventBus Bus, + List Geometry, List Fullscreen, + List Arrange) CreateService() + { + var api = new FakeAppBarApi(); + var hook = new FakeWndProcHook(); + var bus = new EventBus(); + var geometry = new List(); + var fullscreen = new List(); + var arrange = new List(); + bus.Subscribe(geometry.Add); + bus.Subscribe(fullscreen.Add); + bus.Subscribe(arrange.Add); + var svc = new AppBarService(api, hook, bus); + return (svc, api, hook, bus, geometry, fullscreen, arrange); + } +} diff --git a/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs new file mode 100644 index 0000000..26a576f --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs @@ -0,0 +1,45 @@ +// Wave C 修订: USB 差异扫描纯函数测试 (直接读取系统事件 + 驱动器快照 diff) +using SmartSideBAR.Windows.Devices; +using Xunit; + +namespace SmartSideBAR.Windows.Tests; + +public sealed class UsbDeviceWatcherTests +{ + private static UsbDriveInfo D(string letter, string label = "U盘") => + new($"{letter}:\\", label, 32.0, true); + + [Fact] + public void DiffDrives_ArrivalAndRemoval() + { + var before = new Dictionary { ["E:\\"] = D("E"), ["F:\\"] = D("F") }; + var after = new Dictionary { ["F:\\"] = D("F"), ["G:\\"] = D("G") }; + + var (added, removed) = UsbDeviceWatcher.DiffDrives(before, after); + + Assert.Single(added); + Assert.Equal("G:\\", added[0].Drive); + Assert.Single(removed); + Assert.Equal("E:\\", removed[0].Drive); + } + + [Fact] + public void DiffDrives_NoChange_NoEvents() + { + var drives = new Dictionary { ["E:\\"] = D("E") }; + var (added, removed) = UsbDeviceWatcher.DiffDrives(drives, drives); + Assert.Empty(added); + Assert.Empty(removed); + } + + [Fact] + public void DiffDrives_LabelRefresh_IsNotAnEvent() + { + // 同盘符仅卷标变化不算插拔 + var before = new Dictionary { ["E:\\"] = D("E", "旧卷标") }; + var after = new Dictionary { ["E:\\"] = D("E", "新卷标") }; + var (added, removed) = UsbDeviceWatcher.DiffDrives(before, after); + Assert.Empty(added); + Assert.Empty(removed); + } +}