From ef0f467e250ea70239a8cb0002f6e9deabd7d4ee Mon Sep 17 00:00:00 2001 From: haa Date: Wed, 7 Oct 2026 05:51:36 +0200 Subject: [PATCH 1/3] docs: tauri2 capabilities fix brief --- .loom/design/task-brief-capabilities-fix.md | 139 ++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .loom/design/task-brief-capabilities-fix.md diff --git a/.loom/design/task-brief-capabilities-fix.md b/.loom/design/task-brief-capabilities-fix.md new file mode 100644 index 0000000..f2944c5 --- /dev/null +++ b/.loom/design/task-brief-capabilities-fix.md @@ -0,0 +1,139 @@ +# Devin Brief: 根因是 Tauri 2 capabilities 缺失,window.__TAURI__ 从未注入(P0,第三轮) + +## 项目 +`/home/haa/sites/promptkey`,分支从当前 `master`(HEAD `b7c1224`,即 2.0.2)新建 `fix/tauri2-capabilities`。 + +## 背景:三轮修复都没命中根因 + +用户装了 2.0.1 和 2.0.2,两个症状始终存在: +1. **轮盘永远不显示**(按热键毫无反应,但直注可以) +2. **热键录制器无法录入**(提交时被拒) + +前两轮 Devin 分别修了 pipe 链路(重试、顺序、`let _ =`)和录制器交互,**都没解决问题**。我第三轮亲自读完代码,找到根因。 + +## 根因(已核实,不要再怀疑 pipe / emit / listener) + +**Tauri 2 的 capabilities(能力白名单)整个不存在。** + +``` +tauri.conf.json → 没有 app.security.capabilities 字段,也没有指向 capabilities 目录 +gen/schemas/capabilities.json → {} (空对象,零 capability) +仓库根/capabilities/ → ❌ 目录不存在 +``` + +Tauri 2 把 IPC 改成了**白名单制**:没有 capability,`window.__TAURI__` **根本不会注入到 webview**,`invoke()` 也调不通。 + +`withGlobalTauri: true` 是 **Tauri 1 的遗留配置**,在 v2 里它只决定 `__TAURI__` 是不是全局对象,**不等于授予 IPC 权限**。 + +### 为什么两个症状都指向这里 + +| 症状 | 链条 | +|---|---| +| 轮盘不显示 | `src/js/wheel.js` → `prepare()` → `loadPrompts()` → `src/js/store.js:ipc()` → `hasTauri()` 检查 `window.__TAURI__?.core?.invoke` → **false** → 抛「无 Tauri」→ `catch { state.prompts = [] }`。没有 pinned 数据,`prepare()` 后续逻辑全部空转 | +| 录制器无法录入 | `src/js/hotkey_recorder.js` 的 `onCommit` → `ipc('apply_settings', …)` → 同样 `hasTauri()` false → `toast('err', t('ipc.noTauri'))` 后 **throw** → 提交被拒 | + +### 为什么"直注可以" + +`service/src/main.rs` 的 `id=5` 分支(`handle_injection_request`)**完全在 service 内部执行,不经过 GUI、不经过 named pipe、更不经过 Tauri IPC**。所以它能正常工作,**完全不能证明链路是通的**。这是一个误导性线索,前两轮都被它带偏了。 + +### 佐证 + +- `src/js/store.js` 的 `hasTauri()` / `ipc()` 写了完整的降级路径(`t('ipc.noTauri')` + `t('ipc.timeout')` + `t('ipc.fail')`),说明作者预期过 Tauri 缺失的情况 —— 但它现在成了常态 +- Playwright e2e 全部通过,因为测试是**手动 mock `window.__TAURI__`** 注入的。**mock 掩盖了真实环境下的缺失**。这也是为什么 32 个断言全绿而真机全废 + +--- + +# 任务 + +## Task 1:建立 capabilities(核心) + +1. 在仓库根创建 `capabilities/` 目录(Tauri 2 标准位置) +2. 写 `capabilities/default.json`,要求: + - `identifier` 用 `"default"`(或你判断更合适的名字) + - `windows` 必须覆盖**两个窗口**:`main` 和 `wheel-panel`(Rust 侧 `src/main.rs:258-272` 创建 wheel 窗口用的 label 就是 `"wheel-panel"`,主窗口是 tauri.conf.json 里的 `"main"`) + - `permissions` 至少包含 `core:default`,以及项目实际用到的: + - `core:window:allow-show` / `allow-hide` / `allow-set-focus` / `allow-set-position` / `allow-start-dragging`(如有拖拽) + - `core:event:default`(`emit` / `listen` 需要,轮盘的 `wheel-show` 事件依赖它) + - `core:webview:allow-...`(如需) + - `core:app:default` + - **逐个权限对应到真实调用点**,在交付里列出"权限 → 谁在用"的映射表。不要凭感觉堆权限,也不要少授导致新的静默失败 +3. 想清楚要不要拆多个 capability(比如 `main` 和 `wheel` 分开),说明理由 + +## Task 2:让缺失不再静默 + +capabilities 缺失这种致命问题,用户的体感只是"没反应"。要求: + +1. 在 `src/js/store.js` 的 `ipc()` 或 `boot()` 里,**当 `hasTauri()` 为 false 时给出明确、醒目、可操作的提示**,而不是一行 toast 就过去。至少要说清"运行环境缺少 Tauri 能力(capabilities),IPC 不可用",并指向排查方式 +2. 如果有办法在前端检测到 capabilities 缺失(对比预期命令列表),列出来哪些命令不可用 +3. **修 Playwright 的盲区**:现在的 e2e 全靠 mock `__TAURI__`,所以 capabilities 缺失永远测不出来。想一个办法让 CI 能发现这类问题,例如: + - 在 CI 里加一步**校验 `capabilities/` 目录存在且 `gen/schemas/capabilities.json` 非空** + - 或者加一个 Rust 测试断言 capability 已注册 + - 或者两者都做 + - **不要只靠人工 review** + +## Task 3:验证真机链路 + +本机无 Windows,无法真机验证。但你要: + +1. 确认 `tauri build` 会把 `capabilities/` 打进包里(查 Tauri 2 的构建行为,`frontendDist` 之外的能力文件是否需要额外配置) +2. 在 CI 里加一步:构建后**检查产物里含 capability 定义**(如果可行) +3. 如果 tauri CLI 有 `tauri info` / `tauri build --verbose` 能打印生效的 capabilities,跑一次把输出贴出来 + +## Task 4:顺带核查 + +Tauri 1 → 2 的迁移遗漏可能不止 capabilities 一处。系统性过一遍: + +- `withGlobalTauri` 是否还需要(v2 默认行为?) +- `security.csp: null` 在 v2 的语义 +- `src/ipc_listener.rs` 用 `tokio::net::windows::named_pipe` —— 这是**应用自己建的 pipe**,不走 Tauri IPC,**和 capabilities 无关**,确认它不需要额外权限 +- `src/inject_pipe_client.rs` 同理 +- Rust 侧 `#[tauri::command]` 在 v2 是否还需要 `#[allow]` 或注册到某个白名单(除了 `generate_handler!`) +- 有没有别的 v1 遗留配置在 v2 已失效 + +--- + +# 硬约束 + +- 无 cargo,不编译。**但这次必须让 CI 跑 `cargo check`/`clippy`/`test` 全绿** +- Playwright 全部重跑,且**新增一个"不 mock `__TAURI__` 时应该看到明确错误提示"的用例**(防止再被 mock 掩盖) +- 遵守设计系统与 i18n(新文案中英双语) +- 一个 commit `fix(tauri): restore IPC capabilities — window.__TAURI__ was never injected (#NN)`,push,PR `--base master`,**不合并** +- 遵守 LOOM 流程 + +--- + +## 最终交付 + +``` +## 根因确认 +(capabilities 缺失的证据链 + 为什么 Playwright 全绿而真机全废) + +## 改动 +(capabilities 文件内容 + 权限→调用点映射表 + 降级提示 + CI 防线) + +## v1→v2 迁移排查结果 +(Task 4 逐项结论) + +## 测试 +(CI + Playwright,特别是新增的无-mock 用例) + +## 用户升级后必测清单 +(按顺序,每步预期;如果还不行,怎么把证据发回来) + +## 未编译验证清单 +``` + +```json +COMPLETION_NOTIFY +source: assistant +task: promptkey-fix-capabilities +deliverables: +- capabilities/default.json created covering main + wheel-panel with exact permissions +- permission-to-callsite mapping documented +- loud non-silent degradation when __TAURI__ is missing +- CI guard so capabilities can never silently disappear again +- v1->v2 migration audit completed +- branch fix/tauri2-capabilities + PR against master (not merged) +status: success +errors: none +``` From 8ce0dc022fcd1244fb38b3abe24b35e66ff7ca6a Mon Sep 17 00:00:00 2001 From: haa Date: Wed, 7 Oct 2026 06:29:43 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(tauri):=20restore=20IPC=20capabilities?= =?UTF-8?q?=20=E2=80=94=20plugin:*=20IPC=20was=20silently=20ACL-denied=20(?= =?UTF-8?q?LOOM=20TASK-009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.0.1/2.0.2 shipped zero capability files, so Tauri 2's ACL rejected every plugin:* call (event.listen, window.hide, window.set_position) while app commands still answered — the UI looked healthy and the wheel was deaf. All e2e mocked window.__TAURI__, so nothing caught it. - capabilities/default.json covers main + wheel-panel with the exact surface the JS layer calls (core:default + allow-set-position + allow-hide) - probeIpcEnvironment() distinguishes no-bridge vs acl-denied; boot shows a persistent bilingual banner instead of a transient toast; the wheel window renders a visible error card instead of an invisible always-on-top overlay - guards: scripts/check_capabilities.mjs in frontend CI, a cargo test in main.rs, release.yml verifies the resolved capabilities are non-empty - new e2e tests/e2e/no_tauri_e2e.py runs with NO __TAURI__ mock --- .github/workflows/ci.yml | 15 ++- .github/workflows/release.yml | 23 ++++ .loom/design/task-brief-capabilities-fix.md | 43 ++++++ .loom/state.json | 4 +- .loom/tasks.json | 69 ++++++++++ capabilities/default.json | 14 ++ scripts/check_capabilities.mjs | 141 ++++++++++++++++++++ src/index.html | 10 ++ src/js/app.js | 17 ++- src/js/i18n/en-US.js | 8 +- src/js/i18n/zh-CN.js | 8 +- src/js/store.js | 26 ++++ src/js/wheel.js | 22 ++- src/main.rs | 75 ++++++++++- src/styles.css | 12 ++ src/wheel.css | 3 + src/wheel.html | 3 + tests/e2e/no_tauri_e2e.py | 139 +++++++++++++++++++ 18 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 capabilities/default.json create mode 100644 scripts/check_capabilities.mjs create mode 100644 tests/e2e/no_tauri_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 101623e..bc0eca9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,13 +58,19 @@ jobs: - name: Validate JSON configs run: | set -e - for f in tauri.conf.json src/packs/*.json; do + for f in tauri.conf.json capabilities/*.json src/packs/*.json; do echo "validating $f" node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$f" done + # Tauri 2 capabilities regression gate — 2.0.x shipped zero capability + # files; every plugin:* IPC call (event.listen, window.hide, …) was + # ACL-denied while all e2e mocked __TAURI__. Never again. + - name: Verify Tauri capabilities cover both windows + run: node scripts/check_capabilities.mjs + e2e: - name: playwright e2e (mocked Tauri) + name: playwright e2e runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -85,3 +91,8 @@ jobs: - name: Hotkey recorder e2e run: python tests/e2e/hotkey_recorder_e2e.py + + # No __TAURI__ mock — the previous blind spot. Asserts the app fails + # LOUDLY (persistent banner / wheel error card) instead of silently. + - name: Missing-Tauri visibility e2e (no mock) + run: python tests/e2e/no_tauri_e2e.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f625fb..c7ea8ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,29 @@ jobs: - name: Build + bundle (tauri build) run: tauri build --verbose + # tauri-build resolves capabilities/ at compile time and rewrites + # gen/schemas/capabilities.json with the resolved map — this file is the + # build-time proof that capabilities made it into the binary's context. + # {} here = the 2.0.x bug: every plugin:* IPC call ACL-denied at runtime. + - name: Verify resolved capabilities made it into the build + shell: pwsh + run: | + $caps = Get-Content gen/schemas/capabilities.json -Raw | ConvertFrom-Json + $names = @($caps.PSObject.Properties.Name) + if ($names.Count -eq 0) { throw "tauri build resolved ZERO capabilities — plugin:* IPC would be ACL-denied at runtime (the 2.0.x regression)" } + $labels = @() + foreach ($n in $names) { + foreach ($w in @($caps.$n.windows)) { + $labels += $(if ($w -is [string]) { $w } else { $w.identifier }) + } + } + foreach ($req in @('main','wheel-panel')) { + if ($labels -notcontains $req -and $labels -notcontains '*') { + throw "resolved capabilities do not cover window '$req' ($($labels -join ', '))" + } + } + Write-Host "resolved capabilities: $($names -join ', ') — windows: $($labels -join ', ')" + - name: List + verify bundle artifacts shell: pwsh run: | diff --git a/.loom/design/task-brief-capabilities-fix.md b/.loom/design/task-brief-capabilities-fix.md index f2944c5..d77f647 100644 --- a/.loom/design/task-brief-capabilities-fix.md +++ b/.loom/design/task-brief-capabilities-fix.md @@ -137,3 +137,46 @@ deliverables: status: success errors: none ``` + +--- + +# TASK-009 实施记录(源码级结论修正) + +## 机制修正(对原 brief 的一处更正) + +原 brief 说「没有 capability,`window.__TAURI__` 根本不会注入」。读 Tauri 2 源码后需要修正为更精确的两层模型: + +- **`__TAURI__` 注入**由 `app.withGlobalTauri` 控制(tauri-utils config + `read_global_api_scripts` → webview init script),**与 capabilities 无关**。真机上 `hasTauri()` 是 `true`,app command(`generate_handler!` 注册的自定义命令)照常可用——这解释了为什么 2.0.x 的界面能加载、提示词能渲染、服务状态灯正常。 +- **capabilities 管的是 `plugin:*` 命令的 ACL**。`Resolved::resolve` 里 app command 走 `is_allowed`(本地 origin + 无 `__app-acl__` manifest → 直通),plugin command 走 `invoke.acl` 查表。capabilities 为空 → `plugin:event|listen`、`plugin:window|hide`/`set_position` 全部被拒。 +- 所以真实的死法不是「`__TAURI__` 没注入」,而是**「桥在,但轮盘听 `wheel-show` 的 `event.listen` 被 ACL 拒掉」**:Rust 照常 `show()` 出 320px 透明窗口,JS 却永远等不到事件——屏幕上是一个看不见的 always-on-top 覆盖层。比「没反应」更糟,它还会吞点击。 +- 因此降级检测不能只看 `hasTauri()`,必须**实探一个 plugin:* 命令**(`event.listen` 最廉价)。`probeIpcEnvironment()` 区分 `no-bridge` / `acl-denied` 两种死因。 + +## 录制器症状的归因(与 capabilities 无关) + +`apply_settings` / `check_hotkeys` 都是 app command,不受 ACL 门控——录制器的「提交被拒」不是 IPC 拒绝,而是 `commitHotkey` 的回滚路径:`check_hotkeys` 报告 hotkey 注册非 ok(冲突 / 引擎重启失败 / rep 为 null)→ 字段值与配置一起回滚 + toast。这是真实的注册失败被正确表面化。若升级后仍复现,用「链路诊断」按钮的 JSON 回报发回。 + +## 权限 → 调用点映射(逐条有据) + +| 权限(解析后) | 调用点 | 窗口 | +|---|---|---| +| `core:event:allow-listen`(由 `core:default`→`core:event:default` 提供) | `wheel.js init()`:`listen('wheel-show')`/`listen('wheel-hide')`;`app.js`:`listen('wheel-new-prompt')` | 两个 | +| `core:event:allow-unlisten` | listen 返回的 unlisten(探针调用) | 两个 | +| `core:window:allow-outer-position` / `outer-size` / `current-monitor`(由 `core:default`→`core:window:default` 提供) | `wheel.js clampToViewport()` | wheel-panel | +| `core:window:allow-set-position`(显式授予,不在 default) | `clampToViewport()` → `w.setPosition(PhysicalPosition)` | wheel-panel | +| `core:window:allow-hide`(显式授予,不在 default) | `wheel.js hide()` → `win().hide()` | wheel-panel | + +**未授予及理由**:`allow-show`/`allow-set-focus`/`allow-start-dragging`/`allow-close` 等窗口写操作在 JS 侧无调用点(show/focus/hide 由 Rust 侧执行,不经 ACL);`shell:`/`dialog:`/`fs:`/`core:webview:*` 变更类权限同理——JS 从不 invoke 这些 plugin 命令。 + +## app command 不需要 capability 的依据 + +`tauri-build` 的 `has_app_manifest` = `commands().len()>0 || permissions/ 非空 || permission_sets 非空`。本仓库 `build.rs` 仅 `tauri_build::build()`(默认 `AppManifest`,commands 空),无 `permissions/` 目录 → 无 `__app-acl__` manifest → `Resolved::resolve` 对 app command 只做 `is_local` 判断,本地 origin 直通。**若日后给 app command 建 manifest,则必须为每个 `invoke()` 命令建 `allow-` 权限**——这是一个已记录的陷阱。 + +## 拆还是不拆 + +单一 `default` capability 覆盖两个窗口。理由:两个 webview 加载同一 `frontendDist` 的受信本地代码,所需权限差仅两条 window 写权限;拆成 main/wheel 两个文件只增加维护面,不增加安全边界(local-only app,无 remote 源)。 + +## 构建链验证(对 Task 3 的源码级回答) + +- `tauri_build::build()` → `acl::build` → 默认 glob `./capabilities/**/*`(`capabilities_path_pattern` 未覆盖时),与 `frontendDist` 无关——能力文件不进前端产物,而是在 `generate_context!()` 编译期打进二进制的 `context.capabilities`。 +- `save_capabilities` 把解析结果写回 `gen/schemas/capabilities.json` 并 copy 进 `OUT_DIR`;release CI 在读它验证「构建产物确实含 capability」。 +- `validate_capabilities` 在 build.rs 阶段就会对未知权限 identifier 报错 → 拼错的权限过不了 `cargo check`。 diff --git a/.loom/state.json b/.loom/state.json index ad301fb..7dcb844 100644 --- a/.loom/state.json +++ b/.loom/state.json @@ -2,9 +2,9 @@ "schema_version": 2, "project": { "name": "promptkey", - "status": "complete", + "status": "building", "created_at": "2026-10-06T08:16:56.246Z", - "updated_at": "2026-10-07T02:47:43.481Z" + "updated_at": "2026-10-07T04:17:51.530Z" }, "understanding": { "confirmed": [ diff --git a/.loom/tasks.json b/.loom/tasks.json index 54bad5d..20c8741 100644 --- a/.loom/tasks.json +++ b/.loom/tasks.json @@ -846,6 +846,75 @@ "created_at": "2026-10-07T02:37:42.508Z", "updated_at": "2026-10-07T02:47:43.480Z", "completed_at": "2026-10-07T02:47:43.480Z" + }, + { + "id": "TASK-009", + "title": "Restore Tauri 2 IPC capabilities + loud env-degradation + CI guards", + "outcome": "capabilities/default.json grants the exact plugin IPC surface both webviews need; missing capabilities produce a visible banner instead of silent failure; CI blocks regression.", + "acceptance": [ + { + "criterion": "capabilities/default.json exists; windows cover main + wheel-panel; permissions include core:default + allow-set-position + allow-hide", + "verify_by": "cat capabilities/default.json; node scripts/check_capabilities.mjs; cargo test capabilities_cover_both_windows", + "evidence": "" + }, + { + "criterion": "When __TAURI__ is absent OR plugin IPC is ACL-denied, main window shows a persistent bilingual banner; wheel window shows a visible error card", + "verify_by": "python tests/e2e/no_tauri_e2e.py — no-mock and acl-denied cases", + "evidence": "" + }, + { + "criterion": "CI fails if capabilities/ disappears or loses window coverage; release build fails if resolved capabilities are empty", + "verify_by": "review .github/workflows/ci.yml + release.yml; node scripts/check_capabilities.mjs exit codes", + "evidence": "" + } + ], + "done_when": [], + "boundaries": [ + "Does not change injection engine, pipe protocol, or service internals", + "Does not grant plugin permissions the JS layer never calls (no dialog/fs/shell perms — they are Rust-side only)" + ], + "depends_on": [], + "reads": [ + ".loom/design/task-brief-capabilities-fix.md", + "gen/schemas/acl-manifests.json", + "src/js/store.js", + "src/js/wheel.js", + "src/js/app.js", + "src/main.rs", + "tauri.conf.json" + ], + "touches": [ + "capabilities/default.json", + "src/js/store.js", + "src/js/app.js", + "src/js/wheel.js", + "src/js/i18n/zh-CN.js", + "src/js/i18n/en-US.js", + "src/index.html", + "src/wheel.html", + "src/styles.css", + "src/wheel.css", + "src/main.rs", + "scripts/check_capabilities.mjs", + "tests/e2e/no_tauri_e2e.py", + ".github/workflows/ci.yml", + ".github/workflows/release.yml" + ], + "implements": ".loom/design/task-brief-capabilities-fix.md", + "design_exemption": "", + "capability_hooks": [], + "capability_exemption": "Pure configuration/IPC-hardening fix — no professional-domain judgment needed beyond the source-verified Tauri 2 ACL semantics.", + "integrity_version": 1, + "covers": [], + "status": "active", + "progress": { + "completed": [], + "current": "", + "next": "" + }, + "evidence": [], + "created_at": "2026-10-07T04:17:40.992Z", + "updated_at": "2026-10-07T04:17:51.529Z" } ] } diff --git a/capabilities/default.json b/capabilities/default.json new file mode 100644 index 0000000..1c08463 --- /dev/null +++ b/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "IPC surface for the two local webviews: the main settings window and the wheel-panel overlay. Every permission below maps to a concrete JS call site — see PR body for the mapping table.", + "windows": [ + "main", + "wheel-panel" + ], + "permissions": [ + "core:default", + "core:window:allow-set-position", + "core:window:allow-hide" + ] +} diff --git a/scripts/check_capabilities.mjs b/scripts/check_capabilities.mjs new file mode 100644 index 0000000..11dddc3 --- /dev/null +++ b/scripts/check_capabilities.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Tauri 2 capability regression gate — the static half of the "never silently +// lose capabilities again" fix (the runtime half is probeIpcEnvironment). +// +// Why this exists: 2.0.1/2.0.2 shipped with ZERO capability files. Tauri 2 +// then ACL-denies every plugin:* IPC call (event.listen, window.set_position, +// window.hide…) while app commands still answer — so prompts loaded, the +// service dot was green, and the wheel was permanently deaf. Every e2e test +// mocked window.__TAURI__, so nothing caught it. +// +// Checks (pure Node, no Rust toolchain): +// 1. capabilities/ exists and holds at least one .json file +// 2. union of `windows` covers the two webview labels: main + wheel-panel +// 3. every referenced permission identifier exists in +// gen/schemas/acl-manifests.json (catches typos statically) +// 4. resolved allow-* set covers the plugin commands the JS layer calls +// +// Exits non-zero with a precise message on any failure. + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.cwd(); +const CAP_DIR = join(ROOT, 'capabilities'); +const MANIFEST_PATH = join(ROOT, 'gen/schemas/acl-manifests.json'); + +const failures = []; +const fail = (msg) => failures.push(msg); + +/* ---------- window coverage ---------- */ +// labels come from tauri.conf.json (main) and src/main.rs WebviewWindowBuilder +const REQUIRED_WINDOWS = ['main', 'wheel-panel']; + +/* ---------- plugin commands the JS layer actually invokes ---------- */ +// wheel.js: event.listen x2, win.outerPosition/outerSize/currentMonitor, +// win.setPosition, win.hide (window "wheel-panel") +// app.js: event.listen('wheel-new-prompt') (window "main") +// Everything else the frontend invokes is an app command — ungated while the +// crate ships no permissions/ dir (verified: build.rs calls tauri_build::build +// with default attributes; no app ACL manifest is emitted). +const REQUIRED_ALLOW = { + 'core:event': ['allow-listen'], + 'core:window': [ + 'allow-outer-position', + 'allow-outer-size', + 'allow-current-monitor', + 'allow-set-position', + 'allow-hide', + ], +}; + +/* ---------- collect capability files ---------- */ +if (!existsSync(CAP_DIR)) { + fail('capabilities/ directory is missing — Tauri 2 will deny every plugin:* IPC call at runtime'); + report(); +} +const files = readdirSync(CAP_DIR).filter(f => f.endsWith('.json')); +if (!files.length) fail('capabilities/ exists but contains no .json capability file'); + +const windows = []; +const permIds = []; // raw identifiers, string or {identifier, ...} +for (const f of files) { + const p = join(CAP_DIR, f); + let v; + try { v = JSON.parse(readFileSync(p, 'utf8')); } + catch (e) { fail(`${f}: invalid JSON — ${e.message}`); continue; } + for (const w of Array.isArray(v.windows) ? v.windows : []) { + windows.push(typeof w === 'string' ? w : w?.identifier); + } + for (const perm of Array.isArray(v.permissions) ? v.permissions : []) { + permIds.push(typeof perm === 'string' ? perm : perm?.identifier); + } +} + +// glob-ish window matching: '*' or an entry equal to the label +const covers = (label) => windows.some(w => w === '*' || w === label); +for (const label of REQUIRED_WINDOWS) { + if (!covers(label)) fail(`no capability covers window label '${label}'`); +} + +/* ---------- permission identifier validation ---------- */ +let manifest = null; +if (existsSync(MANIFEST_PATH)) { + manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); +} else { + console.warn('warning: gen/schemas/acl-manifests.json not found — identifier validation skipped'); +} + +// split 'a:b:c' → prefix 'a:b', name 'c' (last ':' separates the permission name) +function splitId(id) { + const i = id.lastIndexOf(':'); + return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]; +} +// resolve an identifier to the flat allow-* list it grants, using the manifest. +// Entries inside a default_permission list can be bare 'allow-x' names — +// those are relative to the containing plugin's prefix (ctxPrefix). +function resolveAllows(id, seen = new Set(), ctxPrefix = '') { + const key = `${ctxPrefix}|${id}`; // bare names resolve under different prefixes + if (seen.has(key)) return []; + seen.add(key); + let [prefix, name] = splitId(id); + if (!name) { prefix = ctxPrefix; name = id; } + if (!manifest) return name.startsWith('allow-') ? [[prefix, name]] : []; + const m = manifest[prefix]; + if (!m) { fail(`permission '${id}': unknown plugin prefix '${prefix}'`); return []; } + if (name === 'default') { + const dp = m.default_permission; + if (!dp) { fail(`permission '${id}': plugin '${prefix}' has no default`); return []; } + return (dp.permissions || []).flatMap(p => resolveAllows(p, seen, prefix)); + } + if (!m.permissions?.[name]) { fail(`permission '${id}': not found in manifest for '${prefix}'`); return []; } + return name.startsWith('allow-') ? [[prefix, name]] : []; +} + +const granted = {}; // prefix -> Set(allow-*) +for (const id of permIds) { + if (!id) continue; + for (const [prefix, allow] of resolveAllows(id)) { + (granted[prefix] ??= new Set()).add(allow); + } +} + +for (const [prefix, allows] of Object.entries(REQUIRED_ALLOW)) { + for (const allow of allows) { + if (!granted[prefix]?.has(allow)) { + fail(`capability set grants no '${prefix}:${allow}' — a real JS call site needs it`); + } + } +} + +report(); + +function report() { + if (failures.length) { + console.error('capability check FAILED:'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log(`capabilities OK — ${files.length} file(s), windows: ${[...new Set(windows)].join(', ')}`); + process.exit(0); +} diff --git a/src/index.html b/src/index.html index 1adb72e..120cc37 100644 --- a/src/index.html +++ b/src/index.html @@ -44,6 +44,16 @@
+ + +
diff --git a/src/js/app.js b/src/js/app.js index d0a5c62..55e9165 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -3,7 +3,7 @@ import { icon } from './icons.js'; import { t, initI18n, setLangPref, getLangPref, onLangChange } from './i18n.js'; import { applyTheme, setThemeMode, getThemeMode, resolvedTheme } from './theme.js'; import { toast, modalOpen, modalEscape } from './toast.js'; -import { state, ipc, loadPrompts, rebuildIndex, renderVars, customVars, wheelPrompts } from './store.js'; +import { state, ipc, loadPrompts, rebuildIndex, renderVars, customVars, wheelPrompts, probeIpcEnvironment } from './store.js'; import { wirePrompts, renderPrompts, openDrawer, closeDrawer, drawerOpen } from './views/prompts.js'; import { wireLibrary, renderLibrary, previewPackJson } from './views/library.js'; import { wireLog, renderLog } from './views/log.js'; @@ -140,6 +140,21 @@ async function boot() { }); } catch (e) { console.warn('wheel-new-prompt listen failed', e); } + // Prove the IPC environment loudly. 2.0.x shipped with zero capabilities — + // every plugin:* call (event.listen, window.*) was ACL-denied while app + // commands still worked, so the app looked fine and the wheel was deaf. + // A toast is invisible for that; a persistent banner is not. + const env = await probeIpcEnvironment(); + window.__PK_IPC_ENV__ = env; + if (!env.ok) { + const banner = $('#envBanner'); + if (banner) { + $('#envBannerTitle').textContent = env.kind === 'acl-denied' ? t('ipc.envAcl') : t('ipc.envNoBridge'); + $('#envBannerBody').textContent = env.kind === 'acl-denied' ? t('ipc.envAclSub', { e: env.detail }) : t('ipc.envNoBridgeSub'); + banner.classList.remove('hidden'); + } + } + // data try { await loadPrompts(); diff --git a/src/js/i18n/en-US.js b/src/js/i18n/en-US.js index c9df40d..96ab3f8 100644 --- a/src/js/i18n/en-US.js +++ b/src/js/i18n/en-US.js @@ -38,6 +38,8 @@ export default { "wh.new": "+ New", "wh.centerHint": "Click to close · right-click/hold = new prompt", "wh.page": "{a}/{b}", + "wh.envNoBridge": "Runtime problem: Tauri bridge not injected — wheel cannot work", + "wh.envAcl": "IPC denied by ACL — capabilities missing, wheel cannot work", "tags.section": "Tags", @@ -199,7 +201,11 @@ export default { "ipc.fail": "Operation failed: {e}", "ipc.timeout": "Request timed out — service may be down", - "ipc.noTauri": "Desktop runtime not ready", + "ipc.noTauri": "Tauri bridge not injected (window.__TAURI__ missing)", + "ipc.envNoBridge": "Runtime problem: desktop bridge (Tauri) not injected", + "ipc.envNoBridgeSub": "No IPC call can work. Expected when previewing in a plain browser; if this is the installed desktop app, reinstall it or send a screenshot of this message to the developers.", + "ipc.envAcl": "Runtime problem: IPC permissions (capabilities) missing", + "ipc.envAclSub": "Event and window APIs were denied by ACL: {e}. Wheel summon and quick-create will not work — upgrade to a build that ships capabilities, or report this message to the developers.", "err.generic": "Error: {e}", "rel.justnow": "just now", diff --git a/src/js/i18n/zh-CN.js b/src/js/i18n/zh-CN.js index 662cdfd..907ddf0 100644 --- a/src/js/i18n/zh-CN.js +++ b/src/js/i18n/zh-CN.js @@ -38,6 +38,8 @@ export default { "wh.new": "+ 新建", "wh.centerHint": "点击关闭 · 右键/长按 = 新建提示词", "wh.page": "{a}/{b}", + "wh.envNoBridge": "环境异常:桌面桥接(window.__TAURI__)未注入 — 轮盘无法工作", + "wh.envAcl": "IPC 被 ACL 拒绝 — capabilities 缺失,轮盘无法工作", "tags.section": "标签", @@ -199,7 +201,11 @@ export default { "ipc.fail": "操作失败:{e}", "ipc.timeout": "请求超时,服务可能未响应", - "ipc.noTauri": "桌面环境未就绪", + "ipc.noTauri": "桌面桥接未注入(window.__TAURI__ 缺失)", + "ipc.envNoBridge": "运行环境异常:桌面桥接(Tauri)未注入", + "ipc.envNoBridgeSub": "所有 IPC 调用不可用。浏览器中预览属预期;若这是已安装的桌面版,请重新安装,或将此提示截图反馈给开发者。", + "ipc.envAcl": "运行环境异常:IPC 权限(capabilities)缺失", + "ipc.envAclSub": "事件与窗口接口被 ACL 拒绝:{e}。轮盘唤起、快速新建将不可用 — 请升级到包含 capabilities 的构建,或把此提示反馈给开发者。", "err.generic": "出错了:{e}", "rel.justnow": "刚刚", diff --git a/src/js/store.js b/src/js/store.js index a61b505..d97858c 100644 --- a/src/js/store.js +++ b/src/js/store.js @@ -6,6 +6,32 @@ export function hasTauri() { return !!(window.__TAURI__ && (window.__TAURI__.core?.invoke || window.__TAURI__.invoke)); } +// Distinguishes the two ways IPC dies: +// 'no-bridge' — window.__TAURI__ was never injected (plain browser, or +// withGlobalTauri off): nothing works at all. +// 'acl-denied' — bridge exists but no capability authorizes plugin:* calls. +// event.listen / window.* are rejected while app commands +// still answer, so the app LOOKS healthy — this is the +// 2.0.x failure mode (capabilities/ dir was missing). +export async function probeIpcEnvironment() { + if (!hasTauri()) { + return { ok: false, kind: 'no-bridge', detail: 'window.__TAURI__.core.invoke not injected' }; + } + const listen = window.__TAURI__.event?.listen; + if (typeof listen !== 'function') { + return { ok: false, kind: 'no-bridge', detail: 'window.__TAURI__.event.listen missing' }; + } + try { + // Cheapest ACL-gated probe: registers a listener on a channel nothing + // ever emits. Rejects iff capabilities are absent/mis-scoped. + const unlisten = await listen('pk-env-probe', () => {}); + if (typeof unlisten === 'function') unlisten(); + return { ok: true }; + } catch (e) { + return { ok: false, kind: 'acl-denied', detail: String(e?.message || e) }; + } +} + function rawInvoke() { return window.__TAURI__.core?.invoke || window.__TAURI__.invoke; } diff --git a/src/js/wheel.js b/src/js/wheel.js index 1160f17..140fdeb 100644 --- a/src/js/wheel.js +++ b/src/js/wheel.js @@ -263,12 +263,28 @@ center?.addEventListener('mouseleave', () => clearTimeout(pressTimer)); window.addEventListener('blur', hide); function onShow() { prepare(); } + +// IPC failure must be VISIBLE in the wheel: when the listener never registers, +// Rust still shows this transparent overlay on every hotkey press — a silent +// catch is how 2.0.x shipped an invisible dead window over the user's work. +function envError(msg) { + const el = $('#envErr'); + if (el) { el.textContent = msg; el.classList.add('show'); } +} async function init() { syncPrefs(); // first paint state + const listen = window.__TAURI__?.event?.listen; + if (typeof listen !== 'function') { + envError(t('wh.envNoBridge')); + return; + } try { - await window.__TAURI__?.event?.listen('wheel-show', onShow); - await window.__TAURI__?.event?.listen('wheel-hide', () => hide()); - } catch (e) { console.warn('wheel event listen failed', e); } + await listen('wheel-show', onShow); + await listen('wheel-hide', () => hide()); + } catch (e) { + console.warn('wheel event listen failed', e); + envError(t('wh.envAcl')); + } } init(); diff --git a/src/main.rs b/src/main.rs index c7eecb9..5476ab9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1654,4 +1654,77 @@ fn restart_service(app: AppHandle) -> Result { Ok(()) => Ok("服务已重启".to_string()), Err(e) => Err(e) } -} \ No newline at end of file +} +#[cfg(test)] +mod capability_regression_tests { + //! P0 regression guard — 2.0.1/2.0.2 shipped with an EMPTY capability set: + //! Tauri 2 then ACL-denies every `plugin:*` IPC call (event.listen, + //! window.set_position, window.hide…). App commands still answered, so the + //! UI looked healthy while the wheel window was permanently deaf — and + //! every e2e test mocked window.__TAURI__, so nothing caught it. + //! This test fails `cargo test` before that can ship again. + + use std::path::Path; + + #[test] + fn capabilities_cover_both_windows() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("capabilities"); + assert!( + dir.is_dir(), + "capabilities/ directory is missing — Tauri 2 will deny every plugin:* IPC call" + ); + + let mut windows_covered: Vec = Vec::new(); + let mut permissions: Vec = Vec::new(); + let mut files = 0usize; + for entry in std::fs::read_dir(&dir).expect("capabilities/ readable") { + let path = entry.expect("dir entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + files += 1; + let text = std::fs::read_to_string(&path).expect("capability file readable"); + let v: serde_json::Value = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("invalid capability JSON {}: {}", path.display(), e)); + if let Some(ws) = v.get("windows").and_then(|w| w.as_array()) { + for w in ws { + if let Some(s) = w.as_str() { + windows_covered.push(s.to_string()); + } + } + } + if let Some(ps) = v.get("permissions").and_then(|p| p.as_array()) { + for p in ps { + if let Some(s) = p.as_str() { + permissions.push(s.to_string()); + } else if let Some(s) = p.get("identifier").and_then(|i| i.as_str()) { + permissions.push(s.to_string()); + } + } + } + } + assert!(files > 0, "capabilities/ contains no .json file"); + + let covered = |label: &str| windows_covered.iter().any(|w| w == "*" || w == label); + assert!(covered("main"), "no capability covers the 'main' window"); + assert!( + covered("wheel-panel"), + "no capability covers the 'wheel-panel' window — wheel event.listen/setPosition/hide will be ACL-denied" + ); + + // The load-bearing grants the JS layer actually calls: + // core:default → event:listen + window read APIs + // core:window:allow-hide → wheel.js hide() + // core:window:allow-set-position → wheel.js clampToViewport + for need in [ + "core:default", + "core:window:allow-hide", + "core:window:allow-set-position", + ] { + assert!( + permissions.iter().any(|p| p == need), + "permission '{need}' missing from capabilities — a real JS call site depends on it" + ); + } + } +} diff --git a/src/styles.css b/src/styles.css index 9347a66..0325c4a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -84,6 +84,18 @@ button{font-family:inherit} .topbar-right{margin-left:auto;display:flex;gap:8px;align-items:center} .pill-hint{font-size:var(--fs-caption);color:var(--text-muted)} +/* Fatal IPC-environment banner — persistent by design (never auto-dismisses; + a missing capabilities set must not hide behind a transient toast) */ +.env-banner{ + display:flex;gap:10px;align-items:flex-start;flex-shrink:0; + margin:var(--sp-3) var(--sp-5) 0;padding:10px 14px; + border:1px solid var(--danger);border-left-width:3px;border-radius:var(--r-md); + background:var(--bg-surface);font-size:var(--fs-small); +} +.env-banner>.ico{color:var(--danger);flex-shrink:0;margin-top:1px} +.env-banner-title{display:block;font-size:var(--fs-body);font-weight:600;color:var(--text-primary);margin-bottom:2px} +.env-banner-body{line-height:1.55;color:var(--text-secondary);word-break:break-word} + /* ============ Views ============ */ .view{display:none;flex:1;min-height:0} .view.active{display:flex} diff --git a/src/wheel.css b/src/wheel.css index 0bd13da..7648ae6 100644 --- a/src/wheel.css +++ b/src/wheel.css @@ -63,6 +63,9 @@ body{ max-width:200px;overflow:hidden;text-overflow:ellipsis; } .empty-tip{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:11px;color:var(--muted);width:130px;text-align:center;pointer-events:none} +/* fatal IPC-env card — fills the transparent window so a broken environment is visible, not invisible */ +.env-err{position:fixed;inset:0;display:none;place-items:center;pointer-events:none} +.env-err.show{display:grid;padding:24px;background:var(--surface-solid);border:1px solid var(--danger);border-radius:14px;color:var(--text);font-size:11px;line-height:1.6;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,.5)} /* toast inside wheel window */ .toasts{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);z-index:30;display:flex;flex-direction:column;gap:6px;align-items:center} .toast{background:var(--surface-solid);border:1px solid var(--border-strong);border-radius:10px;padding:9px 16px;font-size:12px;box-shadow:0 8px 30px rgba(0,0,0,.35);animation:open .18s var(--ease-out);display:flex;gap:8px;align-items:center;white-space:nowrap;max-width:290px;overflow:hidden;text-overflow:ellipsis} diff --git a/src/wheel.html b/src/wheel.html index 930b603..dc5e3d3 100644 --- a/src/wheel.html +++ b/src/wheel.html @@ -13,6 +13,9 @@
+ +
diff --git a/tests/e2e/no_tauri_e2e.py b/tests/e2e/no_tauri_e2e.py new file mode 100644 index 0000000..a356e4d --- /dev/null +++ b/tests/e2e/no_tauri_e2e.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Missing-Tauri / ACL-denied visibility e2e — regression guard for the 2.0.x +capabilities bug. + +Every existing e2e mocks window.__TAURI__, which is exactly how 2.0.1/2.0.2 +shipped green while real installs had every plugin:* call ACL-denied. This +test is the blind-spot fix: + + 1. NO __TAURI__ at all (plain-browser context) → persistent env banner + 2. __TAURI__ present but event.listen rejects → ACL-denied banner + 3. wheel.html with no __TAURI__ → visible error card + 4. wheel.html with listen denied → visible error card + +A silent failure must never again be the only signal. + +Run: python3 tests/e2e/no_tauri_e2e.py +Reqs: python3 -m playwright (browsers installed) +""" + +import functools +import http.server +import socketserver +import sys +import threading +from pathlib import Path + +from playwright.sync_api import sync_playwright + +SRC = Path(__file__).resolve().parents[2] / "src" + +LANG_ZH = "localStorage.setItem('pk-lang', 'zh-CN');" + +# Bridge present (app commands would work) but plugin:* is ACL-denied — +# exactly what a missing capabilities/ dir produces on a real install. +ACL_DENIED = """ +window.__TAURI__ = { + core: { invoke: (cmd) => Promise.resolve(null) }, + event: { + listen: (name) => Promise.reject( + `Command plugin:event|listen not allowed by ACL (${name})`), + emit: () => Promise.reject('Command plugin:event|emit not allowed by ACL'), + }, + window: { getCurrentWindow: () => ({}) }, +}; +""" + + +class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + +def serve(): + handler = functools.partial(Quiet, directory=str(SRC)) + srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, srv.server_address[1] + + +CHECKS = [] + + +def check(name, cond, extra=""): + CHECKS.append((name, bool(cond), extra)) + print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" [{extra}]" if extra and not cond else "")) + + +def main(): + srv, port = serve() + with sync_playwright() as p: + browser = p.chromium.launch() + + # ---- 1. main window, no __TAURI__ injected at all ---- + page = browser.new_page() + page.add_init_script(LANG_ZH) + page.goto(f"http://127.0.0.1:{port}/index.html") + page.locator("#envBanner:not(.hidden)").wait_for(timeout=5000) + check("no-bridge: banner visible", page.locator("#envBanner").is_visible()) + check("no-bridge: title names the missing bridge", + "桥接" in (page.locator("#envBannerTitle").text_content() or "") + or "Tauri" in (page.locator("#envBannerTitle").text_content() or "")) + check("no-bridge: body is actionable", + "IPC" in (page.locator("#envBannerBody").text_content() or "")) + kind = page.evaluate("window.__PK_IPC_ENV__?.kind") + check("no-bridge: probe reports kind", kind == "no-bridge", f"got {kind!r}") + page.close() + + # ---- 2. main window, bridge present but plugin:* ACL-denied ---- + page = browser.new_page() + page.add_init_script(LANG_ZH) + page.add_init_script(ACL_DENIED) + page.goto(f"http://127.0.0.1:{port}/index.html") + page.locator("#envBanner:not(.hidden)").wait_for(timeout=5000) + check("acl-denied: banner visible", page.locator("#envBanner").is_visible()) + check("acl-denied: title names capabilities", + "capabilities" in (page.locator("#envBannerTitle").text_content() or "")) + body = page.locator("#envBannerBody").text_content() or "" + check("acl-denied: body quotes the denied command", + "plugin:event|listen" in body or "ACL" in body) + kind = page.evaluate("window.__PK_IPC_ENV__?.kind") + check("acl-denied: probe reports kind", kind == "acl-denied", f"got {kind!r}") + page.close() + + # ---- 3. wheel window, no __TAURI__ ---- + page = browser.new_page() + page.add_init_script(LANG_ZH) + page.goto(f"http://127.0.0.1:{port}/wheel.html") + page.locator("#envErr.show").wait_for(timeout=5000) + check("wheel no-bridge: error card visible", page.locator("#envErr").is_visible()) + check("wheel no-bridge: card explains", + "桥接" in (page.locator("#envErr").text_content() or "")) + check("wheel no-bridge: wheel itself stays hidden", + "show" not in (page.locator("#wheel").get_attribute("class") or "")) + page.close() + + # ---- 4. wheel window, listen ACL-denied ---- + page = browser.new_page() + page.add_init_script(LANG_ZH) + page.add_init_script(ACL_DENIED) + page.goto(f"http://127.0.0.1:{port}/wheel.html") + page.locator("#envErr.show").wait_for(timeout=5000) + check("wheel acl-denied: error card visible", page.locator("#envErr").is_visible()) + check("wheel acl-denied: card names ACL", + "ACL" in (page.locator("#envErr").text_content() or "")) + page.close() + + browser.close() + srv.shutdown() + + failed = [n for n, ok, _ in CHECKS if not ok] + print(f"\n{len(CHECKS) - len(failed)}/{len(CHECKS)} checks passed") + if failed: + print("FAILED:", ", ".join(failed)) + sys.exit(1) + print("ALL PASS") + + +if __name__ == "__main__": + main() From 77ff5d9596d25d102f438e5edcf4fb577becae0f Mon Sep 17 00:00:00 2001 From: haa Date: Wed, 7 Oct 2026 06:30:43 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(loom):=20TASK-009=20done=20=E2=80=94?= =?UTF-8?q?=20capabilities=20fix=20evidence=20(LOOM=20TASK-009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .loom/state.json | 4 ++-- .loom/tasks.json | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.loom/state.json b/.loom/state.json index 7dcb844..fe794e8 100644 --- a/.loom/state.json +++ b/.loom/state.json @@ -2,9 +2,9 @@ "schema_version": 2, "project": { "name": "promptkey", - "status": "building", + "status": "complete", "created_at": "2026-10-06T08:16:56.246Z", - "updated_at": "2026-10-07T04:17:51.530Z" + "updated_at": "2026-10-07T04:30:31.807Z" }, "understanding": { "confirmed": [ diff --git a/.loom/tasks.json b/.loom/tasks.json index 20c8741..9bf97c9 100644 --- a/.loom/tasks.json +++ b/.loom/tasks.json @@ -855,17 +855,17 @@ { "criterion": "capabilities/default.json exists; windows cover main + wheel-panel; permissions include core:default + allow-set-position + allow-hide", "verify_by": "cat capabilities/default.json; node scripts/check_capabilities.mjs; cargo test capabilities_cover_both_windows", - "evidence": "" + "evidence": "capabilities/default.json created (identifier 'default', windows [main, wheel-panel], permissions [core:default, allow-set-position, allow-hide]). node scripts/check_capabilities.mjs → 'capabilities OK'; negative tests (drop wheel-panel / drop mutating perms) fail correctly. Rust test capabilities_cover_both_windows added to src/main.rs — CI runs it (cargo test --workspace); local cargo unavailable per hard constraint." }, { "criterion": "When __TAURI__ is absent OR plugin IPC is ACL-denied, main window shows a persistent bilingual banner; wheel window shows a visible error card", "verify_by": "python tests/e2e/no_tauri_e2e.py — no-mock and acl-denied cases", - "evidence": "" + "evidence": "13/13 PASS. probeIpcEnvironment() (store.js) distinguishes no-bridge vs acl-denied via a live event.listen probe; app.js boot fills #envBanner (persistent, role=alert); wheel.js init() fills #envErr card. i18n keys added zh-CN + en-US." }, { "criterion": "CI fails if capabilities/ disappears or loses window coverage; release build fails if resolved capabilities are empty", "verify_by": "review .github/workflows/ci.yml + release.yml; node scripts/check_capabilities.mjs exit codes", - "evidence": "" + "evidence": "ci.yml frontend job runs check_capabilities.mjs (validates dir exists, windows coverage, identifier existence vs acl-manifests.json, resolved allow-set); e2e job adds no_tauri_e2e.py. release.yml asserts gen/schemas/capabilities.json non-empty + covers both windows after tauri build." } ], "done_when": [], @@ -906,15 +906,40 @@ "capability_exemption": "Pure configuration/IPC-hardening fix — no professional-domain judgment needed beyond the source-verified Tauri 2 ACL semantics.", "integrity_version": 1, "covers": [], - "status": "active", + "status": "done", "progress": { "completed": [], - "current": "", + "current": "complete", "next": "" }, - "evidence": [], + "evidence": [ + "commit 8ce0dc0 on fix/tauri2-capabilities pushed; PR #13 against master (not merged)", + "mechanism correction documented in .loom/design/task-brief-capabilities-fix.md (TASK-009 节): __TAURI__ IS injected; plugin:* commands were ACL-denied; app commands ungated (no __app-acl__ manifest)", + { + "type": "acceptance_results", + "results": [ + { + "criterion": "capabilities/default.json exists; windows cover main + wheel-panel; permissions include core:default + allow-set-position + allow-hide", + "verify_by": "cat capabilities/default.json; node scripts/check_capabilities.mjs; cargo test capabilities_cover_both_windows", + "evidence": "capabilities/default.json created (identifier 'default', windows [main, wheel-panel], permissions [core:default, allow-set-position, allow-hide]). node scripts/check_capabilities.mjs → 'capabilities OK'; negative tests (drop wheel-panel / drop mutating perms) fail correctly. Rust test capabilities_cover_both_windows added to src/main.rs — CI runs it (cargo test --workspace); local cargo unavailable per hard constraint." + }, + { + "criterion": "When __TAURI__ is absent OR plugin IPC is ACL-denied, main window shows a persistent bilingual banner; wheel window shows a visible error card", + "verify_by": "python tests/e2e/no_tauri_e2e.py — no-mock and acl-denied cases", + "evidence": "13/13 PASS. probeIpcEnvironment() (store.js) distinguishes no-bridge vs acl-denied via a live event.listen probe; app.js boot fills #envBanner (persistent, role=alert); wheel.js init() fills #envErr card. i18n keys added zh-CN + en-US." + }, + { + "criterion": "CI fails if capabilities/ disappears or loses window coverage; release build fails if resolved capabilities are empty", + "verify_by": "review .github/workflows/ci.yml + release.yml; node scripts/check_capabilities.mjs exit codes", + "evidence": "ci.yml frontend job runs check_capabilities.mjs (validates dir exists, windows coverage, identifier existence vs acl-manifests.json, resolved allow-set); e2e job adds no_tauri_e2e.py. release.yml asserts gen/schemas/capabilities.json non-empty + covers both windows after tauri build." + } + ], + "at": "2026-10-07T04:30:31.806Z" + } + ], "created_at": "2026-10-07T04:17:40.992Z", - "updated_at": "2026-10-07T04:17:51.529Z" + "updated_at": "2026-10-07T04:30:31.807Z", + "completed_at": "2026-10-07T04:30:31.807Z" } ] }