From 292350c3eeefeed69e377ec8a94d9dac2ece800f Mon Sep 17 00:00:00 2001 From: missile <3397241346@qq.com> Date: Sun, 6 Sep 2026 18:41:57 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(wave-a):=20C#/Avalonia=20=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=20+=20AppBar=20=E4=B8=80=E7=AD=89=E5=85=AC=E6=B0=91?= =?UTF-8?q?=E5=8C=96=20(=C2=A75=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0)=20?= =?UTF-8?q?+=2054=20=E9=A1=B9=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决方案 (SmartSideBAR.slnx, .NET 10 / Avalonia 11.3.20, 方案 §4.1): - SmartSideBAR.Core (net10.0, 平台无关): SidekickConfig 逐字段同构 types.ts (附录 B 互认) · ConfigService 三层合并+迁移+键白名单(B3 根修)+未知字段保留 · EventBus(IPC 事件替代) · SchedulerService(TimeProvider, C1 变更检测落盘, §6.10 过期周期提醒启动不补触发) · LinksService(E1 全量回写) · PolicyService · FloatBallLayout(floatball-layout.ts 逐函数移植) - SmartSideBAR.Windows (net10.0-windows): AppBarNative(LibraryImport) · AppBarApi(QUERYPOS 语义对齐 appbar.cc) · AppBarService 通知状态机 · WndProcHook(SetWindowLongPtr 子类化, Wave B 热键/USB 复用) - SmartSideBAR.Avalonia: Program.cs DI 装配(§4.2) · WindowManager · SidebarWindow Demo rail · SSB_SMOKE_EXIT_MS 冒烟自动退出 - tests: Core 40 + Windows 14 = 54 用例全绿 (FakeAppBarApi 状态机序列断言) AppBar 缺口 K1-K5/K7 闭环 (§5.1): ABN 通知 WndProc 闭环(K1) · ABM_ACTIVATE/ WINDOWPOSCHANGED 上报(K2) · 全屏降级与恢复(K3/V5) · 物理像素单轨(K4: PhysicalDpiScope 强制 PMv2 线程上下文 + MoveWindow 直达) · node-gyp 退役(K5) · DISPLAYCHANGE/DPICHANGED 重排(K7) · 孤儿注册自愈(V6) · 退出 WorkArea 恢复(V8) 对方案勘误 (docs/mlp-wave-a-report.md §2.3): 1) Reposition 锚点 rcWork→rcMonitor: rcWork 已含自身占位, 注册期 ABN 风暴中 占位带逐次内漂一个带宽 (实测工作区被蚕食至 43px); 沿边裁剪交 QUERYPOS 2) Avalonia AddWndProcCallback 非公开 API → SetWindowLongPtr 子类化等效实现 真机冒烟 (2560x1600 @150%): ABM_NEW=True · 侧栏贴右缘 64px · 工作区稳定扣除 · 退出后 WorkArea 完全恢复 · 旧目录配置自动迁移(.migrated.bak) CI 同步: dotnet job 指向 slnx; testing.md 追加 V1-V8 验收清单 --- .github/workflows/ci.yml | 8 +- Directory.Build.props | 11 + SmartSideBAR.slnx | 11 + docs/mlp-wave-a-report.md | 103 ++++++++ docs/testing.md | 19 ++ src/SmartSideBAR.Avalonia/App.axaml | 8 + src/SmartSideBAR.Avalonia/App.axaml.cs | 56 ++++ .../Logging/FileLoggerProvider.cs | 62 +++++ src/SmartSideBAR.Avalonia/Program.cs | 65 +++++ .../SmartSideBAR.Avalonia.csproj | 25 ++ .../ViewModels/RelayCommand.cs | 18 ++ .../ViewModels/SidebarViewModel.cs | 68 +++++ .../Views/SidebarWindow.axaml | 67 +++++ .../Views/SidebarWindow.axaml.cs | 44 ++++ src/SmartSideBAR.Avalonia/WindowManager.cs | 64 +++++ src/SmartSideBAR.Avalonia/app.manifest | 20 ++ .../Configuration/AppearanceConfig.cs | 65 +++++ .../Configuration/ConfigJsonContext.cs | 19 ++ .../Configuration/ConfigService.cs | 249 ++++++++++++++++++ .../Configuration/FloatBallConfig.cs | 44 ++++ .../Configuration/SidekickConfig.cs | 145 ++++++++++ .../FloatBall/FloatBallLayout.cs | 130 +++++++++ src/SmartSideBAR.Core/Links/LinksService.cs | 74 ++++++ src/SmartSideBAR.Core/Messaging/EventBus.cs | 65 +++++ src/SmartSideBAR.Core/Policy/PolicyService.cs | 30 +++ .../Scheduling/SchedulerService.cs | 209 +++++++++++++++ .../SmartSideBAR.Core.csproj | 13 + src/SmartSideBAR.Windows/AppBar/AppBarApi.cs | 153 +++++++++++ .../AppBar/AppBarNative.cs | 104 ++++++++ .../AppBar/AppBarService.cs | 159 +++++++++++ src/SmartSideBAR.Windows/AppBar/IAppBarApi.cs | 41 +++ .../Native/IWndProcHook.cs | 16 ++ .../Native/Win32Display.cs | 14 + .../Native/WndProcHook.cs | 122 +++++++++ .../SmartSideBAR.Windows.csproj | 20 ++ .../ConfigServiceTests.cs | 197 ++++++++++++++ .../SmartSideBAR.Core.Tests/EventBusTests.cs | 65 +++++ .../FloatBallLayoutTests.cs | 94 +++++++ tests/SmartSideBAR.Core.Tests/GlobalUsings.cs | 1 + .../LinksServiceTests.cs | 79 ++++++ .../SchedulerServiceTests.cs | 149 +++++++++++ .../SmartSideBAR.Core.Tests.csproj | 18 ++ .../TestDoubles/FakeTimeProvider.cs | 82 ++++++ .../AppBarServiceTests.cs | 213 +++++++++++++++ .../GlobalUsings.cs | 1 + .../SmartSideBAR.Windows.Tests.csproj | 20 ++ .../TestDoubles/FakeAppBarApi.cs | 131 +++++++++ 47 files changed, 3367 insertions(+), 4 deletions(-) create mode 100644 Directory.Build.props create mode 100644 SmartSideBAR.slnx create mode 100644 docs/mlp-wave-a-report.md create mode 100644 src/SmartSideBAR.Avalonia/App.axaml create mode 100644 src/SmartSideBAR.Avalonia/App.axaml.cs create mode 100644 src/SmartSideBAR.Avalonia/Logging/FileLoggerProvider.cs create mode 100644 src/SmartSideBAR.Avalonia/Program.cs create mode 100644 src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj create mode 100644 src/SmartSideBAR.Avalonia/ViewModels/RelayCommand.cs create mode 100644 src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs create mode 100644 src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml create mode 100644 src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs create mode 100644 src/SmartSideBAR.Avalonia/WindowManager.cs create mode 100644 src/SmartSideBAR.Avalonia/app.manifest create mode 100644 src/SmartSideBAR.Core/Configuration/AppearanceConfig.cs create mode 100644 src/SmartSideBAR.Core/Configuration/ConfigJsonContext.cs create mode 100644 src/SmartSideBAR.Core/Configuration/ConfigService.cs create mode 100644 src/SmartSideBAR.Core/Configuration/FloatBallConfig.cs create mode 100644 src/SmartSideBAR.Core/Configuration/SidekickConfig.cs create mode 100644 src/SmartSideBAR.Core/FloatBall/FloatBallLayout.cs create mode 100644 src/SmartSideBAR.Core/Links/LinksService.cs create mode 100644 src/SmartSideBAR.Core/Messaging/EventBus.cs create mode 100644 src/SmartSideBAR.Core/Policy/PolicyService.cs create mode 100644 src/SmartSideBAR.Core/Scheduling/SchedulerService.cs create mode 100644 src/SmartSideBAR.Core/SmartSideBAR.Core.csproj create mode 100644 src/SmartSideBAR.Windows/AppBar/AppBarApi.cs create mode 100644 src/SmartSideBAR.Windows/AppBar/AppBarNative.cs create mode 100644 src/SmartSideBAR.Windows/AppBar/AppBarService.cs create mode 100644 src/SmartSideBAR.Windows/AppBar/IAppBarApi.cs create mode 100644 src/SmartSideBAR.Windows/Native/IWndProcHook.cs create mode 100644 src/SmartSideBAR.Windows/Native/Win32Display.cs create mode 100644 src/SmartSideBAR.Windows/Native/WndProcHook.cs create mode 100644 src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj create mode 100644 tests/SmartSideBAR.Core.Tests/ConfigServiceTests.cs create mode 100644 tests/SmartSideBAR.Core.Tests/EventBusTests.cs create mode 100644 tests/SmartSideBAR.Core.Tests/FloatBallLayoutTests.cs create mode 100644 tests/SmartSideBAR.Core.Tests/GlobalUsings.cs create mode 100644 tests/SmartSideBAR.Core.Tests/LinksServiceTests.cs create mode 100644 tests/SmartSideBAR.Core.Tests/SchedulerServiceTests.cs create mode 100644 tests/SmartSideBAR.Core.Tests/SmartSideBAR.Core.Tests.csproj create mode 100644 tests/SmartSideBAR.Core.Tests/TestDoubles/FakeTimeProvider.cs create mode 100644 tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs create mode 100644 tests/SmartSideBAR.Windows.Tests/GlobalUsings.cs create mode 100644 tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj create mode 100644 tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b677951..cab0ff8 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 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..4af85e4 --- /dev/null +++ b/docs/mlp-wave-a-report.md @@ -0,0 +1,103 @@ +# 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 对应节实现。 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/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..a2a388a --- /dev/null +++ b/src/SmartSideBAR.Avalonia/App.axaml.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using SmartSideBAR.Core.Scheduling; +using SmartSideBAR.Windows.AppBar; + +namespace SmartSideBAR.Avalonia; + +public partial class App : Application +{ + private readonly IServiceProvider? _sp; + + 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 windowManager = _sp.GetRequiredService(); + + desktop.MainWindow = windowManager.CreateSidebar(); + + // V8: 退出前注销 AppBar / 停调度, 系统 WorkArea 完全恢复 + desktop.ShutdownRequested += (_, _) => + { + appBar.Detach(); + scheduler.Stop(); + }; + + ConfigureSmokeExit(desktop); + } + base.OnFrameworkInitializationCompleted(); + } + + /// 冒烟验证: 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/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..dbd9928 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Program.cs @@ -0,0 +1,65 @@ +// 服务装配 (方案 §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.Native; + +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(); + + // 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 A) 启动 — 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..a8627d2 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj @@ -0,0 +1,25 @@ + + + + + WinExe + net10.0-windows + SmartSideBAR.Avalonia + true + app.manifest + + + + + + + + + + + + + + + + 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..da6725b --- /dev/null +++ b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs @@ -0,0 +1,68 @@ +// SidebarViewModel —— Wave A Demo: rail/面板切换驱动 AppBar 重排 (window:resize 通道落点雏形)。 +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Input; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Windows.AppBar; + +namespace SmartSideBAR.Avalonia.ViewModels; + +public sealed class SidebarViewModel : INotifyPropertyChanged +{ + private readonly AppBarService _appBar; + private bool _panelOpen; + + public SidebarViewModel(AppBarService appBar, IReadOnlyList links) + { + _appBar = appBar; + Links = [.. links.Where(l => l.Enabled)]; + TogglePanelCommand = new RelayCommand(_ => TogglePanel()); + OpenLinkCommand = new RelayCommand(p => + { + if (p is LinkItem link) OpenLink(link); + }); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public bool PanelOpen + { + get => _panelOpen; + private set + { + if (_panelOpen == value) return; + _panelOpen = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(PanelOpen))); + } + } + + public ObservableCollection Links { get; } + + public string AppBarState => _appBar.IsRegistered ? "AppBar 已注册" : "Topmost 回退"; + + public ICommand TogglePanelCommand { get; } + + public ICommand OpenLinkCommand { get; } + + /// 展开/收起: 窗口宽度变化经 AppBarService 与系统协商 (物理像素)。 + public void TogglePanel() + { + PanelOpen = !PanelOpen; + _appBar.SetExpanded(PanelOpen); + } + + public void OpenLink(LinkItem link) + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(link.Url) + { + UseShellExecute = true, + }); + } + catch + { + // Wave A Demo: 静默; ShellService 白名单 (§6.8) 于 Wave B 落地 + } + } +} diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml new file mode 100644 index 0000000..a51c3f1 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs new file mode 100644 index 0000000..0351277 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs @@ -0,0 +1,44 @@ +using Avalonia.Controls; +using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Native; + +namespace SmartSideBAR.Avalonia.Views; + +public partial class SidebarWindow : Window +{ + private readonly AppBarEdge _side; + private double _scaling = 1.0; + + // 供运行时 XAML 加载器可达 (AVLN3001); 应用路径一律走带 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); + // K4: rail 恒为 64 物理像素; DIP 列宽按窗口 DPI 折算 (物理/96) + _scaling = Win32Display.GetScaling(TryGetPlatformHandle()?.Handle ?? 0); + ApplyScaling(); + } + + private void ApplyScaling() + { + var railDip = WindowManager.RailWidthPx / _scaling; + RootGrid.ColumnDefinitions = _side == AppBarEdge.Right + ? new ColumnDefinitions($"{railDip:0.#},*") + : new ColumnDefinitions($"*,{railDip:0.#}"); + } +} diff --git a/src/SmartSideBAR.Avalonia/WindowManager.cs b/src/SmartSideBAR.Avalonia/WindowManager.cs new file mode 100644 index 0000000..7e9c28c --- /dev/null +++ b/src/SmartSideBAR.Avalonia/WindowManager.cs @@ -0,0 +1,64 @@ +// 窗口集中管理 (方案 §4.1): 侧栏 AppBar 轨窗口的创建与挂接。 +// 坐标 (K4): Avalonia 侧不自行定位 —— 窗口位置由 AppBarService 经 MoveWindow 以物理像素下达。 +using Avalonia.Controls; +using Microsoft.Extensions.Logging; +using SmartSideBAR.Avalonia.Views; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.Links; +using SmartSideBAR.Core.Messaging; +using SmartSideBAR.Windows.AppBar; + +namespace SmartSideBAR.Avalonia; + +public sealed class WindowManager( + ConfigService config, + LinksService links, + AppBarService appBar, + IEventBus bus, + ILogger log) +{ + /// rail 收起宽度 (物理像素) —— 方案 §5.3 默认 + public const int RailWidthPx = 64; + /// 面板展开宽度 (物理像素) + public const int ExpandedWidthPx = 420; + + public SidebarWindow CreateSidebar() + { + var cfg = config.Current; + var side = cfg.Display.SidebarSide == SidebarSide.Left ? AppBarEdge.Left : AppBarEdge.Right; + + var win = new SidebarWindow(side) + { + DataContext = new ViewModels.SidebarViewModel(appBar, links.List()), + }; + win.Opened += (_, _) => + { + var handle = win.TryGetPlatformHandle(); + if (handle is null) + { + log.LogWarning("[Window] 未能获取平台句柄, 侧栏以 Topmost 模式运行"); + win.Topmost = true; + return; + } + + // §5.4: AppBar 模式下不置顶 (系统保证不被最大化遮盖); 注册失败才回退 alwaysOnTop + if (appBar.Attach(handle.Handle, side, RailWidthPx, ExpandedWidthPx)) + { + win.Topmost = false; + } + else + { + win.Topmost = true; // v1.2.0 同款 fallback + log.LogWarning("[Window] AppBar 注册失败 → Topmost 回退"); + } + }; + win.Closed += (_, _) => appBar.Detach(); + + // 几何事件: Wave A 仅记录; Wave B 用于面板内容宽度同步 (不自算, 以系统授予为准) + bus.Subscribe(g => + 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}px)", side, RailWidthPx); + return win; + } +} 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/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/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/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..ec5022a --- /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 坐标为物理像素。 + private 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..b590df1 --- /dev/null +++ b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs @@ -0,0 +1,159 @@ +// 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 +{ + 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) + + public bool IsRegistered => _registered; + public AppBarRect? GrantedRect { get; private set; } + public bool FullscreenActive => _fullscreen; + public int CurrentWidthPx => _userExpanded ? _expandedPx : _railPx; + + /// 注册 + 首次定位。失败返回 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); + } + + /// 重排: 注册后 / 展开收起 / ABN_POSCHANGED / 分辨率与任务栏变化 (K1/K4)。 + /// 锚点必须是 rcMonitor 全屏矩形: rcWork 已扣除自身 AppBar 占位, 若以它为锚, + /// 每次 Reposition 都会向屏幕内侧漂移一个带宽 —— 对方案 §5.3 蓝本的勘误。 + /// 贴边方向的尺寸提议全屏高/宽, 沿边方向的裁剪 (任务栏避让) 由 ABM_QUERYPOS 完成。 + public void Reposition(int widthPx) + { + if (!_registered || _hwnd == 0) return; + var mon = api.GetMonitorOf(_hwnd); + var want = _edge switch + { + AppBarNative.ABE_LEFT => new AppBarRect(mon.Monitor.X, mon.Monitor.Y, widthPx, mon.Monitor.H), + AppBarNative.ABE_RIGHT => new AppBarRect(mon.Monitor.X + mon.Monitor.W - widthPx, mon.Monitor.Y, widthPx, mon.Monitor.H), + 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?.LogDebug("[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/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..ef3c675 --- /dev/null +++ b/src/SmartSideBAR.Windows/Native/Win32Display.cs @@ -0,0 +1,14 @@ +// 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; +} 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/SmartSideBAR.Windows.csproj b/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj new file mode 100644 index 0000000..f0bf36d --- /dev/null +++ b/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj @@ -0,0 +1,20 @@ + + + + + net10.0-windows + 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.Windows.Tests/AppBarServiceTests.cs b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs new file mode 100644 index 0000000..080e2f9 --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs @@ -0,0 +1,213 @@ +// 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) + var first = Assert.Single(api.MoveRequests); + Assert.Equal(Hwnd, first.Hwnd); + Assert.Equal(Rail, first.Rect.W); + Assert.Equal(1040, first.Rect.H); // 工作区全高 + 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(1080, q.Want.H); // 全屏高提议; 沿边裁剪由 QUERYPOS 完成 + } + + [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(1040, 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..33d8c39 --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj @@ -0,0 +1,20 @@ + + + + + net10.0-windows + 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..98955f2 --- /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, Work.Y, want.W, Work.H), + AppBarNative.ABE_LEFT => new AppBarRect(Work.X, Work.Y, want.W, Work.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); + } +} From b0ae19e140aecb0036966934b479c9133c80e8b6 Mon Sep 17 00:00:00 2001 From: missile <3397241346@qq.com> Date: Sun, 6 Sep 2026 19:15:21 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(wave-b):=20=E5=B8=B8=E9=A9=BB=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=97=8F=20=E2=80=94=20IME/USB/=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E6=9C=BA/=E7=83=AD=E9=94=AE/Shell/=E9=93=83=E5=A3=B0/=E6=89=98?= =?UTF-8?q?=E7=9B=98=20(=E6=96=B9=E6=A1=88=20=C2=A76.1/6.6/6.7/6.8/6.13/6.?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImeService (§6.1/ADR-M6): GetKeyboardLayout/WM_INPUTLANGCHANGEREQUEST 直调, 零 PS 子进程 (<50ms→<5ms); config.ime.slot 语义落地 (zh↔en 轮换+回退) - UsbDeviceWatcher (§6.6/ADR-M5): RegisterDeviceNotification(DBT_DEVTYP_VOLUME) + WM_DEVICECHANGE, WMI 事件+巡检+PS 降级三层补丁退役; 盘符/卷标/容量查询 - PrinterMonitor (§6.7): System.Management 轮询, E4 根修 — PrinterStateMapper 用 DetectedErrorState 枚举映射 (单测锁定), 不再 PrinterStatus===3 误判 - HotkeyService (§6.13/P2-2): RegisterHotKey 冲突检测=系统事实 + 替代建议算法; WM_HOTKEY 经 WndProcHook 同一基础设施路由 → EventBus - ShellService (§6.8/B2 根修): taskmgr/外链域名白名单(https+内置+config.links)/ HKCU Run 自启 - SoundService: winmm PlaySound 别名 + MCI 播 MP3 (音量/重复/间隔全消费), 替代 PowerShell SystemSounds 守护 - 托盘 (P2-7): 运行时生成图标 + 菜单(显示侧栏/设置/退出); 侧栏 rail 接线 IME/任务管理器; 配置热键注册(capture/annotate/longshot/floatball/ime) - TFM 升级 net10.0-windows10.0.19041.0 (WinRT 投影, 为 Wave C 录屏备); 测试 74/74 全绿 (Core 58 + Windows 16) --- src/SmartSideBAR.Avalonia/App.axaml.cs | 58 +++++++- src/SmartSideBAR.Avalonia/Program.cs | 13 +- .../SmartSideBAR.Avalonia.csproj | 2 +- .../ViewModels/SidebarViewModel.cs | 46 +++++-- .../Views/SidebarWindow.axaml | 88 +++++++----- .../Views/SidebarWindow.axaml.cs | 2 + src/SmartSideBAR.Avalonia/WindowManager.cs | 64 ++++++++- .../Devices/PrinterStateMapper.cs | 62 +++++++++ src/SmartSideBAR.Core/Input/HotkeyParser.cs | 102 ++++++++++++++ .../Audio/SoundService.cs | 71 ++++++++++ .../Devices/PrinterMonitor.cs | 94 +++++++++++++ .../Devices/UsbDeviceWatcher.cs | 120 ++++++++++++++++ .../Input/HotkeyService.cs | 130 ++++++++++++++++++ src/SmartSideBAR.Windows/Input/ImeService.cs | 86 ++++++++++++ .../Native/Win32InputDevice.cs | 90 ++++++++++++ .../Shell/ShellService.cs | 87 ++++++++++++ .../SmartSideBAR.Windows.csproj | 3 +- .../WaveBLogicTests.cs | 78 +++++++++++ .../SmartSideBAR.Windows.Tests.csproj | 2 +- .../UsbDeviceWatcherTests.cs | 23 ++++ 20 files changed, 1165 insertions(+), 56 deletions(-) create mode 100644 src/SmartSideBAR.Core/Devices/PrinterStateMapper.cs create mode 100644 src/SmartSideBAR.Core/Input/HotkeyParser.cs create mode 100644 src/SmartSideBAR.Windows/Audio/SoundService.cs create mode 100644 src/SmartSideBAR.Windows/Devices/PrinterMonitor.cs create mode 100644 src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs create mode 100644 src/SmartSideBAR.Windows/Input/HotkeyService.cs create mode 100644 src/SmartSideBAR.Windows/Input/ImeService.cs create mode 100644 src/SmartSideBAR.Windows/Native/Win32InputDevice.cs create mode 100644 src/SmartSideBAR.Windows/Shell/ShellService.cs create mode 100644 tests/SmartSideBAR.Core.Tests/WaveBLogicTests.cs create mode 100644 tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs diff --git a/src/SmartSideBAR.Avalonia/App.axaml.cs b/src/SmartSideBAR.Avalonia/App.axaml.cs index a2a388a..a995c85 100644 --- a/src/SmartSideBAR.Avalonia/App.axaml.cs +++ b/src/SmartSideBAR.Avalonia/App.axaml.cs @@ -1,16 +1,23 @@ 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; @@ -25,11 +32,23 @@ public override void OnFrameworkInitializationCompleted() { var scheduler = _sp.GetRequiredService(); var appBar = _sp.GetRequiredService(); + var sounds = _sp.GetRequiredService(); + var bus = _sp.GetRequiredService(); var windowManager = _sp.GetRequiredService(); desktop.MainWindow = windowManager.CreateSidebar(); - // V8: 退出前注销 AppBar / 停调度, 系统 WorkArea 完全恢复 + // 提醒: 铃声 (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(); @@ -41,6 +60,43 @@ public override void OnFrameworkInitializationCompleted() 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_SMOKE_EXIT_MS>0 时 N 毫秒后自动退出 (自动化/CI 用)。 private void ConfigureSmokeExit(IClassicDesktopStyleApplicationLifetime desktop) { diff --git a/src/SmartSideBAR.Avalonia/Program.cs b/src/SmartSideBAR.Avalonia/Program.cs index dbd9928..351713f 100644 --- a/src/SmartSideBAR.Avalonia/Program.cs +++ b/src/SmartSideBAR.Avalonia/Program.cs @@ -10,7 +10,11 @@ using SmartSideBAR.Core.Policy; using SmartSideBAR.Core.Scheduling; using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Audio; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; using SmartSideBAR.Windows.Native; +using SmartSideBAR.Windows.Shell; namespace SmartSideBAR.Avalonia; @@ -34,6 +38,13 @@ public static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // UI 层 services.AddSingleton(); @@ -52,7 +63,7 @@ public static int Main(string[] args) config.Load(); var log = sp.GetRequiredService().CreateLogger("Bootstrap"); log.LogInformation( - "SmartSideBAR (Avalonia Wave A) 启动 — config v{Version}, 侧栏 {Side} 侧, 链接 {Links} 条, 提醒 {Reminders} 条", + "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); diff --git a/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj index a8627d2..596a268 100644 --- a/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj +++ b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj @@ -3,7 +3,7 @@ WinExe - net10.0-windows + net10.0-windows10.0.19041.0 SmartSideBAR.Avalonia true app.manifest diff --git a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs index da6725b..c195f77 100644 --- a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs @@ -1,26 +1,39 @@ -// SidebarViewModel —— Wave A Demo: rail/面板切换驱动 AppBar 重排 (window:resize 通道落点雏形)。 +// 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, IReadOnlyList links) + public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService shell, IReadOnlyList links) { _appBar = appBar; + _ime = ime; + _shell = shell; 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()); } public event PropertyChangedEventHandler? PropertyChanged; @@ -36,13 +49,31 @@ private set } } + 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 ICommand TogglePanelCommand { get; } - public ICommand OpenLinkCommand { get; } + public ICommand ToggleImeCommand { get; } + public ICommand OpenTaskMgrCommand { get; } + + public void RefreshIme() + { + var s = _ime.GetState(); + ImeState = s.IsChinese ? "中" : "EN"; + } /// 展开/收起: 窗口宽度变化经 AppBarService 与系统协商 (物理像素)。 public void TogglePanel() @@ -55,14 +86,11 @@ public void OpenLink(LinkItem link) { try { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(link.Url) - { - UseShellExecute = true, - }); + _shell.OpenExternal(new Uri(link.Url)); } - catch + catch (Exception) { - // Wave A Demo: 静默; ShellService 白名单 (§6.8) 于 Wave B 落地 + // 白名单外域名拒绝 (B2); UI 提示于 Wave C 通知系统 } } } diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml index a51c3f1..f0d861e 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml @@ -8,8 +8,8 @@ ShowInTaskbar="False" CanResize="False" ShowActivated="False" - Background="#F0141A22" - TransparencyLevelHint="None" + Background="Transparent" + TransparencyLevelHint="AcrylicBlur" Width="64" Height="1040"> @@ -20,48 +20,62 @@ + + - + + + + - - - - - - - + + + + + + + + + + + + diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs index 0351277..1937d98 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using SmartSideBAR.Avalonia.ViewModels; using SmartSideBAR.Windows.AppBar; using SmartSideBAR.Windows.Native; @@ -32,6 +33,7 @@ protected override void OnOpened(EventArgs e) // K4: rail 恒为 64 物理像素; DIP 列宽按窗口 DPI 折算 (物理/96) _scaling = Win32Display.GetScaling(TryGetPlatformHandle()?.Handle ?? 0); ApplyScaling(); + if (DataContext is SidebarViewModel vm) vm.RefreshIme(); } private void ApplyScaling() diff --git a/src/SmartSideBAR.Avalonia/WindowManager.cs b/src/SmartSideBAR.Avalonia/WindowManager.cs index 7e9c28c..704abb1 100644 --- a/src/SmartSideBAR.Avalonia/WindowManager.cs +++ b/src/SmartSideBAR.Avalonia/WindowManager.cs @@ -1,4 +1,4 @@ -// 窗口集中管理 (方案 §4.1): 侧栏 AppBar 轨窗口的创建与挂接。 +// 窗口集中管理 (方案 §4.1): 侧栏 AppBar 轨窗口的创建、常驻服务挂接与动作路由。 // 坐标 (K4): Avalonia 侧不自行定位 —— 窗口位置由 AppBarService 经 MoveWindow 以物理像素下达。 using Avalonia.Controls; using Microsoft.Extensions.Logging; @@ -7,6 +7,9 @@ using SmartSideBAR.Core.Links; using SmartSideBAR.Core.Messaging; using SmartSideBAR.Windows.AppBar; +using SmartSideBAR.Windows.Devices; +using SmartSideBAR.Windows.Input; +using SmartSideBAR.Windows.Shell; namespace SmartSideBAR.Avalonia; @@ -14,6 +17,11 @@ public sealed class WindowManager( ConfigService config, LinksService links, AppBarService appBar, + HotkeyService hotkeys, + IImeService ime, + IUsbWatcher usb, + IPrinterMonitor printer, + IShellService shell, IEventBus bus, ILogger log) { @@ -22,6 +30,8 @@ public sealed class WindowManager( /// 面板展开宽度 (物理像素) public const int ExpandedWidthPx = 420; + private SidebarWindow? _sidebar; + public SidebarWindow CreateSidebar() { var cfg = config.Current; @@ -29,8 +39,9 @@ public SidebarWindow CreateSidebar() var win = new SidebarWindow(side) { - DataContext = new ViewModels.SidebarViewModel(appBar, links.List()), + DataContext = new ViewModels.SidebarViewModel(appBar, ime, shell, links.List()), }; + _sidebar = win; win.Opened += (_, _) => { var handle = win.TryGetPlatformHandle(); @@ -40,9 +51,10 @@ public SidebarWindow CreateSidebar() win.Topmost = true; return; } + var hwnd = handle.Handle; // §5.4: AppBar 模式下不置顶 (系统保证不被最大化遮盖); 注册失败才回退 alwaysOnTop - if (appBar.Attach(handle.Handle, side, RailWidthPx, ExpandedWidthPx)) + if (appBar.Attach(hwnd, side, RailWidthPx, ExpandedWidthPx)) { win.Topmost = false; } @@ -51,14 +63,56 @@ public SidebarWindow CreateSidebar() win.Topmost = true; // v1.2.0 同款 fallback log.LogWarning("[Window] AppBar 注册失败 → Topmost 回退"); } + + // ---- Wave B 常驻服务挂接 (全部经同一 WndProcHook 基础设施) ---- + hotkeys.Attach(hwnd); + RegisterConfiguredHotkeys(); + usb.Start(hwnd); + printer.Start(); }; win.Closed += (_, _) => appBar.Detach(); - // 几何事件: Wave A 仅记录; Wave B 用于面板内容宽度同步 (不自算, 以系统授予为准) + // 几何事件: 面板内容宽度同步以系统授予为准 bus.Subscribe(g => - log.LogDebug("[Window] AppBar 几何: ({X},{Y}) {W}x{H}px", g.Rect.X, g.Rect.Y, g.Rect.W, g.Rect.H)); + { + 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}px)", side, RailWidthPx); return win; } + + /// 配置热键注册: capture/annotate/longshot/floatball (冲突 → 日志+替代建议)。 + public void RegisterConfiguredHotkeys() + { + var cfg = config.Current; + hotkeys.Register("capture", cfg.Capture.Hotkey); + hotkeys.Register("annotate", cfg.Capture.AnnotateHotkey); + hotkeys.Register("longshot", cfg.Capture.LongshotHotkey); + hotkeys.Register("floatball", cfg.FloatBall.Hotkey); + hotkeys.Register("ime", "Ctrl+Shift+I"); + } + + public void ToggleSidebar() + { + if (_sidebar is { } win) + { + if (!win.IsVisible) + { + win.Show(); + win.WindowState = WindowState.Normal; + } + else + { + win.Hide(); + } + } + } + + /// Wave C: 设置窗口; Wave B 先占位。 + public void OpenSettings() + { + log.LogInformation("[Window] OpenSettings (Wave C 落地)"); + } } 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/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.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/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..36627a3 --- /dev/null +++ b/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs @@ -0,0 +1,120 @@ +// Windows/Devices/UsbDeviceWatcher.cs —— §6.6 (ADR-M5): WM_DEVICECHANGE 消息驱动, 零 PowerShell/零 WMI。 +// v1.2.0 的 WMI 事件+巡检+PS 降级三层补丁整体退役; 延迟 ≤1s (原 ≤2s 承诺)。 +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 nint _hwnd; + private nint _notificationHandle; + private WndProcCallback? _callback; + + public void Start(nint hostHwnd) + { + if (_hwnd != 0) return; + _hwnd = hostHwnd; + var volume = new Win32Input.DEV_BROADCAST_VOLUME + { + dbcv_size = (uint)System.Runtime.InteropServices.Marshal.SizeOf(), + dbcv_devicetype = Win32Input.DBT_DEVTYP_VOLUME, + }; + _notificationHandle = Win32Input.RegisterDeviceNotification(hostHwnd, ref volume, Win32Input.DEVICE_NOTIFY_WINDOW_HANDLE); + _callback = OnDeviceChange; + hook.Add(hostHwnd, _callback); + log?.LogInformation("[USB] 已监听 WM_DEVICECHANGE (notification={Ok})", _notificationHandle != 0); + } + + public void Stop() + { + if (_hwnd == 0) return; + if (_callback is not null) hook.Remove(_hwnd, _callback); + if (_notificationHandle != 0) _ = Win32Input.UnregisterDeviceNotification(_notificationHandle); + _hwnd = 0; + _notificationHandle = 0; + log?.LogInformation("[USB] 已停止监听"); + } + + public IReadOnlyList ListRemovable() => + [.. DriveInfo.GetDrives().Where(IsTargetDrive).Select(ToInfo)]; + + private nint OnDeviceChange(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled) + { + if (msg != Win32Input.WM_DEVICECHANGE) return 0; + if (wParam is not (Win32Input.DBT_DEVICEARRIVAL or Win32Input.DBT_DEVICEREMOVECOMPLETE)) return 0; + if (lParam == 0) return 0; + + var volume = System.Runtime.InteropServices.Marshal.PtrToStructure(lParam); + if (volume.dbcv_devicetype != Win32Input.DBT_DEVTYP_VOLUME) return 0; + + var letter = UnitMaskToLetter(volume.dbcv_unitmask); + if (letter is null) return 0; + if (!IsAllowedDrive(letter)) { log?.LogDebug("[USB] 忽略非目标盘 {Drive}:", letter); return 0; } + + var info = ToInfo(new DriveInfo(letter)); + handled = true; + if (wParam == Win32Input.DBT_DEVICEARRIVAL) + { + bus.Publish(new UsbArrived(info)); + log?.LogInformation("[USB] 接入 {Drive} ({Label})", info.Drive, info.Label); + } + else + { + bus.Publish(new UsbRemoved(info)); + log?.LogInformation("[USB] 移除 {Drive}", info.Drive); + } + return 0; + } + + /// config.usb.ignoreTypes 语义保留: 过滤由调用方/策略完成; 此处仅留扩展点。 + private bool IsAllowedDrive(string letter) => config.Current.Usb.Enabled; + + private static bool IsTargetDrive(DriveInfo d) => + d.DriveType == DriveType.Removable || d.DriveType == DriveType.CDRom; + + 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); + } + + internal static string? UnitMaskToLetter(uint unitMask) + { + for (var i = 0; i < 26; i++) + { + if ((unitMask & (1u << i)) != 0) return $"{(char)('A' + i)}:\\"; + } + return null; + } + + 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/Win32InputDevice.cs b/src/SmartSideBAR.Windows/Native/Win32InputDevice.cs new file mode 100644 index 0000000..301fe30 --- /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)] + 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", 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/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 index f0bf36d..c581072 100644 --- a/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj +++ b/src/SmartSideBAR.Windows/SmartSideBAR.Windows.csproj @@ -3,13 +3,14 @@ - net10.0-windows + net10.0-windows10.0.19041.0 SmartSideBAR.Windows true + 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/SmartSideBAR.Windows.Tests.csproj b/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj index 33d8c39..c6fa51b 100644 --- a/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj +++ b/tests/SmartSideBAR.Windows.Tests/SmartSideBAR.Windows.Tests.csproj @@ -2,7 +2,7 @@ - net10.0-windows + net10.0-windows10.0.19041.0 false diff --git a/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs new file mode 100644 index 0000000..79bf06a --- /dev/null +++ b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs @@ -0,0 +1,23 @@ +// Wave B Windows 层测试: USB 盘符解析 (零 WMI 路径的纯函数) +using SmartSideBAR.Windows.Devices; +using Xunit; + +namespace SmartSideBAR.Windows.Tests; + +public sealed class UsbDeviceWatcherTests +{ + [Fact] + public void UnitMaskToLetter_SingleBit() + { + Assert.Equal("E:\\", UsbDeviceWatcher.UnitMaskToLetter(1u << 4)); + Assert.Equal("A:\\", UsbDeviceWatcher.UnitMaskToLetter(1u)); + Assert.Equal("Z:\\", UsbDeviceWatcher.UnitMaskToLetter(1u << 25)); + } + + [Fact] + public void UnitMaskToLetter_FirstBitWins() + { + Assert.Equal("D:\\", UsbDeviceWatcher.UnitMaskToLetter((1u << 3) | (1u << 5))); + Assert.Null(UsbDeviceWatcher.UnitMaskToLetter(0)); + } +} From 8daa938899618ea8a1243551bc2cdbc9f85b16f6 Mon Sep 17 00:00:00 2001 From: missile <3397241346@qq.com> Date: Sun, 6 Sep 2026 19:47:02 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(wave-c):=20=E6=8D=95=E8=8E=B7=E6=97=8F?= =?UTF-8?q?=20+=20UX=20=E2=80=94=20=E6=88=AA=E5=9B=BE/=E6=89=B9=E6=B3=A8/?= =?UTF-8?q?=E9=95=BF=E6=88=AA=E5=9B=BE/=E5=BD=95=E5=B1=8F(MP4)/=E6=82=AC?= =?UTF-8?q?=E6=B5=AE=E7=90=83/=E8=AE=BE=E7=BD=AE/OOBE/=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=20(=E6=96=B9=E6=A1=88=20=C2=A76.2-6.5/6.11/6.12/6.14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GdiCapture (§6.2): BitBlt 物理像素直接裁剪 —— A4 类偏移结构性不可能; PNG/JPG 落盘 - OverlayWindow: 全屏透明置顶拖拽选框, Pointer 统一鼠标/触屏 (D1 语义); DIP↔物理仅换算一次 (K4) - AnnotateWindow (§6.3/D1/D3/P1-8): InkCanvas 统一 Pointer 事件流; 撤销/清空; 导出双模式 仅笔迹(透明 PNG)/含背景合成; 工具栏 颜色×5/粗细×3 - LongshotService (§6.4/A5/C3): EnumWindows(剔除 cloaked)+DWM 扩展框物理矩形 + PageDown 滚动 + 行哈希线性拼接 (LongshotStitcher 纯函数, O(n) 根修 O(n²)); 倒计时/进度/完成 全事件化 - GraphicsCaptureRecorder (§6.5/ADR-M4): GraphicsCaptureItem(IGraphicsCaptureItemInterop COM 路径, 19041 投影无 CreateForMonitorAsync) → Direct3D11CaptureFramePool 零拷贝 → MediaStreamSource → MediaTranscoder 系统 H.264 → MP4 直出 (希沃白板/微信可直读); fps/bitrate 消费 config.recorder; 停止交付 null 样本使 MP4 moov 完整 - FloatBallWindow (§6.14): 透明置顶球 + 拖拽 + 扇形菜单 (fanRadius 布局投影), 8 动作接动作路由 - SettingsWindow (§6.11/P1-6): 常规/截图/录屏/设备/热键/诊断 六区; 保存经 ConfigService 白名单路径并即时生效 (热键重注册) - OobeWindow (§6.11/P1-12/E2/P2-3): 6 步向导 + 环境真实探测 + OobeState 字段级合并 - DiagnosticsService (§6.12): 屏幕/触控/IME/USB/打印机/AppBar/录屏/目录可写 真实探测 + 诊断包导出 (report.json + logs.zip) - ToastService: 进程内右下角通知; 设备事件/录屏状态/保存结果全接入 - WindowManager 动作总路由: rail 按钮/悬浮球/热键/托盘四入口共用; 白名单模块跳过热键注册 (P2-1) - 冒烟: AppBar 稳定预留 + 5 热键注册零冲突 + 打印机轮询 2 台实检 - 构建 0 警告 0 错误, 测试 74/74 全绿 --- src/SmartSideBAR.Avalonia/Program.cs | 6 + .../Ui/AnnotateWindow.cs | 245 ++++++++++++++ .../Ui/FloatBallWindow.cs | 188 +++++++++++ src/SmartSideBAR.Avalonia/Ui/OobeWindow.cs | 169 ++++++++++ src/SmartSideBAR.Avalonia/Ui/OverlayWindow.cs | 136 ++++++++ .../Ui/SettingsWindow.cs | 152 +++++++++ src/SmartSideBAR.Avalonia/Ui/ToastService.cs | 77 +++++ .../Ui/WindowPickerWindow.cs | 49 +++ .../ViewModels/SidebarViewModel.cs | 12 +- .../Views/SidebarWindow.axaml | 8 +- src/SmartSideBAR.Avalonia/WindowManager.cs | 236 +++++++++++++- .../Capture/LongshotStitcher.cs | 44 +++ src/SmartSideBAR.Windows/AppBar/AppBarApi.cs | 2 +- .../Capture/GdiCapture.cs | 103 ++++++ .../Capture/LongshotService.cs | 258 +++++++++++++++ .../Devices/UsbDeviceWatcher.cs | 6 + .../Native/Win32InputDevice.cs | 2 +- .../Recording/GraphicsCaptureRecorder.cs | 301 ++++++++++++++++++ .../Shell/DiagnosticsService.cs | 125 ++++++++ .../SmartSideBAR.Windows.csproj | 1 + 20 files changed, 2096 insertions(+), 24 deletions(-) create mode 100644 src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/OobeWindow.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/OverlayWindow.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/SettingsWindow.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/ToastService.cs create mode 100644 src/SmartSideBAR.Avalonia/Ui/WindowPickerWindow.cs create mode 100644 src/SmartSideBAR.Core/Capture/LongshotStitcher.cs create mode 100644 src/SmartSideBAR.Windows/Capture/GdiCapture.cs create mode 100644 src/SmartSideBAR.Windows/Capture/LongshotService.cs create mode 100644 src/SmartSideBAR.Windows/Recording/GraphicsCaptureRecorder.cs create mode 100644 src/SmartSideBAR.Windows/Shell/DiagnosticsService.cs diff --git a/src/SmartSideBAR.Avalonia/Program.cs b/src/SmartSideBAR.Avalonia/Program.cs index 351713f..07be05d 100644 --- a/src/SmartSideBAR.Avalonia/Program.cs +++ b/src/SmartSideBAR.Avalonia/Program.cs @@ -11,9 +11,11 @@ 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; @@ -45,6 +47,10 @@ public static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // UI 层 services.AddSingleton(); diff --git a/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs b/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs new file mode 100644 index 0000000..10a3998 --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/AnnotateWindow.cs @@ -0,0 +1,245 @@ +// 屏幕批注窗 (§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); + } + + /// 导出仅笔迹 (透明底)。 + public Bitmap ExportStrokesOnly(int width, int height) + { + var rtb = new RenderTargetBitmap(new PixelSize(width, height)); + using (var ctx = rtb.CreateDrawingContext()) + { + 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); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + var p = e.GetCurrentPoint(this); + _current = new Stroke { Color = _color, Width = _width }; + _current.Points.Add(p.Position); + e.Pointer.Capture(this); + e.Handled = true; // 全类型指针接受 —— D1 根修 + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (_current is null) return; + var p = e.GetCurrentPoint(this); + if (e is PointerEventArgs { } && p.Properties.IsLeftButtonPressed || e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen) + { + _current.Points.Add(p.Position); + InvalidateVisual(); + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (_current is null) return; + _strokes.Add(_current); + _current = null; + InvalidateVisual(); + StrokesChanged?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } +} + +/// 批注主窗: 全屏透明置顶; 导出 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(); + Content = new Panel + { + Children = { _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); + using var composite = new RenderTargetBitmap(new PixelSize(w, h)); + 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); + 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/FloatBallWindow.cs b/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs new file mode 100644 index 0000000..0bf6cde --- /dev/null +++ b/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs @@ -0,0 +1,188 @@ +// 悬浮球 (§6.14): 透明置顶小窗; 拖拽吸附 (FloatBallLayout 纯算法) + 点击展开扇形快捷菜单。 +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using SmartSideBAR.Core.Configuration; +using SmartSideBAR.Core.FloatBall; +using AloLayout = global::Avalonia.Layout; +using Math = global::System.Math; + +namespace SmartSideBAR.Avalonia.Ui; + +public sealed class FloatBallWindow : Window +{ + private readonly string[] _actions; + private readonly Action _dispatch; + private readonly int _size; + private bool _dragging; + private Point _pressPos; + private PixelPoint _pressWindowPos; + private bool _moved; + private readonly Panel _root = new(); + + public FloatBallWindow(FloatBallConfig cfg, RectLike workArea, Action dispatch) + { + _actions = [.. cfg.Actions]; + _dispatch = dispatch; + _size = cfg.Size; + Width = _size; + Height = _size; + SystemDecorations = SystemDecorations.None; + ShowInTaskbar = false; + Topmost = true; + Background = Brushes.Transparent; + TransparencyLevelHint = []; + ShowActivated = false; + + var ball = new Border + { + Width = _size, + Height = _size, + CornerRadius = new CornerRadius(_size / 2), + Background = new SolidColorBrush(Color.FromArgb(210, 24, 32, 44)), + BorderBrush = new SolidColorBrush(Color.Parse("#2B6EE0")), + BorderThickness = new Thickness(2), + Child = new TextBlock + { + Text = "S", + Foreground = Brushes.White, + FontSize = _size * 0.42, + FontWeight = FontWeight.Bold, + HorizontalAlignment = AloLayout.HorizontalAlignment.Center, + VerticalAlignment = AloLayout.VerticalAlignment.Center, + }, + }; + _root.Children.Add(ball); + Content = _root; + + // 默认位置: workArea 62% 高度、贴配置侧 + var pos = FloatBallLayout.DefaultBallPosition( + new RectLike(workArea.X, workArea.Y, workArea.Width, workArea.Height), _size, "right"); + Position = new PixelPoint(pos.X, pos.Y); + + PointerPressed += OnPress; + PointerMoved += OnMove; + PointerReleased += OnRelease; + } + + 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 = p.X - _pressPos.X; + var dy = p.Y - _pressPos.Y; + if (Math.Abs(dx) + Math.Abs(dy) > 6) _moved = true; + if (_moved) + { + Position = new PixelPoint(_pressWindowPos.X + (int)dx, _pressWindowPos.Y + (int)dy); + } + } + + private void OnRelease(object? s, PointerReleasedEventArgs e) + { + _dragging = false; + if (_moved) return; // 拖拽结束: 吸附可选 (Wave D 细化 SnapThreshold) + ToggleFan(); + } + + private bool _fanOpen; + + private void ToggleFan() + { + if (_fanOpen) + { + Width = _size; + Height = _size; + _fanOpen = false; + RebuildFan(collapsed: true); + return; + } + _fanOpen = true; + RebuildFan(collapsed: false); + } + + private void RebuildFan(bool collapsed) + { + _root.Children.Clear(); + if (collapsed) + { + Width = _size; + Height = _size; + var ball = new Border + { + Width = _size, + Height = _size, + CornerRadius = new CornerRadius(_size / 2), + Background = new SolidColorBrush(Color.FromArgb(210, 24, 32, 44)), + BorderBrush = new SolidColorBrush(Color.Parse("#2B6EE0")), + BorderThickness = new Thickness(2), + Child = new TextBlock + { + Text = "S", + Foreground = Brushes.White, + FontSize = _size * 0.42, + FontWeight = FontWeight.Bold, + HorizontalAlignment = AloLayout.HorizontalAlignment.Center, + VerticalAlignment = AloLayout.VerticalAlignment.Center, + }, + }; + _root.Children.Add(ball); + return; + } + + // 扇形展开: 简化为圆形环绕 (fanRadius 布局算法的 UI 投影) + var count = Math.Max(1, _actions.Length); + var radius = FloatBallLayout.FanRadius(count); + var panel = radius * 2 + 80; + Width = panel; + Height = panel; + Position = new PixelPoint(Position.X - (panel - _size) / 2, Position.Y - (panel - _size) / 2); + for (var i = 0; i < count; i++) + { + var action = _actions[i]; + var angle = Math.PI * 2 * i / count - Math.PI / 2; + var btn = new Button + { + Width = 52, + Height = 52, + CornerRadius = new CornerRadius(26), + Content = ActionLabel(action), + Background = new SolidColorBrush(Color.FromArgb(225, 24, 32, 44)), + Foreground = Brushes.White, + }; + btn.Click += (_, _) => + { + _dispatch(action); + ToggleFan(); + }; + Canvas.SetLeft(btn, panel / 2 + radius * Math.Cos(angle) - 26); + Canvas.SetTop(btn, panel / 2 + radius * Math.Sin(angle) - 26); + _root.Children.Add(btn); + } + } + + private static string ActionLabel(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/SidebarViewModel.cs b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs index c195f77..40def7f 100644 --- a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs @@ -17,11 +17,12 @@ public sealed class SidebarViewModel : INotifyPropertyChanged private bool _panelOpen; private string _imeState = "IME"; - public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService shell, IReadOnlyList links) + 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 => @@ -34,6 +35,10 @@ public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService she 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")); } public event PropertyChangedEventHandler? PropertyChanged; @@ -64,10 +69,15 @@ private set 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; } = new RelayCommand(_ => { }); + public ICommand AnnotateCommand { get; } = new RelayCommand(_ => { }); + public ICommand RecordCommand { get; } = new RelayCommand(_ => { }); + public ICommand LongshotCommand { get; } = new RelayCommand(_ => { }); public void RefreshIme() { diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml index f0d861e..e2ae1fd 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml @@ -46,13 +46,13 @@ - - + - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs index 1937d98..1b911e0 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs @@ -1,16 +1,13 @@ using Avalonia.Controls; -using SmartSideBAR.Avalonia.ViewModels; using SmartSideBAR.Windows.AppBar; -using SmartSideBAR.Windows.Native; namespace SmartSideBAR.Avalonia.Views; public partial class SidebarWindow : Window { private readonly AppBarEdge _side; - private double _scaling = 1.0; - // 供运行时 XAML 加载器可达 (AVLN3001); 应用路径一律走带 edge 的构造 + // 供运行时 XAML 加载器可达; 应用路径一律走带 edge 的构造 public SidebarWindow() : this(AppBarEdge.Right) { } public SidebarWindow(AppBarEdge side) @@ -30,17 +27,6 @@ public void OnAppBarGeometry(int grantedWidthPx) protected override void OnOpened(EventArgs e) { base.OnOpened(e); - // K4: rail 恒为 64 物理像素; DIP 列宽按窗口 DPI 折算 (物理/96) - _scaling = Win32Display.GetScaling(TryGetPlatformHandle()?.Handle ?? 0); - ApplyScaling(); - if (DataContext is SidebarViewModel vm) vm.RefreshIme(); - } - - private void ApplyScaling() - { - var railDip = WindowManager.RailWidthPx / _scaling; - RootGrid.ColumnDefinitions = _side == AppBarEdge.Right - ? new ColumnDefinitions($"{railDip:0.#},*") - : new ColumnDefinitions($"*,{railDip:0.#}"); + if (DataContext is ViewModels.SidebarViewModel vm) vm.RefreshIme(); } } diff --git a/src/SmartSideBAR.Avalonia/WindowManager.cs b/src/SmartSideBAR.Avalonia/WindowManager.cs index df1fae0..6c22d92 100644 --- a/src/SmartSideBAR.Avalonia/WindowManager.cs +++ b/src/SmartSideBAR.Avalonia/WindowManager.cs @@ -32,8 +32,9 @@ public sealed class WindowManager( IEventBus bus, ILogger log) { - public const int RailWidthPx = 64; // rail 收起宽度 (物理像素) - public const int ExpandedWidthPx = 420; // 面板展开宽度 (物理像素) + // v1.1 液态玻璃设计基准 (DIP): rail 72 + 面板 380; 物理像素 = DIP × 窗口 DPI 缩放 + public const double RailWidthDip = 72; + public const double PanelWidthDip = 380; private SidebarWindow? _sidebar; private FloatBallWindow? _floatBall; @@ -59,9 +60,12 @@ public SidebarWindow CreateSidebar() 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); // §5.4: AppBar 注册失败回退 alwaysOnTop (v1.2.0 同策略) - if (appBar.Attach(hwnd, side, RailWidthPx, ExpandedWidthPx)) + if (appBar.Attach(hwnd, side, railPx, expandedPx)) { win.Topmost = false; } @@ -115,7 +119,7 @@ public SidebarWindow CreateSidebar() 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}px)", side, RailWidthPx); + log.LogInformation("[Window] 侧栏窗口已创建 ({Side} 侧, rail={Rail} DIP, 胶囊高度=工作区 84%)", side, RailWidthDip); return win; } diff --git a/src/SmartSideBAR.Windows/AppBar/AppBarService.cs b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs index b590df1..ac1b84b 100644 --- a/src/SmartSideBAR.Windows/AppBar/AppBarService.cs +++ b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs @@ -20,6 +20,9 @@ public sealed class AppBarService( 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 收起宽度 (物理像素) @@ -72,15 +75,18 @@ public void SetExpanded(bool expanded) /// 重排: 注册后 / 展开收起 / ABN_POSCHANGED / 分辨率与任务栏变化 (K1/K4)。 /// 锚点必须是 rcMonitor 全屏矩形: rcWork 已扣除自身 AppBar 占位, 若以它为锚, /// 每次 Reposition 都会向屏幕内侧漂移一个带宽 —— 对方案 §5.3 蓝本的勘误。 - /// 贴边方向的尺寸提议全屏高/宽, 沿边方向的裁剪 (任务栏避让) 由 ABM_QUERYPOS 完成。 + /// 垂直范围 = 工作区高度 84% 的悬浮胶囊 (上下各留 8%, 对齐 v1.1 玻璃胶囊设计); + /// 水平锚定显示器边缘, 沿边裁剪交给 ABM_QUERYPOS。 public void Reposition(int widthPx) { if (!_registered || _hwnd == 0) return; var mon = api.GetMonitorOf(_hwnd); + 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, mon.Monitor.Y, widthPx, mon.Monitor.H), - AppBarNative.ABE_RIGHT => new AppBarRect(mon.Monitor.X + mon.Monitor.W - widthPx, mon.Monitor.Y, widthPx, mon.Monitor.H), + 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), }; diff --git a/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs b/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs index 1d08b0e..d9e5bbb 100644 --- a/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs +++ b/src/SmartSideBAR.Windows/Devices/UsbDeviceWatcher.cs @@ -1,5 +1,7 @@ -// Windows/Devices/UsbDeviceWatcher.cs —— §6.6 (ADR-M5): WM_DEVICECHANGE 消息驱动, 零 PowerShell/零 WMI。 -// v1.2.0 的 WMI 事件+巡检+PS 降级三层补丁整体退役; 延迟 ≤1s (原 ≤2s 承诺)。 +// 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; @@ -25,20 +27,24 @@ public sealed class UsbDeviceWatcher( 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 = []; - // 勘误 (方案 §6.6/ADR-M5): DBT_DEVTYP_VOLUME 过滤器不支持 RegisterDeviceNotification - // (该 API 仅接受 DEVICEINTERFACE/HANDLE, VOLUME 注册返回 err=13 ERROR_INVALID_DATA)。 - // 卷插拔 WM_DEVICECHANGE 本就广播给所有顶层窗口 —— 直接 WndProcHook 接收即为正解, - // 与 v1.2.0 行为一致, 零注册零过滤。 public void Start(nint hostHwnd) { if (_hwnd != 0) return; _hwnd = hostHwnd; + _lastDrives = Snapshot(); _callback = OnDeviceChange; hook.Add(hostHwnd, _callback); - log?.LogInformation("[USB] 已监听 WM_DEVICECHANGE (顶层窗口广播, 零过滤注册)"); + log?.LogInformation("[USB] 已监听 WM_DEVICECHANGE (系统广播 + 差异扫描, 基线 {Count} 个可移动盘)", _lastDrives.Count); } public void Stop() @@ -50,41 +56,75 @@ public void Stop() } public IReadOnlyList ListRemovable() => - [.. DriveInfo.GetDrives().Where(IsTargetDrive).Select(ToInfo)]; + Snapshot().Values.ToArray(); private nint OnDeviceChange(nint hwnd, uint msg, nuint wParam, nint lParam, ref bool handled) { - if (msg != Win32Input.WM_DEVICECHANGE) return 0; - if (wParam is not (Win32Input.DBT_DEVICEARRIVAL or Win32Input.DBT_DEVICEREMOVECOMPLETE)) return 0; - if (lParam == 0) return 0; - - var volume = System.Runtime.InteropServices.Marshal.PtrToStructure(lParam); - if (volume.dbcv_devicetype != Win32Input.DBT_DEVTYP_VOLUME) return 0; - - var letter = UnitMaskToLetter(volume.dbcv_unitmask); - if (letter is null) return 0; - if (!IsAllowedDrive(letter)) { log?.LogDebug("[USB] 忽略非目标盘 {Drive}:", letter); return 0; } - - var info = ToInfo(new DriveInfo(letter)); + if (msg != WM_DEVICECHANGE) return 0; handled = true; - if (wParam == Win32Input.DBT_DEVICEARRIVAL) + // 到达/移除/设备节点变化 三类都值得重扫; 卷挂载后文件系统可能未就绪, 延迟再扫一次 + 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})", info.Drive, info.Label); + log?.LogInformation("[USB] 接入 {Drive} ({Label}, {Gb}GB)", info.Drive, info.Label, info.SizeGb); } - else + foreach (var info in removed) { bus.Publish(new UsbRemoved(info)); log?.LogInformation("[USB] 移除 {Drive}", info.Drive); } - return 0; } - /// config.usb.ignoreTypes 语义保留: 过滤由调用方/策略完成; 此处仅留扩展点。 - private bool IsAllowedDrive(string letter) => config.Current.Usb.Enabled; + /// 驱动器快照差异 (纯函数, 单测锁定)。 + 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 static bool IsTargetDrive(DriveInfo d) => - d.DriveType == DriveType.Removable || d.DriveType == DriveType.CDRom; + 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) { @@ -102,14 +142,5 @@ private static UsbDriveInfo ToInfo(DriveInfo d) return new UsbDriveInfo(d.Name, label, gb, d.DriveType == DriveType.Removable); } - internal static string? UnitMaskToLetter(uint unitMask) - { - for (var i = 0; i < 26; i++) - { - if ((unitMask & (1u << i)) != 0) return $"{(char)('A' + i)}:\\"; - } - return null; - } - public void Dispose() => Stop(); } diff --git a/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs index 080e2f9..9b062a8 100644 --- a/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs +++ b/tests/SmartSideBAR.Windows.Tests/AppBarServiceTests.cs @@ -27,11 +27,12 @@ public void Attach_SelfHeals_Registers_AndPositionsRail() Assert.Contains("QUERYPOS", api.Ops); Assert.Contains("SETPOS", api.Ops); Assert.Contains("WINDOWPOSCHANGED", api.Ops); - // 首次以 rail 宽度落位 (物理像素, K4) + // 首次以 rail 宽度落位 (物理像素, K4) —— v1.1 悬浮胶囊: 上下各留 8% 工作区 var first = Assert.Single(api.MoveRequests); Assert.Equal(Hwnd, first.Hwnd); Assert.Equal(Rail, first.Rect.W); - Assert.Equal(1040, first.Rect.H); // 工作区全高 + 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 已广播 @@ -61,7 +62,7 @@ public void Attach_RightEdge_QueryRect_AnchorsToFullMonitor() // 勘误 (方案 §5.3): 锚点为 rcMonitor 全屏矩形 —— rcWork 会引发占位带逐次内漂 Assert.Equal(1920 - Rail, q.Want.X); Assert.Equal(Rail, q.Want.W); - Assert.Equal(1080, q.Want.H); // 全屏高提议; 沿边裁剪由 QUERYPOS 完成 + Assert.Equal(874, q.Want.H); // 胶囊高提议 (工作区 84%) } [Fact] @@ -81,7 +82,7 @@ public void Reposition_IsStable_UnderRepeatedPosChanged() { Assert.Equal(1920 - Rail, m.Rect.X); Assert.Equal(Rail, m.Rect.W); - Assert.Equal(1040, m.Rect.H); + Assert.Equal(874, m.Rect.H); // 反复重排不漂移、不蚕食 }); } diff --git a/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs b/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs index 98955f2..96eeeb4 100644 --- a/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs +++ b/tests/SmartSideBAR.Windows.Tests/TestDoubles/FakeAppBarApi.cs @@ -42,11 +42,11 @@ 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, Work.Y, want.W, Work.H), - AppBarNative.ABE_LEFT => new AppBarRect(Work.X, Work.Y, want.W, Work.H), + 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), }; diff --git a/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs index 79bf06a..26a576f 100644 --- a/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs +++ b/tests/SmartSideBAR.Windows.Tests/UsbDeviceWatcherTests.cs @@ -1,4 +1,4 @@ -// Wave B Windows 层测试: USB 盘符解析 (零 WMI 路径的纯函数) +// Wave C 修订: USB 差异扫描纯函数测试 (直接读取系统事件 + 驱动器快照 diff) using SmartSideBAR.Windows.Devices; using Xunit; @@ -6,18 +6,40 @@ 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 UnitMaskToLetter_SingleBit() + public void DiffDrives_NoChange_NoEvents() { - Assert.Equal("E:\\", UsbDeviceWatcher.UnitMaskToLetter(1u << 4)); - Assert.Equal("A:\\", UsbDeviceWatcher.UnitMaskToLetter(1u)); - Assert.Equal("Z:\\", UsbDeviceWatcher.UnitMaskToLetter(1u << 25)); + var drives = new Dictionary { ["E:\\"] = D("E") }; + var (added, removed) = UsbDeviceWatcher.DiffDrives(drives, drives); + Assert.Empty(added); + Assert.Empty(removed); } [Fact] - public void UnitMaskToLetter_FirstBitWins() + public void DiffDrives_LabelRefresh_IsNotAnEvent() { - Assert.Equal("D:\\", UsbDeviceWatcher.UnitMaskToLetter((1u << 3) | (1u << 5))); - Assert.Null(UsbDeviceWatcher.UnitMaskToLetter(0)); + // 同盘符仅卷标变化不算插拔 + 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); } } From 01b0d44e8e03c1b502fce13cc4c58ac3c0a85877 Mon Sep 17 00:00:00 2001 From: missile <3397241346@qq.com> Date: Sun, 6 Sep 2026 20:59:59 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(ui):=20dock=20=E6=94=B6=E8=B5=B7/?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E7=90=83=E9=87=8D=E5=81=9A=20+=201.1=20SVG?= =?UTF-8?q?=20=E5=9B=BE=E6=A0=87=E5=85=A8=E9=9D=A2=E6=9B=BF=E6=8D=A2=20(?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E7=AC=AC=E4=BA=8C=E8=BD=AE?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标 (参照 SmartSideBAR-1.1): - 拷贝 1.1 全套 14 枚线条 SVG (24 视框), 生成 深色(rail)/白色(悬浮球扇形) 两套 - 引入 Avalonia.Svg.Skia 11.3.0; Tag="svg:名" 约定 + LoadSvgIcons 统一加载 (SvgImageExtension 在 11.3.0 有缺陷不可用, SvgSource(new Uri) 代码加载) - rail 图标全部替换 emoji → 1.1 SVG; floatball-icon.png 图元启用 dock 收起 (v1.1 dockMain 语义): - ↓ 收起 = 工作区底角 52×52 玻璃小方块 (sidebar.svg + ↑ 角标), ↑ 点击恢复 - 勘误: 透明窗口经外部 MoveWindow 缩放后 Avalonia 不再出帧 (真机复现: PrintWindow/抓屏全黑, 未缩放的悬浮球/胶囊正常) —— dock 改用独立 DockWindow, 侧栏 Hide(); AppBar 先 Detach (WorkArea 完整归还, v1.1 同语义), undock 重新 Attach (含孤儿注册自愈); dock 期间 QueryAndSetPos 会被自身旧占位顶位 —— 先注销自身再协商 - rail 52 DIP (v1.1), 面板 380 DIP; dock 位置 side-aware (左/右缘) 悬浮球重做 (v1.1 语义): - floatball-icon.png 玻璃球图元; 拖拽 (阈值 6px) + 释放吸附 (SnapToEdges); 单击展开玻璃扇形 (白色 SVG 图标, FanItemOffset 弧形排布, 方向远离最近角); 双击开合侧栏; 动作点击 → Dispatch 总路由 测试钩子: SSB_AUTO_DOCK_MS / SSB_AUTO_UNDOCK_MS (dock 形态自动化回归) 真机回归: dock 方块 (2482,1450) 78px 精准落位; dock→undock 全链路 (注销→方块→重注册→胶囊复原) 通过; 构建 0 警告 0 错误, 测试 75/75 全绿 --- src/SmartSideBAR.Avalonia/App.axaml.cs | 28 +++ .../Assets/floatball-icon.png | Bin 0 -> 38951 bytes .../Assets/icons-white/annotate.svg | 3 + .../Assets/icons-white/bell.svg | 3 + .../Assets/icons-white/capture.svg | 3 + .../Assets/icons-white/ime.svg | 3 + .../Assets/icons-white/link.svg | 3 + .../Assets/icons-white/lock.svg | 3 + .../Assets/icons-white/longshot.svg | 3 + .../Assets/icons-white/printer.svg | 3 + .../Assets/icons-white/record.svg | 3 + .../Assets/icons-white/reminder.svg | 3 + .../Assets/icons-white/settings.svg | 3 + .../Assets/icons-white/sidebar.svg | 3 + .../Assets/icons-white/taskmgr.svg | 3 + .../Assets/icons-white/usb.svg | 3 + .../Assets/icons/annotate.svg | 3 + .../Assets/icons/bell.svg | 3 + .../Assets/icons/capture.svg | 3 + .../Assets/icons/ime.svg | 3 + .../Assets/icons/link.svg | 3 + .../Assets/icons/lock.svg | 3 + .../Assets/icons/longshot.svg | 3 + .../Assets/icons/printer.svg | 3 + .../Assets/icons/record.svg | 3 + .../Assets/icons/reminder.svg | 3 + .../Assets/icons/settings.svg | 3 + .../Assets/icons/sidebar.svg | 3 + .../Assets/icons/taskmgr.svg | 3 + .../Assets/icons/usb.svg | 3 + .../SmartSideBAR.Avalonia.csproj | 7 + src/SmartSideBAR.Avalonia/Ui/DockWindow.cs | 57 +++++ .../Ui/FloatBallWindow.cs | 225 ++++++++++-------- .../ViewModels/SidebarViewModel.cs | 19 +- .../Views/SidebarWindow.axaml | 156 ++++++------ .../Views/SidebarWindow.axaml.cs | 25 ++ src/SmartSideBAR.Avalonia/WindowManager.cs | 57 ++++- .../AppBar/AppBarService.cs | 44 +++- .../Native/Win32Display.cs | 34 +++ 39 files changed, 551 insertions(+), 185 deletions(-) create mode 100644 src/SmartSideBAR.Avalonia/Assets/floatball-icon.png create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/annotate.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/bell.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/capture.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/ime.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/link.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/lock.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/longshot.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/printer.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/record.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/reminder.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/settings.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/sidebar.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/taskmgr.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons-white/usb.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/annotate.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/bell.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/capture.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/ime.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/link.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/lock.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/longshot.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/printer.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/record.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/reminder.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/settings.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/sidebar.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/taskmgr.svg create mode 100644 src/SmartSideBAR.Avalonia/Assets/icons/usb.svg create mode 100644 src/SmartSideBAR.Avalonia/Ui/DockWindow.cs diff --git a/src/SmartSideBAR.Avalonia/App.axaml.cs b/src/SmartSideBAR.Avalonia/App.axaml.cs index a995c85..1d22e9e 100644 --- a/src/SmartSideBAR.Avalonia/App.axaml.cs +++ b/src/SmartSideBAR.Avalonia/App.axaml.cs @@ -56,6 +56,8 @@ public override void OnFrameworkInitializationCompleted() }; ConfigureSmokeExit(desktop); + ConfigureAutoDock(windowManager); + ConfigureAutoUndock(windowManager); } base.OnFrameworkInitializationCompleted(); } @@ -97,6 +99,32 @@ private static WindowIcon MakeIcon() 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) { diff --git a/src/SmartSideBAR.Avalonia/Assets/floatball-icon.png b/src/SmartSideBAR.Avalonia/Assets/floatball-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2e79d8fe9893478795886340bae51794f4273814 GIT binary patch literal 38951 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 + + 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/SmartSideBAR.Avalonia.csproj b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj index 596a268..dd885cc 100644 --- a/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj +++ b/src/SmartSideBAR.Avalonia/SmartSideBAR.Avalonia.csproj @@ -13,10 +13,17 @@ + + + + + + + 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 index 0bf6cde..41d8acc 100644 --- a/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs +++ b/src/SmartSideBAR.Avalonia/Ui/FloatBallWindow.cs @@ -1,12 +1,11 @@ -// 悬浮球 (§6.14): 透明置顶小窗; 拖拽吸附 (FloatBallLayout 纯算法) + 点击展开扇形快捷菜单。 +// 悬浮球 (§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; -using AloLayout = global::Avalonia.Layout; -using Math = global::System.Math; namespace SmartSideBAR.Avalonia.Ui; @@ -14,18 +13,29 @@ 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 bool _moved; - private readonly Panel _root = new(); + private DateTime _lastTap = DateTime.MinValue; - public FloatBallWindow(FloatBallConfig cfg, RectLike workArea, Action dispatch) + 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; @@ -35,37 +45,47 @@ public FloatBallWindow(FloatBallConfig cfg, RectLike workArea, Action di TransparencyLevelHint = []; ShowActivated = false; - var ball = new Border - { - Width = _size, - Height = _size, - CornerRadius = new CornerRadius(_size / 2), - Background = new SolidColorBrush(Color.FromArgb(210, 24, 32, 44)), - BorderBrush = new SolidColorBrush(Color.Parse("#2B6EE0")), - BorderThickness = new Thickness(2), - Child = new TextBlock - { - Text = "S", - Foreground = Brushes.White, - FontSize = _size * 0.42, - FontWeight = FontWeight.Bold, - HorizontalAlignment = AloLayout.HorizontalAlignment.Center, - VerticalAlignment = AloLayout.VerticalAlignment.Center, - }, - }; - _root.Children.Add(ball); + _orb = BuildOrb(); + _root.Children.Add(_orb); Content = _root; - // 默认位置: workArea 62% 高度、贴配置侧 - var pos = FloatBallLayout.DefaultBallPosition( - new RectLike(workArea.X, workArea.Y, workArea.Width, workArea.Height), _size, "right"); + 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); @@ -81,108 +101,111 @@ private void OnMove(object? s, PointerEventArgs e) { if (!_dragging) return; var p = e.GetCurrentPoint(this).Position; - var dx = p.X - _pressPos.X; - var dy = p.Y - _pressPos.Y; - if (Math.Abs(dx) + Math.Abs(dy) > 6) _moved = true; - if (_moved) + 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 + (int)dx, _pressWindowPos.Y + (int)dy); + Position = new PixelPoint(_pressWindowPos.X + dx, _pressWindowPos.Y + dy); } } private void OnRelease(object? s, PointerReleasedEventArgs e) { + if (!_dragging) return; _dragging = false; - if (_moved) return; // 拖拽结束: 吸附可选 (Wave D 细化 SnapThreshold) - ToggleFan(); - } - - private bool _fanOpen; - - private void ToggleFan() - { - if (_fanOpen) + if (_moved && !_expanded) { - Width = _size; - Height = _size; - _fanOpen = false; - RebuildFan(collapsed: true); + // 吸附 (v1.1 SnapToEdges 语义, 物理像素) + var snapped = FloatBallLayout.SnapToEdges( + (Position.X, Position.Y), (_size, _size), _workPx, _snapThreshold); + Position = new PixelPoint(snapped.X, snapped.Y); return; } - _fanOpen = true; - RebuildFan(collapsed: false); + // 双击的第一击: 250ms 内第二击由 DoubleTapped 处理 + var sinceLast = DateTime.UtcNow - _lastTap; + _lastTap = DateTime.UtcNow; + if (sinceLast.TotalMilliseconds < 260) return; + ToggleFan(); } - private void RebuildFan(bool collapsed) - { - _root.Children.Clear(); - if (collapsed) - { - Width = _size; - Height = _size; - var ball = new Border - { - Width = _size, - Height = _size, - CornerRadius = new CornerRadius(_size / 2), - Background = new SolidColorBrush(Color.FromArgb(210, 24, 32, 44)), - BorderBrush = new SolidColorBrush(Color.Parse("#2B6EE0")), - BorderThickness = new Thickness(2), - Child = new TextBlock - { - Text = "S", - Foreground = Brushes.White, - FontSize = _size * 0.42, - FontWeight = FontWeight.Bold, - HorizontalAlignment = AloLayout.HorizontalAlignment.Center, - VerticalAlignment = AloLayout.VerticalAlignment.Center, - }, - }; - _root.Children.Add(ball); - return; - } + // ---- 扇形菜单 ---- - // 扇形展开: 简化为圆形环绕 (fanRadius 布局算法的 UI 投影) + 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 + 80; + 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; - Position = new PixelPoint(Position.X - (panel - _size) / 2, Position.Y - (panel - _size) / 2); + 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 angle = Math.PI * 2 * i / count - Math.PI / 2; - var btn = new Button + var (ox, oy) = FloatBallLayout.FanItemOffset(i, count, radius, (dirX, dirY)); + var item = new Button { - Width = 52, - Height = 52, - CornerRadius = new CornerRadius(26), - Content = ActionLabel(action), - Background = new SolidColorBrush(Color.FromArgb(225, 24, 32, 44)), - Foreground = Brushes.White, + 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, + }; - btn.Click += (_, _) => + item.Click += (_, _) => { + CollapseFan(); _dispatch(action); - ToggleFan(); }; - Canvas.SetLeft(btn, panel / 2 + radius * Math.Cos(angle) - 26); - Canvas.SetTop(btn, panel / 2 + radius * Math.Sin(angle) - 26); - _root.Children.Add(btn); + 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 ActionLabel(string action) => action switch + private static string ActionTitle(string action) => action switch { - "capture" => "📷", - "annotate" => "✏️", - "longshot" => "📜", - "record" => "🎥", - "ime" => "中", - "taskmgr" => "📊", - "sidebar" => "☰", - "settings" => "⚙️", + "capture" => "区域截图", + "annotate" => "屏幕批注", + "longshot" => "长截图", + "record" => "录屏", + "ime" => "输入法", + "taskmgr" => "任务管理器", + "sidebar" => "侧边栏", + "settings" => "设置", _ => action, }; } diff --git a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs index 561ad55..93c7539 100644 --- a/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/SmartSideBAR.Avalonia/ViewModels/SidebarViewModel.cs @@ -43,6 +43,7 @@ public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService she PrinterCommand = new RelayCommand(_ => Dispatch("printer")); SettingsCommand = new RelayCommand(_ => Dispatch("settings")); ReminderCommand = new RelayCommand(_ => Dispatch("reminder")); + DockCommand = new RelayCommand(_ => Dispatch("dock")); } public event PropertyChangedEventHandler? PropertyChanged; @@ -50,7 +51,7 @@ public SidebarViewModel(AppBarService appBar, IImeService ime, IShellService she public bool PanelOpen { get => _panelOpen; - private set + set { if (_panelOpen == value) return; _panelOpen = value; @@ -58,6 +59,21 @@ private set } } + 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; @@ -86,6 +102,7 @@ private set 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() { diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml index 30927b4..3a38395 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml @@ -1,6 +1,7 @@ + Width="52" Height="800"> - - - - - - - - - - - - - - - - - - - + + - + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + - + diff --git a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs index 1b911e0..197a046 100644 --- a/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs +++ b/src/SmartSideBAR.Avalonia/Views/SidebarWindow.axaml.cs @@ -1,4 +1,7 @@ using Avalonia.Controls; +using AvaloniaVisual = global::Avalonia.Visual; +using Avalonia.VisualTree; +using SmartSideBAR.Avalonia.ViewModels; using SmartSideBAR.Windows.AppBar; namespace SmartSideBAR.Avalonia.Views; @@ -27,6 +30,28 @@ public void OnAppBarGeometry(int 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 index 6c22d92..4d9d8ac 100644 --- a/src/SmartSideBAR.Avalonia/WindowManager.cs +++ b/src/SmartSideBAR.Avalonia/WindowManager.cs @@ -1,5 +1,6 @@ // 窗口集中管理 (方案 §4.1): 侧栏 AppBar 轨窗口的创建、常驻服务挂接与全部动作路由。 // 附录 A 的 window:*/floatball:*/capture:* 等 invoke 通道在本进程内直达类型安全服务。 +using Avalonia; using Avalonia.Controls; using Microsoft.Extensions.Logging; using SmartSideBAR.Avalonia.Ui; @@ -32,18 +33,25 @@ public sealed class WindowManager( IEventBus bus, ILogger log) { - // v1.1 液态玻璃设计基准 (DIP): rail 72 + 面板 380; 物理像素 = DIP × 窗口 DPI 缩放 - public const double RailWidthDip = 72; + // 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) { @@ -63,6 +71,8 @@ public SidebarWindow CreateSidebar() 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)) @@ -183,6 +193,10 @@ public void Dispatch(string action) ToggleSidebar(); break; + case "dock": + SetDocked(!_docked); + break; + case "settings": OpenSettings(); break; @@ -271,7 +285,9 @@ public void ShowFloatBall() { if (_floatBall is { IsVisible: true }) return; if (config.Current.Policy.DisabledModules.Contains("floatball", StringComparer.OrdinalIgnoreCase)) return; - _floatBall = new FloatBallWindow(config.Current.FloatBall, new Core.FloatBall.RectLike(0, 0, 2560, 1600), Dispatch); + 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(); } @@ -282,6 +298,41 @@ public void ToggleFloatBall() 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) diff --git a/src/SmartSideBAR.Windows/AppBar/AppBarService.cs b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs index ac1b84b..6cc802f 100644 --- a/src/SmartSideBAR.Windows/AppBar/AppBarService.cs +++ b/src/SmartSideBAR.Windows/AppBar/AppBarService.cs @@ -30,11 +30,13 @@ public sealed class AppBarService( 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) @@ -72,6 +74,15 @@ public void SetExpanded(bool 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 蓝本的勘误。 @@ -81,6 +92,37 @@ 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 @@ -98,7 +140,7 @@ public void Reposition(int widthPx) } GrantedRect = granted; bus.Publish(new AppBarGeometryChanged(granted, widthPx)); - log?.LogDebug("[AppBar] 重排 → ({X},{Y}) {W}x{H}px", granted.X, granted.Y, granted.W, granted.H); + log?.LogInformation("[AppBar] 重排 → ({X},{Y}) {W}x{H}px", granted.X, granted.Y, granted.W, granted.H); } /// WndProc 状态机 —— K1/K2/K3/K7 的通知闭环。 diff --git a/src/SmartSideBAR.Windows/Native/Win32Display.cs b/src/SmartSideBAR.Windows/Native/Win32Display.cs index ef3c675..959f91e 100644 --- a/src/SmartSideBAR.Windows/Native/Win32Display.cs +++ b/src/SmartSideBAR.Windows/Native/Win32Display.cs @@ -11,4 +11,38 @@ public static partial class Win32Display /// 窗口 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); } From ea70bbab608853427caf85c8736ad221b2c80613 Mon Sep 17 00:00:00 2001 From: missile <3397241346@qq.com> Date: Sun, 6 Sep 2026 21:34:24 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=E9=81=97=E7=95=99=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B8=85=E5=8D=95=20(remaining-work.md)=20=E2=80=94?= =?UTF-8?q?=20P0=20=E5=8F=91=E5=B8=83=E5=89=8D=E5=BF=85=E5=8A=9E=20/=20P1?= =?UTF-8?q?=20=E5=8A=9F=E8=83=BD=E8=A1=A5=E5=85=A8=20/=20P2=20=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E9=97=A8=E7=A6=81=20/=20P3=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E6=BC=94=E8=BF=9B=20+=20=E5=85=AB=E6=9D=A1=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E5=8B=98=E8=AF=AF=E4=B8=8E=E7=8E=AF=E5=A2=83=E5=A4=87=E5=BF=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/remaining-work.md | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/remaining-work.md 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。