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(', ')}`)