Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .claude/skills/ship-safely/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions .claude/skills/ship-safely/scripts/check-tauri-acl.mjs
Original file line number Diff line number Diff line change
@@ -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(', ')}`)
Loading