From f48194c04a5d2d7955bcfaff805cd8abd0294589 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 11:34:48 +0545 Subject: [PATCH] Add a ship-safely gate for the class of bug v2.1.0 shipped v2.1.0 went out unquittable. The capability file granted core:window:allow-close but not core:window:allow-destroy, close() runs through to destroy, the ACL refused it, and the rejected promise went nowhere. Nothing in the calling code points at the file that decides this, the denial is silent in a release build, and no one clicked the X before it shipped. scripts/check-tauri-acl.mjs resolves what the capabilities actually grant, following core:default down through the generated ACL manifest, and compares it against the core window calls the frontend makes, for every window label the backend creates. It reproduces both real failures: drop allow-destroy and it names it, narrow "windows" back to ["main"] and it flags that second windows are covered by nothing. The skill carries the rest: awaiting core calls instead of dropping the promise, two ways to exercise a path in a running build without taking over the desktop, counting what is left before removing the last way out of something, and leaving releases until I ask for one. --- .claude/skills/ship-safely/SKILL.md | 76 ++++++++++ .../ship-safely/scripts/check-tauri-acl.mjs | 133 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 .claude/skills/ship-safely/SKILL.md create mode 100644 .claude/skills/ship-safely/scripts/check-tauri-acl.mjs diff --git a/.claude/skills/ship-safely/SKILL.md b/.claude/skills/ship-safely/SKILL.md new file mode 100644 index 00000000..b7eb2d46 --- /dev/null +++ b/.claude/skills/ship-safely/SKILL.md @@ -0,0 +1,76 @@ +--- +name: ship-safely +description: Pre-release gate for Stroke that catches the class of bug that made v2.1.0 unquittable - Tauri ACL/capability gaps, silently swallowed IPC failures, and critical paths nobody exercised in a real build. Use before merging a `release:*` PR, after touching src-tauri/capabilities/*, window lifecycle or close/quit code in src-tauri/src/lib.rs, WindowControls.svelte or TitleBar.svelte, and whenever a change removes a handler, a tray, a menu item or a shortcut. +--- + +# Ship safely + +## Quick start + +```bash +node .claude/skills/ship-safely/scripts/check-tauri-acl.mjs . +``` + +Exits non-zero and names the gap. Needs `src-tauri/gen/schemas/acl-manifests.json`, +which a Tauri build writes, so run `npm run tauri dev` once on a fresh clone. + +## What went wrong in v2.1.0 + +The capability file granted `core:window:allow-close` but never +`core:window:allow-destroy`. That gap sat there harmlessly for months because +the tray build intercepted `CloseRequested` and hid the window, so `close()` +never ran through to `destroy`. Removing the tray let it through, the ACL +refused the call, and the rejected promise went nowhere. The window is +frameless with no tray, no File > Quit and no quit shortcut, so the app shipped +with no way to close it. + +Three separate failures lined up. The gate below covers each one. + +## 1. Permissions live in a file the code does not mention + +`src-tauri/capabilities/default.json` is the only thing standing between a +frontend `getCurrentWindow().x()` call and a denial, and nothing in the calling +code points at it. Run the checker after any change to window handling, and +read its two rules: + +- A JS call can require more than one permission. `close()` needs + **both** `allow-close` and `allow-destroy`. +- `"windows"` is a list of label globs. `open_new_window` creates `main-2` + onward, and a capability scoped to `["main"]` denies every core call from + those windows, including dragging the title bar. + +## 2. A denied core call is silent in release + +The rejected promise had no handler, and release builds have no devtools, so +nothing surfaced. Any call into Tauri from the UI gets awaited inside +`try/catch` with a `console.error`, the way `WindowControls.svelte` does it now. +Never leave `void somePromise()` on a path the user depends on. + +## 3. Nobody clicked the button + +Static checks would have caught this one, but not the next one. Exercise the +path you changed in a running build before merging. Both of these work without +taking over the desktop, which matters because I am usually working on it: + +- **Headless DOM.** With `npm run tauri dev` up, `chromium --headless=new + --dump-dom --virtual-time-budget=9000 http://localhost:1420/` renders the real + frontend. Grep the output for the state you expect. +- **HMR self-trigger.** Temporarily call the thing on mount, save, watch the dev + log, revert. Back up the file first and restore from the backup, never by hand. + +`pcvision` screenshots of a whole monitor catch whatever else is open. Target +the window, or use the two above. + +## 4. Do not remove the last way out + +Before deleting a tray, a menu item, a shortcut or a handler, count what is left +for that action. The tray removal was correct on its own and only became a +critical bug because it was the last fallback for quitting. If a change leaves +exactly one path to something the user cannot work without, say so in the PR. + +## Releasing + +Releases run four platform builds and the org is short on Actions minutes, so +**never merge a `release:*` PR unless I asked for a release in that message.** +Open the PR, say the release is waiting on me, and stop. See +`.changeset/README.md` for the changeset format. diff --git a/.claude/skills/ship-safely/scripts/check-tauri-acl.mjs b/.claude/skills/ship-safely/scripts/check-tauri-acl.mjs new file mode 100644 index 00000000..801fa327 --- /dev/null +++ b/.claude/skills/ship-safely/scripts/check-tauri-acl.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// Cross-references the Tauri core APIs the frontend calls against the +// permissions src-tauri/capabilities/*.json actually grants, for every window +// label the app creates. +// +// This exists because of v2.1.0: the capability file granted +// `core:window:allow-close` but not `core:window:allow-destroy`. `close()` runs +// through to `destroy`, the ACL refused it, the rejected promise went nowhere, +// and the app shipped unquittable. A denied core call is silent in a release +// build, so it has to be caught here. + +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { execSync } from 'node:child_process' + +const root = process.argv[2] ?? process.cwd() +const manifestPath = join(root, 'src-tauri/gen/schemas/acl-manifests.json') +const capsDir = join(root, 'src-tauri/capabilities') + +if (!existsSync(manifestPath)) { + console.error(`No ACL manifest at ${manifestPath}.\nRun a Tauri build once (npm run tauri dev) to generate it, then re-run.`) + process.exit(2) +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + +/** Expand one permission id into the `module:command` strings it allows. */ +function expand(module, id, seen = new Set()) { + const key = `${module}:${id}` + if (seen.has(key)) return [] + seen.add(key) + + // A fully-qualified id re-targets the module: "core:window:allow-close". + if (id.includes(':')) { + const parts = id.split(':') + const cmd = parts.pop() + return expand(parts.join(':'), cmd, seen) + } + + const mod = manifest[module] + if (!mod) return [] + + if (id === 'default') { + const set = mod.default_permission + if (!set) return [] + return (set.permissions ?? []).flatMap((p) => expand(module, p, seen)) + } + + const perm = mod.permissions?.[id] + if (perm) return (perm.commands?.allow ?? []).map((c) => `${module}:${c}`) + + const set = mod.permission_sets?.[id] + if (set) return (set.permissions ?? []).flatMap((p) => expand(module, p, seen)) + + return [] +} + +// ── what the capabilities grant, per window label glob ────────────────────── +const capabilities = readdirSync(capsDir) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(readFileSync(join(capsDir, f), 'utf8'))) + +const grants = capabilities.map((cap) => { + const allowed = new Set() + for (const p of cap.permissions ?? []) { + const id = typeof p === 'string' ? p : p.identifier + if (!id) continue + for (const c of expand('core', id)) allowed.add(c) + // Non-core plugins live under their own manifest key ("dialog", "opener"). + const head = id.split(':')[0] + if (head !== 'core' && manifest[head]) { + for (const c of expand(head, id.split(':').slice(1).join(':') || 'default')) allowed.add(c) + } + } + return { windows: cap.windows ?? ['main'], allowed, identifier: cap.identifier } +}) + +const labelAllows = (glob, label) => + new RegExp('^' + glob.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$').test(label) + +// ── what the frontend calls ───────────────────────────────────────────────── +// JS method -> core command(s). `close` carries `destroy`: Tauri's close runs +// through to destroy once nothing prevents it, and destroy is its own gate. +const IMPLIED = { close: ['close', 'destroy'] } +const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()) + +let hits = '' +try { + hits = execSync( + `grep -rhoE '(getCurrentWindow|getCurrentWebviewWindow|appWindow)\\(?\\)?\\.[a-zA-Z]+' ${join(root, 'src')} || true`, + { encoding: 'utf8' }, + ) +} catch { /* grep found nothing */ } + +const used = new Set() +for (const line of hits.split('\n')) { + const m = line.match(/\.([a-zA-Z]+)$/) + if (!m) continue + const js = m[1] + for (const cmd of IMPLIED[js] ?? [camelToSnake(js)]) used.add(cmd) +} + +// Window labels the backend creates, so second windows are checked too. +let labels = ['main'] +try { + const commands = readFileSync(join(root, 'src-tauri/src/commands.rs'), 'utf8') + const m = commands.match(/format!\("(main-\{[a-z]+\})"\)/) + if (m) labels.push('main-2') +} catch { /* no secondary windows */ } + +// ── report ────────────────────────────────────────────────────────────────── +const problems = [] +for (const label of labels) { + const forLabel = grants.filter((g) => g.windows.some((w) => labelAllows(w, label))) + if (forLabel.length === 0) { + problems.push(`window "${label}" is covered by no capability at all - every core call from it is denied`) + continue + } + const allowed = new Set(forLabel.flatMap((g) => [...g.allowed])) + for (const cmd of [...used].sort()) { + if (!allowed.has(`core:window:${cmd}`) && !allowed.has(`core:webview:${cmd}`)) { + problems.push(`window "${label}" calls ${cmd}() but no capability grants core:window:allow-${cmd.replace(/_/g, '-')}`) + } + } +} + +if (problems.length) { + console.error('Tauri ACL gaps:\n' + problems.map((p) => ` ✗ ${p}`).join('\n')) + console.error('\nAdd the permission to src-tauri/capabilities/default.json, or widen its "windows" globs.') + process.exit(1) +} + +console.log(`Tauri ACL ok - ${used.size} core window call(s) checked across ${labels.length} window label(s): ${labels.join(', ')}`)