Compare commits

..
7 Commits
Author SHA1 Message Date
jknappandClaude Opus 5.5 8305c96e20 Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s
Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:05:50 +00:00
jknapp 3537b234d8 Make links in Claude's output clickable (#59)
Build App / compute-version (push) Successful in 6s
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m58s
Build App / build-linux (push) Successful in 5m51s
Build App / sync-to-github (push) Successful in 8s
Build App / create-tag (push) Successful in 9s
Reviewed three times. Rounds 1 and 2 each found a real hole in the gate -- a plain click opened links, then a selection gesture did -- both addressed. The attacker-controlled mouse mode is recorded as a known residual rather than claimed closed.

Still unverified on a real desktop: double-click and drag-select across a link in both tracking states.
2026-09-19 03:20:15 +00:00
shadowdaoandClaude Opus 5 83c9c24951 test: give two synthesised clicks the detail a real click carries
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 9s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-linux (pull_request) Successful in 7m58s
Build App (Preview) / build-windows (pull_request) Successful in 4m54s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The previous commit tightened the gate's click-count check from `> 1` to
`!== 1`, which two tests in the wiring block did not survive: they built
`new MouseEvent("click", { button: 0 })` directly rather than through the
`click()` helper, so `detail` defaulted to 0 and the gate refused them.

The gate is right and the tests were wrong -- a mouseup derived from a real
click always carries `detail >= 1`, and 0 is exactly the synthetic-event
shape the tightening was for. Both now pass `detail: 1`.

I pushed the previous commit without noticing this, having read a truncated
test summary that hid the failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:08:36 -07:00
shadowdaoandClaude Opus 5 c6f9c1d43f fix: tighten the click-count check and stop three comments overstating
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 6m6s
Build App (Preview) / build-windows (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Third-round review polish; no behaviour change beyond the first item.

`detail > 1` was justified in a comment by noting a synthesised event
carries `detail` 0 -- which is an argument for letting untrusted synthetic
events through the click-count half of the gate. A mouseup derived from a
real click always carries `detail >= 1`, so the check is now `!== 1`.
Nothing in the container can dispatch a DOM event, so this is hardening
rather than a hole; the comment now says that instead of the reverse.

Three comments claimed more than they hold. The selection check's
paragraph read as though it caught every copy gesture: it sees a drag only
once the drag has spanned a cell, so a press and release inside one
character cell -- or a drag walked back to its start -- still opens the
link. That is the gap the rejected mousedown/mouseup distance check would
have closed, and it is now recorded beside the reason for rejecting it.

`?1002l` was described as taking effect synchronously with the write; it
takes effect when xterm parses it, on its queued write task. And
`modifierPromised` was described as written on every hover, when `hover()`
clears and returns early with no host element -- which leaves it false, the
stricter direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:07:59 -07:00
shadowdaoandClaude Opus 5 593b8168eb fix: a selection is not a request to leave the app
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-linux (pull_request) Successful in 6m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Re-review found the gate did not cover the gesture users actually make.
xterm's `Linkifier._handleMouseUp` has no click-count check, no distance
threshold and no timestamp, so it activates on the mouseup that *ends a
selection* as readily as on a click. Double-clicking a word or dragging
across a few characters inside an OSC 8 link therefore opened the browser.

Worse with a program holding the mouse: the only way to select text there
is Shift/Option+drag, which is byte-identical to the gesture the gate
accepted as a deliberate request to open. A container wrapping each output
row in a link would have harvested every legitimate copy.

`term.hasSelection()` is the load-bearing check: a drag is one press and
one release, so its click count is 1 and `detail` cannot see it. `detail >
1` is belt-and-braces for the case where the selection came out empty, and
for not depending on the selection model being written before the
Linkifier's listener runs -- it is, but the check costs nothing. Drag
distance was rejected rather than forgotten: xterm hands `activate` only
the mouseup, so measuring it means binding our own listener and keeping a
second source of truth about one gesture.

The hover card's promise is now sticky. The hint was computed once at hover
while the gate re-read the mode at mouseup, so a card reading "Shift+click
to open" could be on screen while a bare click opened the link. The gate
now requires the modifier if either the card asked for it or the live mode
does.

The same gate is applied to the WebLinksAddon branch, which had none. That
also closes a real bypass: `OscLinkProvider` drops non-http(s) OSC 8
targets before `linkHandler` sees them, so a `javascript:` target with an
`https://evil.tld` label fell through to WebLinks and opened ungated.

What is not closed, and is now recorded rather than papered over: the mouse
mode is a permission the container grants itself. It can drop tracking
before the pointer arrives and hold it off through the click. The selection
and click-count checks hold either way, so the mass-harvest variant is
gone, but the real fix needs a signal the container cannot write and this
pane does not have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:00:42 -07:00
shadowdaoandClaude Opus 5 ac50c38891 fix: gate OSC 8 link activation instead of merely hinting at it
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / build-linux (pull_request) Successful in 7m13s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Review of this branch found its central premise was false. The claim was
that xterm cancels a mousedown before the link layer while a program holds
the mouse, so only a Shift+click could reach a link. None of that holds:
`cancel()` is `if (this.options.cancelEvents || force)` and `cancelEvents`
defaults to false and is never set here, so it does nothing; the mouse
reporting listeners bind to `.xterm` while the Linkifier is constructed on
`screenElement`, a descendant, so the link layer sees the event first
regardless; and `_handleMouseUp` checks neither the modifier nor the
button before calling `activate`.

So a plain click opened the link, and so did a right-click. That is not a
missing convenience. OSC 8 lets the container wrap any clickable TUI widget
-- a menu row, a "1. Yes", a file chip -- in a link to anywhere, and
because the mouse report still reaches the program afterwards the widget
responds too and nothing looks wrong. The hover card was the only
mitigation, and it assumes a user deliberately reaching for a link.

`opensOnClick` is now a real gate: primary button only, and while a program
tracks the mouse the force-selection modifier is required -- the gesture
the user already has for "this click is for the terminal, not the program".
With nothing tracking, a bare click opens, which is what WebLinksAddon
already does for plain-text URLs in the same buffer. The mode is read per
click through a getter rather than captured, and `syncMouseCapture` and the
gate share one expression, because a gate that disagreed with the badge
would be the hole again.

The gate and the hint also share one modifier predicate, and the hint is
conditional on tracking, so it can never name a key that does nothing.

Three more from the same review. The origin span had `flexShrink: 0`, which
beats `overflowWrap` under flexbox, so an attacker-controlled 600-character
origin ran off the pane and hid the registrable domain -- the same spoof as
an ellipsis, without one; it now wraps and the remainder is what gives way.
The card had no `pointerEvents: none`, and `xterm-hover` is inert at this
placement, so a card under the pointer took `mouseleave` from screenElement
and made bottom-row links flicker and refuse to activate at all. And the
design doc comment had come adrift from its function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:45:11 -07:00
shadowdaoandClaude Opus 5 f311ca1990 feat: make links in Claude's output clickable
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m47s
Build App (Preview) / build-linux (pull_request) Successful in 8m8s
Build App (Preview) / build-windows (pull_request) Failing after 13m30s
Build App (Preview) / prune-previews (pull_request) Skipped
Claude Code prints links as OSC 8 hyperlinks whose visible text is
hard-wrapped into terminal-width pieces -- urlDetector's header records a
346-character sign-in URL arriving as five emissions, each carrying the
whole URL in its parameter and about 80 characters on screen. WebLinksAddon
regex-matches the painted characters row by row, so against Claude it
matches a fragment or nothing, which is why the URL toast exists.

xterm 5.5 hands over the exact parameter through `linkHandler`, so the
slicing stops mattering. WebLinksAddon stays for plain-text URLs in
ordinary shell output; the two cover different cases and neither replaces
the other. Both now share one failure reporter and one validator.

No new key handling was needed. xterm's mousedown handler is
`if (areMouseEventsActive && !shouldForceSelection(e)) return cancel(e)`,
so holding the force-selection modifier lets the event reach the link
layer while Claude still holds the mouse -- Shift+click, or Option+click on
macOS, which this terminal already enables for text selection.

The hover card is the security half rather than decoration. OSC 8
decouples the label from the target completely: a container can print
`https://claude.ai` and link it anywhere, which is strictly worse than the
userinfo spoofing already guarded against and which invalidated the
justification for opening a click without confirmation ("a deliberate act
on visible text"). Hovering now shows the real origin, in full and never
truncated, because truncating it is the spoof. A target that fails
validation says so and deliberately echoes nothing of itself.

The hint names the modifier for the platform, from xterm's own `isMac`
list, so it cannot tell a Mac user to press a key that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:23:18 -07:00
65 changed files with 16212 additions and 85 deletions
+3
View File
@@ -1,6 +1,9 @@
node_modules/ node_modules/
app/dist/ app/dist/
app/src-tauri/target/ app/src-tauri/target/
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
# tracked, reviewable form of the same information.
app/src-tauri/permissions/autogenerated/
Screenshot*.png Screenshot*.png
code-review.md code-review.md
+47 -3
View File
@@ -73,6 +73,16 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`) - **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models - **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow - **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html`
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
style nonce and CodeMirror's injected styles would stop applying. A missing or broken
`viewer.html` Vite entry is not caught by Tauri at build time — both Vite dev and Tauri's asset
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
in `file_viewer/mod.rs` is the only thing pinning this.
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar - **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`, - **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home and the editors reused by Project Home
@@ -161,6 +171,19 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`, container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`. `auth_token_commands.rs`.
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
user (spec §5 says why the archive API never writes to the target directory). Commands take
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
viewer command accepts a path. Which window may *call* each command is the ACL's job: the
`file-viewer-*` capability grants exactly the five `viewer_*` commands (see `build.rs`).
- **`build.rs` + `src/command_census.rs`** — the build declares a Tauri `AppManifest` from the
`generate_handler!` list and refuses to build unless every command has exactly one bare
`allow-*` grant in the capability file its name says it belongs to. The parser and rules are
in `command_census.rs`, compiled into both the build script and the test build, so they are
unit-tested; `the_generated_app_manifest_matches_the_handler_list` reads back what tauri
embedded. Design: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can - **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
@@ -578,9 +601,28 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/` - Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])` - Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`, - **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not** `lib.rs`, and a bare `allow-<name-with-dashes>` entry in the one capability file for the
need an entry there — adding one is not required and none exists for any app command. window that calls it — `viewer_*` commands in `capabilities/file-viewer.json`, everything else
in `capabilities/default.json`. `build.rs` declares a Tauri `AppManifest` from the handler list
(without one, tauri 2.11 does not apply the ACL to app commands at all) and fails `cargo
check`/`tauri build` on a missing, misspelled, duplicated or misfiled grant, a `deny-*`, or a
hand-written file under `permissions/`. `src/test/capabilities.test.ts` fails if code that runs
in a window imports a `tauri-commands.ts` wrapper that window is not granted. Only `_` becomes
`-` in the identifier; `permissions/autogenerated/` is generated and ignored, and
`gen/schemas/*.json` is regenerated by every build and committed.
- **A new window needs its own top-level `capabilities/*.json`; never `webviews`/`remote`;
never inline.** `build.rs` only vouches for what `src/command_census.rs` reads — a top-level
`capabilities/*.json` file with a `windows` list — so it refuses to build on anything tauri
would load that the census can't check: a capability under a subdirectory or written as
`.toml`/`.json5`, a `webviews` or `remote` key in a capability file (either widens grants past
what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`/any
`tauri.<platform>.conf.json`/`TAURI_CONFIG`, or a tauri config in a format it can't parse
(JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, swap files) is recognised and skipped
rather than refused. Each failure names the check that failed, not just "capabilities do not
match generate_handler!". **Known limit:** adding a new `tauri.<platform>.conf.json` to a tree
that has already been built once only takes effect on a clean build or in CI — cargo's
incremental build has no reason to notice a file that did not exist on the previous build.
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`. - The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely - **Adding project state that changes the container?** `container_needs_recreation()` is entirely
**label-based** — it does not diff the container's env. If a new setting affects the container's **label-based** — it does not diff the container's env. If a new setting affects the container's
@@ -601,6 +643,8 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in `#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true. `models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows - Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
## Secrets ## Secrets
+348
View File
@@ -8,6 +8,21 @@
"name": "triple-c", "name": "triple-c",
"version": "0.4.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
@@ -413,6 +428,204 @@
"specificity": "bin/cli.js" "specificity": "bin/cli.js"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
"integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.13",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1055,6 +1268,123 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz",
"integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz",
"integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2523,6 +2853,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -3599,6 +3935,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -3925,6 +4267,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+15
View File
@@ -13,6 +13,21 @@
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\"" "hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
+2
View File
@@ -51,6 +51,8 @@ tokio = { version = "1", features = ["full", "test-util"] }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
serde_json = "1"
[features] [features]
default = ["custom-protocol"] default = ["custom-protocol"]
+207 -2
View File
@@ -1,3 +1,208 @@
fn main() { //! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
tauri_build::build() //! to build unless every registered command is granted in exactly one capability file — the
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
//! could call any command.
//!
//! Because the census can only vouch for what it reads, the build also stops on any capability
//! tauri would load that the census does not: anything in `capabilities/` other than a
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
//!
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
#[path = "src/command_census.rs"]
mod command_census;
use std::path::Path;
/// Stops the build. `what` names the check that failed, so a malformed capability file, a
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
eprintln!();
eprintln!(
"{what} ({} problem{}):",
problems.len(),
if problems.len() == 1 { "" } else { "s" }
);
for p in problems {
eprintln!(" - {p}");
}
eprintln!();
eprintln!("{hint}");
eprintln!();
std::process::exit(1);
}
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
src/command_census.rs can only vouch for what it reads.";
fn file_name(path: &Path) -> String {
path.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned()
}
fn main() {
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/command_census.rs");
let lib_rs = std::fs::read_to_string("src/lib.rs")
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
let Some(commands) = command_census::registered_commands(&lib_rs) else {
fail(
"missing generate_handler! block",
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
.to_string()],
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
);
};
check_tauri_config();
let files = read_capabilities();
let problems = command_census::check(&commands, &files);
if !problems.is_empty() {
fail(
"capabilities do not match generate_handler!",
&problems,
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
capabilities/default.json. See src/command_census.rs.",
);
}
prune_permissions(&commands);
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
// there is no owned form; leaking is fine in a process that exits right after.
let leaked: Vec<&'static str> = commands
.into_iter()
.map(|c| &*Box::leak(c.into_boxed_str()))
.collect();
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
let attributes = tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
if let Err(error) = tauri_build::try_build(attributes) {
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
println!("{error:#}");
std::process::exit(1);
}
}
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
/// capability could still reference (and the build would pass). Delete only the stale files:
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
/// loads and which is skipped (see `command_census::is_os_junk`).
fn prune_permissions(commands: &[String]) {
let root = Path::new("permissions");
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries {
let path = entry.expect("readable entry in permissions/").path();
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
continue;
}
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
let file = file.expect("readable entry").path();
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let live = file.extension().is_some_and(|e| e == "toml")
&& commands.iter().any(|c| c == stem);
if !live {
std::fs::remove_file(&file)
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
}
}
} else {
fail(
"hand-written permission",
&[format!(
"{} is not generated by build.rs; hand-written permissions are not allowed \
(every grant is a bare allow-* string in a capability file)",
path.display()
)],
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
an app command is granted by listing allow-<command> in a capability file.",
);
}
}
}
/// Every capability tauri will load, read the way the census reads it — or the build stops.
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
let mut files = Vec::new();
let mut stray = Vec::new();
let mut invalid = Vec::new();
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
let path = entry.expect("readable entry in capabilities/").path();
let name = file_name(&path);
let is_file = path.is_file();
if is_file && command_census::is_os_junk(&name) {
continue;
}
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
stray.push(problem);
continue;
}
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
match command_census::capability_file(&name, &json) {
Ok(file) => files.push(file),
Err(problem) => invalid.push(problem),
}
}
stray.sort();
if !stray.is_empty() {
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
}
invalid.sort();
if !invalid.is_empty() {
fail("invalid capability file", &invalid, LAYOUT_HINT);
}
files.sort_by(|a, b| a.name.cmp(&b.name));
files
}
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
fn check_tauri_config() {
let mut problems = Vec::new();
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = file_name(&path);
match command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
problems.sort();
if !problems.is_empty() {
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
}
} }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
{
"identifier": "file-viewer",
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
"windows": ["file-viewer-*"],
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-destroy",
"core:webview:allow-internal-toggle-devtools",
"allow-viewer-get-state",
"allow-viewer-choose-file",
"allow-viewer-read-file",
"allow-viewer-poll-file",
"allow-viewer-write-file"
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
//! The command census shared by `build.rs` and the `cargo test` suite.
//!
//! `build.rs` pulls this file in with `#[path = "src/command_census.rs"]` and `lib.rs` with
//! `#[cfg(test)] mod command_census;`, so the parser that decides what the Tauri `AppManifest`
//! declares is the parser the tests exercise, and the rules that decide whether the build
//! passes have unit tests. Nothing here may reference the crate: only `std` and `serde_json`
//! (a dependency of both the crate and the build script).
//!
//! Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` §3.2.
use std::collections::{BTreeMap, BTreeSet};
/// The command names inside `generate_handler![ … ])` in `lib.rs`, in registration order,
/// duplicates kept (the caller decides whether that is an error). `None` if the block is
/// missing or unterminated.
///
/// Comma-split, not line-split: `// Docker` style comments are stripped from every line first
/// (a whole-line comment strips to nothing; a trailing one leaves the code before it), and the
/// *cleaned* text is then split on `,` so each grant is its own item regardless of how many
/// share a line. A line-split version of this parser shipped first and used
/// `rsplit("::").next()` once *per line*: two commands on one line (`a::x, b::y,`) collapsed to
/// a single item, silently dropping `a::x` — a denied command at runtime with nothing flagging
/// it. Comma-splitting fixes that because it no longer assumes one item per line.
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>> {
let (_, rest) = lib_rs.split_once("generate_handler![")?;
let (inside, _) = rest.split_once("])")?;
let cleaned: String = inside
.lines()
// Strip a trailing `//` comment (and a whole-line one, which strips to "").
.map(|l| l.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
Some(
cleaned
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
// `a::b::name` → `name`; a bare `name` (no `::`) is its own last segment.
s.rsplit("::").next().map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect(),
)
}
/// `viewer_read_file` → `allow-viewer-read-file`. tauri-utils 2.9.0 (`acl/build.rs:290`)
/// replaces only `_`; permission identifiers may not contain `_`, but the command name inside
/// the generated permission stays snake_case.
pub fn allow_permission(command: &str) -> String {
format!("allow-{}", command.replace('_', "-"))
}
/// The `windows` list of the one capability file that may grant `command`. A command that
/// must be callable from both windows is a design change: make it here, visibly, rather than
/// by widening a capability file.
pub fn expected_windows(command: &str) -> &'static [&'static str] {
if command.starts_with("viewer_") {
&["file-viewer-*"]
} else {
&["main"]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityFile {
pub name: String,
pub windows: Vec<String>,
pub bare: Vec<String>,
}
/// One `capabilities/*.json`, reduced to what the census checks. Plugin and core grants
/// (anything with a `:`) are not this module's business; the exact-set tests in `lib.rs` and
/// `file_viewer/mod.rs` pin those.
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?;
// `webviews` would extend the grants to webviews by label (the browser-view pop-out is
// meant to be in no capability), and `remote` would extend them to a remote origin. The
// census reasons about `windows` only, so either key is refused rather than half-checked.
for key in ["webviews", "remote"] {
if value.get(key).is_some() {
return Err(format!(
"{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only"
));
}
}
let windows = value["windows"]
.as_array()
.ok_or_else(|| format!("{name}: `windows` must be an array"))?
.iter()
.map(|w| {
w.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{name}: `windows` entries must be strings"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut bare = Vec::new();
for grant in value["permissions"]
.as_array()
.ok_or_else(|| format!("{name}: `permissions` must be an array"))?
{
let id = match grant {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Object(o) => o
.get("identifier")
.and_then(|i| i.as_str())
.ok_or_else(|| format!("{name}: a scoped grant needs a string `identifier`"))?,
_ => return Err(format!("{name}: a grant is a string or an object")),
};
if !id.contains(':') {
bare.push(id.to_string());
}
}
Ok(CapabilityFile { name: name.to_string(), windows, bare })
}
/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or
/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with
/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the
/// census reads only top-level JSON, so anything else tauri might load is refused rather than
/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the
/// caller's to skip first (see [`is_os_junk`]).
pub fn stray_capability_entry(name: &str, is_file: bool) -> Option<String> {
if !is_file {
return Some(format!(
"capabilities/{name} is not a regular file; tauri loads capabilities from \
subdirectories too, so every capability must be a top-level capabilities/*.json"
));
}
if name.ends_with(".json") {
return None;
}
Some(format!(
"capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \
too) but the census cannot check it, so every capability must be a top-level \
capabilities/*.json"
))
}
/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`,
/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from
/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs
/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it,
/// and the caller must refuse it.
pub fn is_os_junk(name: &str) -> bool {
let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e));
!loadable
&& (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini")
|| name.ends_with(".swp")
|| name.ends_with(".swo")
|| name.ends_with('~'))
}
/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`,
/// `Tauri.toml` and the per-platform `tauri.<platform>.conf.json[5]` / `Tauri.<platform>.toml`
/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a
/// format it cannot (JSON5/TOML), `None` = not a tauri config file.
pub fn tauri_config_file(name: &str) -> Option<bool> {
if name.starts_with("tauri.") && name.ends_with(".conf.json") {
Some(true)
} else if (name.starts_with("tauri.") && name.ends_with(".conf.json5"))
|| (name.starts_with("Tauri.") && name.ends_with(".toml"))
{
Some(false)
} else {
None
}
}
/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that
/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it
/// is non-empty: an inline object is a capability the census never sees, and a list of
/// identifiers switches every *other* capability file off, which the census also assumes is
/// not happening.
pub fn tauri_config_problem(name: &str, json: &str) -> Option<String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return Some(format!("{name}: not valid JSON: {e}")),
};
match value.pointer("/app/security/capabilities") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Array(a)) if a.is_empty() => None,
Some(_) => Some(format!(
"{name}: app.security.capabilities is not allowed; every capability lives in a \
top-level capabilities/*.json file, where the census checks it"
)),
}
}
/// Everything that must hold between the handler list and the capability files. Returns every
/// violation rather than the first, so a batch of forgotten grants is one build failure; an
/// empty vector is a pass.
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String> {
let mut problems = Vec::new();
if commands.is_empty() {
problems.push(
"no commands were parsed out of generate_handler! — an empty AppManifest would \
silently leave every app command ungated"
.to_string(),
);
return problems;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for c in commands {
if !c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
problems.push(format!("{c:?} is not a command name ([a-z0-9_]+)"));
}
if !seen.insert(c.as_str()) {
problems.push(format!("{c} is registered more than once"));
}
}
let known: BTreeMap<String, &str> =
seen.iter().map(|c| (allow_permission(c), *c)).collect();
for f in files {
let windows: Vec<&str> = f.windows.iter().map(String::as_str).collect();
for id in &f.bare {
match known.get(id) {
Some(command) => {
let want = expected_windows(command);
if windows.as_slice() != want {
problems.push(format!(
"{}: {id} must be granted in the capability file whose windows are \
{want:?}, not {windows:?}",
f.name
));
}
}
None if id.starts_with("deny-") => problems.push(format!(
"{}: {id}: deny-* is global in tauri 2.11 — it would deny the command for \
every window, not just this one; use allow-lists only",
f.name
)),
None if id.starts_with("allow-") => problems.push(format!(
"{}: {id} names no registered command (the identifier is allow-<command> \
with every `_` replaced by `-`)",
f.name
)),
None => problems.push(format!(
"{}: {id}: only allow-<command> app grants are permitted as bare identifiers",
f.name
)),
}
}
}
for c in &seen {
let id = allow_permission(c);
let holders: Vec<&str> = files
.iter()
.filter(|f| f.bare.iter().any(|b| b == &id))
.map(|f| f.name.as_str())
.collect();
match holders.len() {
0 => problems.push(format!(
"{c} is registered but no capability file grants {id}; add it to the file \
whose windows are {:?}",
expected_windows(c)
)),
1 => {}
_ => problems.push(format!(
"{id} is granted in more than one capability file: {holders:?}"
)),
}
}
problems
}
#[cfg(test)]
mod tests {
use super::*;
fn cmds(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
fn file(name: &str, windows: &[&str], bare: &[&str]) -> CapabilityFile {
CapabilityFile {
name: name.to_string(),
windows: windows.iter().map(|w| w.to_string()).collect(),
bare: bare.iter().map(|b| b.to_string()).collect(),
}
}
/// The two files as they must look after the lockdown, for a three-command app.
fn good_files() -> Vec<CapabilityFile> {
vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
]
}
const THREE: &[&str] = &["check_docker", "open_file_viewer", "viewer_read_file"];
#[test]
fn the_parser_reads_the_handler_list_in_order_and_ignores_comments() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image, // trailing comment is not a command
url_open::open_url_external,
// Viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&["check_docker", "build_image", "open_url_external", "viewer_read_file"])
);
}
#[test]
fn the_parser_keeps_duplicates_so_the_caller_can_report_them() {
let lib_rs = "generate_handler![\n a::x,\n b::x,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "x"]));
}
#[test]
fn the_parser_returns_none_without_a_handler_block() {
assert_eq!(registered_commands("fn main() {}"), None);
assert_eq!(registered_commands("generate_handler![ a::b, "), None, "unterminated");
}
/// The bug this regression-tests: a line-split parser applies `rsplit("::").next()` once
/// per *line*, so two commands sharing a line collapse into one item and the first is
/// silently dropped. Comma-splitting must keep both regardless of layout.
#[test]
fn two_commands_on_one_line_are_both_kept() {
let lib_rs = "generate_handler![\n a::x, b::y,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "y"]));
}
/// Mirrors the real `lib.rs` handler list's shape: `// Section` comments between groups,
/// and command paths one (`open_url_external`), two (`url_open::open_url_external`) and
/// three (`commands::docker_commands::check_docker`) segments deep, all ending in a comma
/// except the last entry before `])`.
#[test]
fn a_fixture_shaped_like_the_real_handler_list_parses_every_command() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image,
// Opening a link in the host browser
url_open::open_url_external,
// Bare, module-less command
open_help,
// Terminal file viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&[
"check_docker",
"build_image",
"open_url_external",
"open_help",
"viewer_read_file",
])
);
}
#[test]
fn permission_identifiers_replace_only_underscores() {
assert_eq!(allow_permission("check_docker"), "allow-check-docker");
assert_eq!(allow_permission("viewer_read_file"), "allow-viewer-read-file");
assert_eq!(allow_permission("aws_sso_refresh"), "allow-aws-sso-refresh");
}
#[test]
fn viewer_commands_belong_to_the_viewer_windows_and_nothing_else_does() {
assert_eq!(expected_windows("viewer_read_file"), ["file-viewer-*"]);
assert_eq!(expected_windows("open_file_viewer"), ["main"]);
assert_eq!(expected_windows("check_docker"), ["main"]);
}
#[test]
fn a_capability_file_yields_its_windows_and_bare_grants_only() {
let json = r#"{
"identifier": "default",
"description": "x",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
{ "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/*" }] },
"allow-check-docker",
{ "identifier": "allow-list-projects" }
]
}"#;
let parsed = capability_file("default.json", json).unwrap();
assert_eq!(parsed.name, "default.json");
assert_eq!(parsed.windows, vec!["main"]);
assert_eq!(parsed.bare, vec!["allow-check-docker", "allow-list-projects"]);
}
#[test]
fn a_capability_file_without_windows_or_permissions_is_an_error() {
assert!(capability_file("x.json", r#"{"permissions": []}"#).unwrap_err().contains("windows"));
assert!(capability_file("x.json", r#"{"windows": ["main"]}"#).unwrap_err().contains("permissions"));
assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json"));
}
#[test]
fn webviews_and_remote_keys_are_refused() {
let with = |extra: &str| {
format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#)
};
let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err();
assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}");
let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err();
assert!(err.contains("`remote`"), "{err}");
// Present-but-empty is still refused: the key itself is the widening surface.
assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err());
}
#[test]
fn only_top_level_json_files_are_capabilities() {
assert_eq!(stray_capability_entry("default.json", true), None);
for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] {
let err = stray_capability_entry(name, true).expect(name);
assert!(err.contains(name) && err.contains("not a .json file"), "{err}");
}
let err = stray_capability_entry("sub", false).unwrap();
assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}");
// A directory named like a capability is still a directory.
assert!(stray_capability_entry("x.json", false).is_some());
}
#[test]
fn os_junk_is_recognised_but_never_something_tauri_would_load() {
for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] {
assert!(is_os_junk(junk), "{junk}");
}
for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] {
assert!(!is_os_junk(real), "{real}");
}
}
#[test]
fn tauri_config_files_are_found_by_name_and_format() {
assert_eq!(tauri_config_file("tauri.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false));
assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false));
assert_eq!(tauri_config_file("Tauri.toml"), Some(false));
assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false));
assert_eq!(tauri_config_file("Cargo.toml"), None);
assert_eq!(tauri_config_file("build.rs"), None);
}
#[test]
fn inline_capabilities_in_the_tauri_config_are_refused() {
let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#;
assert_eq!(tauri_config_problem("tauri.conf.json", ok), None);
assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None);
assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None);
let inline = r#"{"app": {"security": {"capabilities": [
{"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]}
]}}}"#;
let err = tauri_config_problem("tauri.conf.json", inline).unwrap();
assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}");
let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#;
assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG"));
assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON"));
}
#[test]
fn a_correct_census_has_no_problems() {
assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new());
}
#[test]
fn an_empty_command_list_is_refused_because_it_would_disable_the_acl() {
let problems = check(&[], &good_files());
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("no commands"), "{problems:?}");
}
#[test]
fn a_command_without_a_grant_is_named_together_with_the_file_it_belongs_in() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("open_file_viewer"));
assert!(problems[0].contains("allow-open-file-viewer"));
assert!(problems[0].contains("[\"main\"]"));
}
#[test]
fn a_grant_in_two_files_is_reported_once_naming_both() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("extra.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-check-docker"));
assert!(problems[0].contains("default.json") && problems[0].contains("extra.json"));
}
#[test]
fn a_grant_that_names_no_command_is_a_typo() {
let mut files = good_files();
files[0].bare.push("allow-check-dokcer".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: allow-check-dokcer"));
assert!(problems[0].contains("no registered command"));
}
#[test]
fn deny_grants_are_refused_with_the_reason() {
let mut files = good_files();
files[1].bare.push("deny-check-docker".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("file-viewer.json: deny-check-docker"));
assert!(problems[0].contains("global"));
}
#[test]
fn other_bare_identifiers_are_refused() {
let mut files = good_files();
files[0].bare.push("default".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: default"));
}
#[test]
fn a_grant_in_the_wrong_file_is_refused_even_though_it_is_granted_exactly_once() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer", "allow-viewer-read-file"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-viewer-read-file"));
assert!(problems[0].contains("[\"file-viewer-*\"]"));
}
#[test]
fn a_widened_windows_list_is_the_wrong_file_too() {
let files = vec![
file("default.json", &["main", "file-viewer-*"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 2, "{problems:?}");
}
#[test]
fn bad_names_and_duplicate_registrations_are_refused() {
let commands = cmds(&["check_docker", "Check-Docker", "check_docker", "open_file_viewer", "viewer_read_file"]);
let problems = check(&commands, &good_files());
assert!(problems.iter().any(|p| p.contains("\"Check-Docker\"") && p.contains("[a-z0-9_]+")), "{problems:?}");
assert!(problems.iter().any(|p| p.contains("check_docker is registered more than once")), "{problems:?}");
}
#[test]
fn every_problem_is_reported_in_one_pass() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-nope", "deny-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
// typo, deny, open_file_viewer missing, viewer_read_file missing
assert_eq!(problems.len(), 4, "{problems:?}");
}
}
+19 -14
View File
@@ -46,7 +46,7 @@ pub struct FileContents {
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar /// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not /// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// something to take on trust. /// something to take on trust.
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024; pub(crate) const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
#[tauri::command] #[tauri::command]
pub async fn list_container_files( pub async fn list_container_files(
@@ -352,7 +352,7 @@ const CONTAINER_WRITE_ROOTS: &[&str] = &["/workspace", "/home/claude", "/tmp"];
/// ///
/// `what` names the parameter in the error, because these messages are shown to /// `what` names the parameter in the error, because these messages are shown to
/// a user who is looking at a folder, not at argv. /// a user who is looking at a folder, not at argv.
fn validate_container_path(what: &str, path: &str) -> Result<(), String> { pub(crate) fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
if path.is_empty() { if path.is_empty() {
return Err(format!("{} path cannot be empty", what)); return Err(format!("{} path cannot be empty", what));
} }
@@ -394,7 +394,7 @@ fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
/// directly. What it buys is that the *panel* keeps its promise — the roots /// directly. What it buys is that the *panel* keeps its promise — the roots
/// named in the refusal are the roots it writes to — and that a mis-aimed drop /// named in the refusal are the roots it writes to — and that a mis-aimed drop
/// cannot quietly land outside them. /// cannot quietly land outside them.
fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> { pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
validate_container_path(what, path)?; validate_container_path(what, path)?;
if CONTAINER_WRITE_ROOTS if CONTAINER_WRITE_ROOTS
.iter() .iter()
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
} }
/// One regular file's bytes, pulled out of a container. /// One regular file's bytes, pulled out of a container.
struct FetchedFile { pub(crate) struct FetchedFile {
bytes: Vec<u8>, pub(crate) bytes: Vec<u8>,
/// The size the tar header declared, i.e. the file's real size — which is /// The size the tar header declared, i.e. the file's real size — which is
/// not `bytes.len()` once `max_bytes` has cut the read short. /// not `bytes.len()` once `max_bytes` has cut the read short.
size: u64, pub(crate) size: u64,
truncated: bool, pub(crate) truncated: bool,
} }
/// Fetch a single regular file from a container as exact bytes. /// Fetch a single regular file from a container as exact bytes.
@@ -1202,7 +1202,7 @@ struct FetchedFile {
/// file — or the whole *directory tree*, since the type check happens after the /// file — or the whole *directory tree*, since the type check happens after the
/// read — landed in host RAM twice. This function buffers, so every caller of /// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling. /// it must name a ceiling.
async fn fetch_container_file( pub(crate) async fn fetch_container_file(
container_id: &str, container_id: &str,
container_path: &str, container_path: &str,
max_bytes: u64, max_bytes: u64,
@@ -1448,6 +1448,14 @@ pub async fn create_container_directory(
Ok(dest) Ok(dest)
} }
/// Every "container is not running" refusal starts with this, so a caller (the file
/// viewer's poll, `app/src/viewer/ipcMessages.ts`) can tell it apart from any other failure.
pub(crate) const NOT_RUNNING_PREFIX: &str = "Start the project before";
pub(crate) fn not_running_message(action: &str, why: &str) -> String {
format!("{} {}{}.", NOT_RUNNING_PREFIX, action, why)
}
/// Refuse, in a sentence, before a Docker error has to speak for us. /// Refuse, in a sentence, before a Docker error has to speak for us.
/// ///
/// Both file transfers and the backup run through `docker exec`, which needs a /// Both file transfers and the backup run through `docker exec`, which needs a
@@ -1456,7 +1464,7 @@ pub async fn create_container_directory(
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath` /// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside /// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem. /// path *validation* and reads like the path was the problem.
async fn require_running(container_id: &str, action: &str) -> Result<(), String> { pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
let docker = get_docker()?; let docker = get_docker()?;
let running = docker let running = docker
.inspect_container(container_id, None) .inspect_container(container_id, None)
@@ -1468,10 +1476,7 @@ async fn require_running(container_id: &str, action: &str) -> Result<(), String>
if running { if running {
return Ok(()); return Ok(());
} }
Err(format!( Err(not_running_message(action, "it runs inside the running container"))
"Start the project before {} — it runs inside the running container.",
action
))
} }
/// Copy one regular file out of a container onto a host path the user chose in /// Copy one regular file out of a container onto a host path the user chose in
@@ -2011,7 +2016,7 @@ async fn upload_one(
/// call site for why each of those three matters; the short version is that /// call site for why each of those three matters; the short version is that
/// this text ends up inside a toast that renders above every modal, and its /// this text ends up inside a toast that renders above every modal, and its
/// author is the container. /// author is the container.
fn clip_container_text(text: &str) -> String { pub(crate) fn clip_container_text(text: &str) -> String {
const MAX: usize = 200; const MAX: usize = 200;
let flattened: String = text let flattened: String = text
.trim() .trim()
@@ -0,0 +1,365 @@
//! IPC for the terminal file viewer. Every command here is gated on the calling
//! window's label and reads its target from the registry — no path, no label, no
//! project id crosses IPC from a viewer window. See spec §6.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use crate::commands::file_commands::{
fetch_container_file, not_running_message, require_running, validate_container_write_path, MAX_READ_BYTES,
};
use crate::file_viewer::is_viewer_label;
use crate::file_viewer::poll::{poll_file, ViewerPoll};
use crate::file_viewer::registry::{
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
};
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
use crate::file_viewer::window::open_viewer_window;
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
use crate::models::Project;
use crate::AppState;
pub const GOTO_EVENT: &str = "file-viewer-goto";
#[derive(Clone, Debug, Serialize)]
pub struct ViewerState {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
#[derive(Clone, Debug, Serialize)]
pub struct ViewerFile {
pub contents_base64: String,
pub truncated: bool,
pub size: u64,
pub hash: String,
pub editable: bool,
pub readonly_reason: Option<String>,
}
fn require_main(window_label: &str) -> Result<(), String> {
if window_label == "main" {
Ok(())
} else {
Err("Only the main window can open files.".into())
}
}
fn require_viewer(window_label: &str) -> Result<String, String> {
if is_viewer_label(window_label) {
Ok(window_label.to_string())
} else {
Err("This command belongs to a file window.".into())
}
}
fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState {
ViewerState {
project_id: target.project_id,
project_name: target.project_name,
raw_path: target.raw_path,
state: target.state,
initial: target.initial,
}
}
fn window_title(raw_path: &str, project_name: &str) -> String {
let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path);
format!("{}{}", base, project_name)
}
/// Refuses a save payload before decoding it: base64 of at most
/// [`MAX_WRITE_BYTES`] is at most `4 * ceil(MAX_WRITE_BYTES / 3)` characters.
/// `write_file` enforces the cap on the decoded bytes too; this stops a
/// compromised viewer from making the app allocate and decode an arbitrarily
/// large string first.
fn check_encoded_len(encoded_len: usize) -> Result<(), String> {
if encoded_len > MAX_WRITE_BYTES.div_ceil(3) * 4 {
return Err("Files over 1 MiB are read-only in the viewer.".into());
}
Ok(())
}
/// The caller's registry entry, or a sentence.
fn own_target(
window: &tauri::Window,
registry: &ViewerRegistry,
) -> Result<(String, ViewerTarget), String> {
let label = require_viewer(window.label())?;
let target = registry
.get(&label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
Ok((label, target))
}
fn resolved_path(target: &ViewerTarget) -> Result<String, String> {
match &target.state {
ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()),
_ => Err("Choose a file first.".into()),
}
}
/// The one place a viewer command looks up its project (P14).
fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
state
.projects_store
.get(project_id)
.ok_or_else(|| "This project no longer exists.".to_string())
}
/// `action` completes "Start the project before …", e.g. "saving this file".
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
let container_id = project
.container_id
.clone()
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
require_running(&container_id, action).await?;
Ok(container_id)
}
/// The container of the project a viewer window belongs to, if it is running.
async fn running_container_for(
state: &AppState,
target: &ViewerTarget,
action: &str,
) -> Result<String, String> {
running_container_of(&project_of(state, &target.project_id)?, action).await
}
/// Raises an existing viewer window and moves it to `location`.
fn focus_viewer(app: &AppHandle, label: &str, location: Location) {
if let Some(existing) = app.get_webview_window(label) {
let _ = existing.unminimize();
let _ = existing.set_focus();
let _ = app.emit_to(label, GOTO_EVENT, location);
}
}
// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn open_file_viewer(
project_id: String,
path: String,
line: Option<u32>,
col: Option<u32>,
end_line: Option<u32>,
window: tauri::Window,
app: AppHandle,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<(), String> {
require_main(window.label())?;
let project = project_of(&state, &project_id)?;
let container_id = running_container_of(&project, "opening files").await?;
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
let candidates = candidate_paths(&path, &mounts)?;
let matches = probe_candidates(&container_id, &candidates).await?;
let initial = Location { line, col, end_line };
let target_state = match matches.len() {
0 => ViewerTargetState::NotFound { tried: candidates },
1 => ViewerTargetState::Resolved { container_path: matches[0].clone() },
_ => ViewerTargetState::Choose { candidates: matches },
};
let title = window_title(&path, &project.name);
let target = ViewerTarget {
project_id,
project_name: project.name.clone(),
raw_path: path,
state: target_state,
initial: initial.clone(),
};
// Dedupe, stale pruning and the cap are one registry call, so a second click
// while the first window is still being built finds it rather than reading
// its not-yet-existing window as stale.
let label = match registry.reserve(target, |l| app.get_webview_window(l).is_some())? {
Reservation::Reserved(label) => label,
// Still being built: it opens at its own location in a moment.
Reservation::Existing { built: false, .. } => return Ok(()),
Reservation::Existing { label, built: true } => {
focus_viewer(&app, &label, initial);
return Ok(());
}
};
if let Err(e) = open_viewer_window(&app, &label, &title) {
registry.remove(&label);
return Err(e);
}
registry.mark_built(&label);
Ok(())
}
#[tauri::command]
pub async fn viewer_get_state(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
Ok(viewer_state_of(&label, target))
}
#[tauri::command]
pub async fn viewer_choose_file(
index: usize,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
let chosen = match &target.state {
ViewerTargetState::Choose { candidates } => candidates
.get(index)
.cloned()
.ok_or_else(|| "That choice is no longer available.".to_string())?,
_ => return Err("This window is not choosing a file.".into()),
};
let app = window.app_handle();
match registry.choose(&label, chosen, |l| app.get_webview_window(l).is_some())? {
Choice::Resolved(updated) => Ok(viewer_state_of(&label, updated)),
// Another window already has this file. This window was only ever a
// chooser, so hand over to that one and close this one, as a second
// click on the same path would have. The error is what this window
// shows if the destroy fails.
Choice::AlreadyOpen { label: other, .. } => {
focus_viewer(app, &other, target.initial);
let _ = window.destroy();
Err("This file is already open in another window.".into())
}
}
}
#[tauri::command]
pub async fn viewer_read_file(
max_bytes: u64,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target, "opening files").await?;
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
let fetched = fetch_container_file(&container_id, &path, cap).await?;
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
Ok(()) => (true, None),
Err(reason) => (false, Some(reason)),
};
Ok(ViewerFile {
hash: sha256_hex(&fetched.bytes),
contents_base64: BASE64.encode(&fetched.bytes),
truncated: fetched.truncated,
size: fetched.size,
editable,
readonly_reason,
})
}
#[tauri::command]
pub async fn viewer_poll_file(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerPoll, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
poll_file(&container_id, &path).await
}
/// Errors from `write_file` pass through unchanged: the frontend matches the
/// `write::CONFLICT_PREFIX`/`GONE_PREFIX` prefixes and `READ_ONLY_MESSAGE` (TS copies in
/// `app/src/viewer/ipcMessages.ts`), and anything else (a full disk) is already a
/// sentence it shows as is. Success is a `SavedFile`: the new base hash and the hash
/// the disk held right after the swap.
#[tauri::command]
pub async fn viewer_write_file(
contents_base64: String,
base_hash: String,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<SavedFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
validate_container_write_path("File", &path)?;
check_encoded_len(contents_base64.len())?;
let bytes = BASE64
.decode(contents_base64.as_bytes())
.map_err(|_| "The editor sent malformed content.".to_string())?;
let container_id = running_container_for(&state, &target, "saving this file").await?;
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_is_main_only_and_viewer_commands_are_viewer_only() {
assert!(require_main("main").is_ok());
assert!(require_main("file-viewer-1").is_err());
assert!(require_main("browser-view-x").is_err());
assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7");
assert!(require_viewer("main").is_err());
assert!(require_viewer("file-viewer-").is_err());
}
/// Both "no container" refusals a viewer command can give start with the prefix
/// the viewer reads as "Container not running" (`ipcMessages.ts`).
#[test]
fn not_running_refusals_carry_the_shared_prefix() {
use crate::commands::file_commands::NOT_RUNNING_PREFIX;
let m = not_running_message("checking this file for changes", "files live in its container");
assert_eq!(m, "Start the project before checking this file for changes — files live in its container.");
assert!(m.starts_with(NOT_RUNNING_PREFIX));
}
#[test]
fn a_saved_file_serialises_both_hashes() {
let json = serde_json::to_value(SavedFile { hash: "a".into(), disk_hash: "b".into() }).unwrap();
assert_eq!(json, serde_json::json!({ "hash": "a", "disk_hash": "b" }));
}
#[test]
fn the_title_is_basename_then_project() {
assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C");
assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x");
assert_eq!(window_title("Makefile", "p"), "Makefile — p");
}
#[test]
fn viewer_state_serialises_the_ipc_shape() {
let target = ViewerTarget {
project_id: "pid".into(),
project_name: "P".into(),
raw_path: "src/a.rs".into(),
state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() },
initial: Location { line: Some(3), col: Some(2), end_line: None },
};
let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap();
assert_eq!(json["project_id"], "pid");
assert_eq!(json["state"]["kind"], "resolved");
assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs");
assert_eq!(json["initial"]["line"], 3);
assert!(json["initial"]["end_line"].is_null());
}
#[test]
fn the_encoded_length_is_capped_before_decoding() {
let at_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES]);
assert!(check_encoded_len(at_cap.len()).is_ok());
// MAX + 1 and MAX + 2 bytes pad to the same length as MAX; `write_file`'s
// decoded check refuses those. The first size this bound itself refuses:
let over_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES + 3]);
assert!(check_encoded_len(over_cap.len()).is_err());
assert!(check_encoded_len(at_cap.len() + 1).is_err());
assert!(check_encoded_len(0).is_ok());
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod auth_token_commands;
pub mod aws_commands; pub mod aws_commands;
pub mod docker_commands; pub mod docker_commands;
pub mod file_commands; pub mod file_commands;
pub mod file_viewer_commands;
pub mod gateway_commands; pub mod gateway_commands;
pub mod help_commands; pub mod help_commands;
pub mod inspect_commands; pub mod inspect_commands;
+109
View File
@@ -0,0 +1,109 @@
//! The terminal file viewer: one OS window per clicked path.
//!
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
//! the caller's own entry, which is why nothing here takes a path from a window.
//!
//! `file-viewer-*` is also the `windows` glob of `capabilities/file-viewer.json`, which grants
//! exactly the five `viewer_*` commands and nothing else. Labels are minted only here; a window
//! created anywhere else with a matching label would inherit those grants.
pub mod poll;
pub mod registry;
pub mod resolve;
pub mod window;
pub mod write;
/// Spec §3: the 21st click is refused with a toast.
pub const MAX_VIEWER_WINDOWS: usize = 20;
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
pub fn is_viewer_label(label: &str) -> bool {
label
.strip_prefix(VIEWER_LABEL_PREFIX)
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_numbered_viewer_labels_pass() {
assert!(is_viewer_label("file-viewer-1"));
assert!(is_viewer_label("file-viewer-20"));
assert!(!is_viewer_label("file-viewer-"));
assert!(!is_viewer_label("file-viewer-x"));
assert!(!is_viewer_label("main"));
assert!(!is_viewer_label("browser-view-abc"));
}
/// Both Vite's dev server and Tauri's asset lookup fall back to `index.html`
/// when `viewer.html` is missing, so a broken entry opens the *main app* in
/// the viewer window with no error anywhere. Pin the two files the entry needs.
#[test]
fn the_viewer_entry_exists_and_is_a_vite_input() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let html = std::fs::read_to_string(app_dir.join("viewer.html")).expect("app/viewer.html");
assert!(html.contains("/src/viewer/main.tsx"));
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
}
#[derive(serde::Deserialize)]
struct Capability {
windows: Vec<String>,
permissions: Vec<String>,
}
/// Task 12: a substring check on the capability JSON (the form this test used to take)
/// only proves a permission string appears *somewhere* in the file — it would not catch
/// `windows` widened past `file-viewer-*`, nor an extra grant slipped in beside the ones
/// this window actually needs. Parse both capability files and pin `windows`/`permissions`
/// exactly, so a later widening of either file is a failing test, not a silent threat-model
/// drift — this file *is* the reviewed threat model of record (see its own description).
#[test]
fn the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json"))
.expect("capabilities/file-viewer.json");
let cap: Capability = serde_json::from_str(&raw).expect("file-viewer.json must be valid JSON");
assert_eq!(cap.windows, vec!["file-viewer-*"]);
let mut permissions = cap.permissions;
permissions.sort();
assert_eq!(
permissions,
vec![
// App commands (bare): the five viewer commands, and nothing else — build.rs
// refuses any other bare grant in this file.
"allow-viewer-choose-file",
"allow-viewer-get-state",
"allow-viewer-poll-file",
"allow-viewer-read-file",
"allow-viewer-write-file",
// Plugin/core grants, unchanged.
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]
);
}
/// The main window's capability file must stay scoped to `main` — a `windows` list that
/// grew to include `file-viewer-*` would hand every viewer window the dialog/store surface
/// `default.json` grants `main`, which is a much larger IPC surface than the one
/// `file-viewer.json` was deliberately kept small.
#[test]
fn the_default_capability_is_scoped_to_the_main_window_only() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/default.json"))
.expect("capabilities/default.json");
let cap: Capability = serde_json::from_str(&raw).expect("default.json must be valid JSON");
assert_eq!(cap.windows, vec!["main"]);
}
}
+194
View File
@@ -0,0 +1,194 @@
//! One cheap exec per tick: the file's full hash and size, or "gone".
//!
//! This is what the 2 s poll asks, instead of re-downloading up to 1 MiB of archive per
//! window per tick. The hash is coreutils `sha256sum`, which equals `write::sha256_hex`
//! of the bytes whenever the read was not truncated — the only case in which the
//! editor uses a hash as its save base.
use serde::Serialize;
use crate::docker::exec::exec_oneshot_streams_as;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerPoll {
pub exists: bool,
pub hash: Option<String>,
pub size: Option<u64>,
}
/// Exit 4 = gone. A failure after `test -f` passed is re-checked: if the file vanished
/// in between (deleted while being hashed), that is "gone", not an error (M6).
pub const POLL_SCRIPT: &str = r#"test -f "$1" || exit 4
sha256sum -- "$1" && stat -c %s -- "$1" && exit 0
test -f "$1" || exit 4
exit 1"#;
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
if code == 4 {
return ViewerPoll { exists: false, hash: None, size: None };
}
let mut lines = stdout.lines();
let hash = lines
.next()
.and_then(|l| l.split_whitespace().next())
// GNU `sha256sum` prefixes the line with `\` when the name contains a
// backslash or a newline; strip it before validating the hex (P15).
.map(|h| h.trim_start_matches('\\'))
.filter(|h| super::write::is_sha256_hex(h))
.map(str::to_string);
let size = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
ViewerPoll { exists: true, hash, size }
}
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
let cmd = vec![
"sh".to_string(),
"-c".to_string(),
POLL_SCRIPT.to_string(),
"poll".to_string(),
container_path.to_string(),
];
let (stdout, stderr, code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
if code != 0 && code != 4 {
return Err(format!(
"Could not check the file: {}",
crate::commands::file_commands::clip_container_text(&stderr)
));
}
Ok(parse_poll_output(code, &stdout))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_present_file_yields_hash_and_size() {
let out = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\n42\n";
assert_eq!(
parse_poll_output(0, out),
ViewerPoll {
exists: true,
hash: Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into()),
size: Some(42)
}
);
}
#[test]
fn exit_four_means_gone() {
assert_eq!(parse_poll_output(4, ""), ViewerPoll { exists: false, hash: None, size: None });
}
#[test]
fn garbage_is_not_a_hash() {
let p = parse_poll_output(0, "not a hash /x\nabc\n");
assert_eq!(p, ViewerPoll { exists: true, hash: None, size: None });
}
#[test]
fn the_script_tests_existence_before_hashing() {
assert!(POLL_SCRIPT.contains("test -f \"$1\" || exit 4"));
assert!(POLL_SCRIPT.contains("sha256sum -- \"$1\""));
assert!(POLL_SCRIPT.contains("stat -c %s -- \"$1\""));
}
#[cfg(unix)]
fn run_poll_script(path_env: Option<&str>, target: &std::path::Path) -> (i64, String, String) {
let mut cmd = std::process::Command::new("sh");
if let Some(p) = path_env {
cmd.env("PATH", p);
}
let out = cmd.arg("-c").arg(POLL_SCRIPT).arg("poll").arg(target).output().unwrap();
(
out.status.code().unwrap_or(-1) as i64,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-poll-{}-{}", name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn on_the_host_the_poll_script_reports_hash_size_and_gone() {
let dir = test_dir("plain");
let target = dir.join("t.txt");
std::fs::write(&target, b"hello\n").unwrap();
let (code, stdout, stderr) = run_poll_script(None, &target);
assert_eq!(code, 0, "stderr={stderr}");
let p = parse_poll_output(code, &stdout);
assert_eq!(p.hash.as_deref(), Some(super::super::write::sha256_hex(b"hello\n").as_str()));
assert_eq!(p.size, Some(6));
let (code, _, _) = run_poll_script(None, &dir.join("missing"));
assert_eq!(code, 4);
let _ = std::fs::remove_dir_all(&dir);
}
/// M6: the file is deleted after `test -f` passed but before `sha256sum` read it
/// (a `sha256sum` shim on PATH deletes it and fails). That is "gone", not an error
/// the viewer would have to explain.
#[cfg(unix)]
#[test]
fn on_the_host_a_file_deleted_mid_poll_reads_as_gone() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("race");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\nrm -f -- \"$2\"\necho 'sha256sum: No such file or directory' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 4, "stderr={stderr}");
assert_eq!(parse_poll_output(code, &stdout), ViewerPoll { exists: false, hash: None, size: None });
let _ = std::fs::remove_dir_all(&dir);
}
/// A failure with the file still present stays a real error (exit 1), which
/// `poll_file` turns into "Could not check the file: …".
#[cfg(unix)]
#[test]
fn on_the_host_a_hash_failure_on_a_present_file_is_an_error() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("fail");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\necho 'sha256sum: Permission denied' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, _stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 1, "stderr={stderr}");
assert!(stderr.contains("Permission denied"));
let _ = std::fs::remove_dir_all(&dir);
}
/// P15: a path containing a backslash makes GNU `sha256sum` prefix the whole
/// line with `\`; that must not blind change detection by yielding `hash: None`.
#[test]
fn a_backslash_prefixed_hash_is_still_recognised() {
let out = "\\e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\\y\n7\n";
let p = parse_poll_output(0, out);
assert_eq!(
p.hash.as_deref(),
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
);
assert_eq!(p.size, Some(7));
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Which viewer window is looking at what.
//!
//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on
//! `AppState`, like the browser view keeps its own state. A label is reserved *before*
//! the window is built so two concurrent clicks cannot both pass the cap check.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Location {
pub line: Option<u32>,
pub col: Option<u32>,
pub end_line: Option<u32>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ViewerTargetState {
Resolved { container_path: String },
Choose { candidates: Vec<String> },
NotFound { tried: Vec<String> },
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerTarget {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
/// What [`ViewerRegistry::reserve`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reservation {
/// A window is already registered on this file. `built` is false while that
/// window is still being created: it has no `WebviewWindow` to focus yet, and
/// it will open at its own location, so the caller should simply return.
Existing { label: String, built: bool },
/// A new label, registered and counted against the cap; build its window,
/// then call [`ViewerRegistry::mark_built`] (or `remove` if building failed).
Reserved(String),
}
/// What [`ViewerRegistry::choose`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Choice {
/// The caller's entry now points at the chosen file.
Resolved(ViewerTarget),
/// Another window already has that file; the caller's entry is unchanged.
AlreadyOpen { label: String, built: bool },
}
#[derive(Clone, Debug)]
struct Entry {
target: ViewerTarget,
/// Set once the window's `build()` has returned. Until then the label has no
/// window by design, so "registered but windowless" means "being built", not
/// "stale" — only built entries are ever pruned.
built: bool,
}
#[derive(Default)]
pub struct ViewerRegistry {
entries: Mutex<HashMap<String, Entry>>,
next: AtomicU64,
}
fn same_file(t: &ViewerTarget, project_id: &str, container_path: &str) -> bool {
t.project_id == project_id
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
}
fn open_on(
entries: &HashMap<String, Entry>,
project_id: &str,
container_path: &str,
except: Option<&str>,
) -> Option<(String, bool)> {
entries
.iter()
.find(|(label, e)| Some(label.as_str()) != except && same_file(&e.target, project_id, container_path))
.map(|(label, e)| (label.clone(), e.built))
}
/// Drops built entries whose window is gone, whatever their state. `Destroyed`
/// normally removes an entry; this is the backstop for one it missed, so a leak
/// can never hold a cap slot for good.
fn prune(entries: &mut HashMap<String, Entry>, is_live: &dyn Fn(&str) -> bool) {
entries.retain(|label, e| !e.built || is_live(label));
}
impl ViewerRegistry {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
/// Finds the window already open on a resolved target, or reserves a label,
/// in one critical section, after pruning built entries `is_live` says are
/// gone. `is_live` runs under the registry lock and must not call back into
/// the registry.
pub fn reserve(
&self,
target: ViewerTarget,
is_live: impl Fn(&str) -> bool,
) -> Result<Reservation, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
if let ViewerTargetState::Resolved { container_path } = &target.state {
if let Some((label, built)) = open_on(&entries, &target.project_id, container_path, None) {
return Ok(Reservation::Existing { label, built });
}
}
if entries.len() >= MAX_VIEWER_WINDOWS {
return Err(format!(
"{} file windows are already open — close one before opening another.",
MAX_VIEWER_WINDOWS
));
}
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
entries.insert(label.clone(), Entry { target, built: false });
Ok(Reservation::Reserved(label))
}
/// Records that `label`'s window exists. A no-op if it was already removed
/// (a window destroyed the moment it appeared).
pub fn mark_built(&self, label: &str) {
if let Some(e) = self.lock().get_mut(label) {
e.built = true;
}
}
/// Points `label`'s entry at `container_path`, unless another window already
/// has that file open — then the entry is left alone, so no two entries are
/// ever resolved to the same file.
pub fn choose(
&self,
label: &str,
container_path: String,
is_live: impl Fn(&str) -> bool,
) -> Result<Choice, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
let project_id = entries
.get(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?
.target
.project_id
.clone();
if let Some((other, built)) = open_on(&entries, &project_id, &container_path, Some(label)) {
return Ok(Choice::AlreadyOpen { label: other, built });
}
let entry = entries.get_mut(label).expect("checked above under the same lock");
entry.target.state = ViewerTargetState::Resolved { container_path };
Ok(Choice::Resolved(entry.target.clone()))
}
pub fn get(&self, label: &str) -> Option<ViewerTarget> {
self.lock().get(label).map(|e| e.target.clone())
}
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
let mut entries = self.lock();
let entry = entries
.get_mut(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
entry.target.state = state;
Ok(entry.target.clone())
}
pub fn remove(&self, label: &str) {
self.lock().remove(label);
}
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
open_on(&self.lock(), project_id, container_path, None).map(|(label, _)| label)
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(project: &str, path: &str) -> ViewerTarget {
ViewerTarget {
project_id: project.into(),
project_name: "Demo".into(),
raw_path: path.into(),
state: ViewerTargetState::Resolved { container_path: path.into() },
initial: Location { line: Some(3), col: None, end_line: None },
}
}
fn all_live(_: &str) -> bool {
true
}
/// Reserves a label that must be new.
fn fresh(r: &ViewerRegistry, t: ViewerTarget) -> String {
match r.reserve(t, all_live).unwrap() {
Reservation::Reserved(label) => label,
other => panic!("expected a new label, got {:?}", other),
}
}
fn choosing(project: &str, candidates: &[&str]) -> ViewerTarget {
ViewerTarget {
state: ViewerTargetState::Choose { candidates: candidates.iter().map(|c| c.to_string()).collect() },
..target(project, "a")
}
}
#[test]
fn labels_are_sequential_and_never_reused() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
let b = fresh(&r, target("p", "/workspace/b"));
assert_eq!(a, "file-viewer-1");
assert_eq!(b, "file-viewer-2");
r.remove(&a);
let c = fresh(&r, target("p", "/workspace/c"));
assert_eq!(c, "file-viewer-3");
assert_eq!(r.len(), 2);
}
#[test]
fn the_cap_refuses_the_twenty_first_window() {
let r = ViewerRegistry::default();
for i in 0..MAX_VIEWER_WINDOWS {
fresh(&r, target("p", &format!("/workspace/{}", i)));
}
let err = r.reserve(target("p", "/workspace/one-more"), all_live).unwrap_err();
assert!(err.contains("20"), "{}", err);
assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
}
#[test]
fn an_open_resolved_file_is_found_by_project_and_path() {
let r = ViewerRegistry::default();
let label = fresh(&r, target("p", "/workspace/a"));
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
assert_eq!(r.find_open("other", "/workspace/a"), None);
// A window still choosing is not "open on" any path.
r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap();
assert_eq!(r.find_open("p", "/workspace/a"), None);
r.remove(&label);
assert_eq!(r.get(&label), None);
}
#[test]
fn set_state_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err());
}
#[test]
fn target_state_serialises_with_a_kind_tag() {
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
}
/// I1: a second click while the first window is still being built must find
/// that window, not read it as stale and reserve a second one.
#[test]
fn a_window_being_built_is_found_not_replaced() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
// No window exists yet for `a`: `is_live` says so, and it must not matter.
let second = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(second, Reservation::Existing { label: a.clone(), built: false });
assert!(r.get(&a).is_some());
assert_eq!(r.len(), 1);
r.mark_built(&a);
let third = r.reserve(target("p", "/workspace/a"), all_live).unwrap();
assert_eq!(third, Reservation::Existing { label: a, built: true });
assert_eq!(r.len(), 1);
}
/// A built entry whose window is gone is stale: pruned, and the file reopens.
#[test]
fn a_built_entry_without_a_window_is_pruned_and_the_file_reopens() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.mark_built(&a);
let again = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(again, Reservation::Reserved("file-viewer-2".into()));
assert_eq!(r.get(&a), None);
assert_eq!(r.len(), 1);
}
/// M2: a leaked entry of any state cannot hold a cap slot once built and gone,
/// and an entry still being built always keeps its slot.
#[test]
fn leaked_entries_of_every_state_free_their_cap_slot() {
let r = ViewerRegistry::default();
let mut labels = Vec::new();
for i in 0..MAX_VIEWER_WINDOWS {
let t = match i % 3 {
0 => target("p", &format!("/workspace/{}", i)),
1 => choosing("p", &["/workspace/x", "/workspace/y"]),
_ => ViewerTarget { state: ViewerTargetState::NotFound { tried: vec![] }, ..target("p", "z") },
};
labels.push(fresh(&r, t));
}
// All still being built: none may be pruned, so the cap holds.
assert!(r.reserve(target("p", "/workspace/new"), |_| false).is_err());
for l in &labels {
r.mark_built(l);
}
// Built, and one of each state has lost its window.
let dead = [labels[0].clone(), labels[1].clone(), labels[2].clone()];
let live = |l: &str| !dead.iter().any(|d| d == l);
assert!(matches!(r.reserve(target("p", "/workspace/new"), live), Ok(Reservation::Reserved(_))));
assert_eq!(r.len(), MAX_VIEWER_WINDOWS - 2);
for d in &dead {
assert_eq!(r.get(d), None);
}
}
#[test]
fn mark_built_on_a_removed_label_is_a_no_op() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.remove(&a);
r.mark_built(&a);
assert_eq!(r.get(&a), None);
}
/// M5: choosing a file another window already has leaves the chooser alone,
/// so two entries are never resolved to the same file.
#[test]
fn choosing_a_file_open_elsewhere_does_not_resolve_a_second_entry() {
let r = ViewerRegistry::default();
let open = fresh(&r, target("p", "/workspace/x"));
r.mark_built(&open);
let chooser = fresh(&r, choosing("p", &["/workspace/x", "/workspace/y"]));
r.mark_built(&chooser);
let c = r.choose(&chooser, "/workspace/x".into(), all_live).unwrap();
assert_eq!(c, Choice::AlreadyOpen { label: open.clone(), built: true });
assert!(matches!(r.get(&chooser).unwrap().state, ViewerTargetState::Choose { .. }));
match r.choose(&chooser, "/workspace/y".into(), all_live).unwrap() {
Choice::Resolved(t) => assert_eq!(t.state, ViewerTargetState::Resolved { container_path: "/workspace/y".into() }),
other => panic!("expected Resolved, got {:?}", other),
}
assert_eq!(r.find_open("p", "/workspace/y"), Some(chooser));
}
#[test]
fn choosing_the_same_path_in_another_project_is_not_a_duplicate() {
let r = ViewerRegistry::default();
fresh(&r, target("other", "/workspace/x"));
let chooser = fresh(&r, choosing("p", &["/workspace/x"]));
assert!(matches!(r.choose(&chooser, "/workspace/x".into(), all_live), Ok(Choice::Resolved(_))));
}
#[test]
fn choose_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.choose("file-viewer-9", "/workspace/x".into(), all_live).is_err());
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Turning what Claude printed into a container path that exists.
//!
//! Relative paths are the common case (Claude prints project-relative paths). The
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
//! is one exec as the container user and prints `realpath -e` of every candidate that
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
//! hold the resolved path, not the one that was clicked.
use crate::commands::file_commands::validate_container_path;
use crate::docker::exec::exec_oneshot_streams_as;
pub const MAX_CANDIDATES: usize = 16;
const MAX_RAW_LEN: usize = 4096;
/// `$@` are the candidates. For each regular file, print its resolved path.
pub const PROBE_SCRIPT: &str = r#"for c in "$@"; do if test -f "$c"; then realpath -e -- "$c" 2>/dev/null; fi; done; exit 0"#;
pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String> {
if raw.is_empty() {
return Err("The path is empty.".into());
}
if raw.len() > MAX_RAW_LEN {
return Err("The path is too long.".into());
}
if raw.contains('\0') {
return Err("The path contains a NUL byte.".into());
}
if raw.split('/').any(|seg| seg == "..") {
return Err(format!("{} climbs out of its folder with `..`; refusing.", raw));
}
if raw.starts_with('/') {
let normalised = collapse(raw);
validate_container_path("File", &normalised)?;
return Ok(vec![normalised]);
}
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
let rel = rel.trim_start_matches("./");
if rel.is_empty() {
return Err("The path is empty.".into());
}
let mut out: Vec<String> = Vec::new();
let mut push = |candidate: String| {
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
out.push(candidate);
}
};
push(format!("/workspace/{}", rel));
for mount in mount_names {
if mount.is_empty() || mount.contains('/') || mount == "." || mount == ".." {
continue;
}
push(format!("/workspace/{}/{}", mount, rel));
}
for c in &out {
validate_container_path("File", c)?;
}
Ok(out)
}
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
fn collapse(path: &str) -> String {
let absolute = path.starts_with('/');
let joined = path
.split('/')
.filter(|seg| !seg.is_empty() && *seg != ".")
.collect::<Vec<_>>()
.join("/");
if absolute { format!("/{}", joined) } else { joined }
}
/// One resolved path per line; anything that is not an absolute, valid container path is
/// dropped (the script's own diagnostics go to stderr, but a hostile `realpath` output is
/// still container-authored text).
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || validate_container_path("File", line).is_err() {
continue;
}
if !seen.iter().any(|s| s == line) {
seen.push(line.to_string());
}
}
seen
}
pub async fn probe_candidates(
container_id: &str,
candidates: &[String],
) -> Result<Vec<String>, String> {
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
cmd.extend(candidates.iter().cloned());
let (stdout, _stderr, _code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
Ok(parse_probe_output(&stdout))
}
#[cfg(test)]
mod tests {
use super::*;
fn mounts(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn an_absolute_path_is_its_own_only_candidate() {
let c = candidate_paths("/workspace/api/src/main.rs", &mounts(&["api"])).unwrap();
assert_eq!(c, vec!["/workspace/api/src/main.rs"]);
}
#[test]
fn a_relative_path_probes_workspace_then_each_mount() {
let c = candidate_paths("src/main.rs", &mounts(&["api", "web"])).unwrap();
assert_eq!(
c,
vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn dot_prefix_and_duplicate_slashes_are_normalised_and_candidates_deduped() {
let c = candidate_paths("./src//main.rs", &mounts(&["api", "api", ""])).unwrap();
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
}
#[test]
fn traversal_nul_and_oversize_are_refused() {
assert!(candidate_paths("../etc/passwd", &[]).is_err());
assert!(candidate_paths("src/../../x", &[]).is_err());
assert!(candidate_paths("/workspace/../etc/passwd", &[]).is_err());
assert!(candidate_paths("a\0b", &[]).is_err());
assert!(candidate_paths("", &[]).is_err());
assert!(candidate_paths(&"a".repeat(5000), &[]).is_err());
}
#[test]
fn candidate_list_is_capped() {
let many: Vec<String> = (0..40).map(|i| format!("m{}", i)).collect();
let c = candidate_paths("x.rs", &many).unwrap();
assert_eq!(c.len(), MAX_CANDIDATES);
}
#[test]
fn probe_output_keeps_valid_resolved_regular_files_only() {
let out = "/workspace/api/src/main.rs\n/workspace/api/src/main.rs\n\nrelative/junk\n/etc/../x\n/workspace/web/src/main.rs\n";
assert_eq!(
parse_probe_output(out),
vec!["/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn the_probe_script_prints_resolved_paths_of_regular_files() {
// Shape assertions: the script is data handed to `sh -c`, and these are the
// three things a later edit must not lose.
assert!(PROBE_SCRIPT.contains("test -f"));
assert!(PROBE_SCRIPT.contains("realpath -e --"));
assert!(PROBE_SCRIPT.contains("for c in \"$@\""));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences:
//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in
//! `capabilities/file-viewer.json` applies; and the registry entry is removed on
//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a
//! Rust-side `destroy()`.
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
use super::registry::ViewerRegistry;
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> {
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into()))
.title(title)
.inner_size(900.0, 700.0)
.min_inner_size(480.0, 320.0)
.build()
.map_err(|e| format!("Could not open the file window: {}", e))?;
let app_for_event = app.clone();
let label_owned = label.to_string();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
app_for_event.state::<ViewerRegistry>().remove(&label_owned);
}
});
Ok(())
}
+604
View File
@@ -0,0 +1,604 @@
//! Saving: stage in `/tmp`, then swap in as the container user.
//!
//! The Docker archive API writes as root, so it is used for exactly one thing — landing
//! the payload at `/tmp/triple-c-viewer-<uuid>`, owned by the container user (the
//! existing `write_file_to_container`). Everything that touches the *target directory*
//! runs in an exec as `claude`, so a save can do nothing the user's own shell could not.
//! A non-root process cannot `chown`, so the saved file is owned by the container user,
//! as it would be after Claude Code edited it; mode is kept with `chmod --reference`.
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::commands::file_commands::clip_container_text;
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// `$1` target, `$2` staged payload in /tmp, `$3` the hash the editor loaded from.
/// Exit 1 = a step failed (unreadable target, a failed stage/replace, …), 3 = changed
/// on disk, 4 = gone, 5 = the target is not writable by the container user; stdout on
/// success is `sha256sum` of the target *after* the write. That is not necessarily the
/// hash of what we wrote: another writer (Claude Code, on the same file) can land
/// between `mv` and `sha256sum`. `saved_file` therefore takes the save's base from the
/// bytes and only reports this one as what the disk held afterwards (M2).
///
/// P15: `sha256sum -- "$target"` prefixes its whole line with `\` when the path
/// contains a backslash or a newline, so `$actual` has that prefix stripped before
/// it is compared with `$expect` (which never carries one) — otherwise such a path
/// would conflict forever.
///
/// I1: `$actual` is read from a plain `sha256sum` command substitution, not a
/// pipeline into `cut` — POSIX sh has no `pipefail`, so `cmd | cut … || exit 1` tests
/// only `cut`'s exit status and an unreadable file (EACCES, EIO) fell through as a
/// false "changed on disk" conflict (empty `$actual` never equals `$expect`) instead
/// of a real error, hiding the actual failure from the user and from `classify_write`.
///
/// I2/M3: `$staged` is created by `mktemp` (exclusive — never follows a planted
/// symlink or stale leftover at that name) and is part of the `EXIT` trap from the
/// moment it is assigned, so a failure at any later step (`cp`, `chmod`, `mv`) cannot
/// leave a partial `.<name>.triple-c-<suffix>` behind in the user's own directory —
/// including on a signal, for the steps after the trap covers it.
pub const WRITE_SCRIPT: &str = r#"target=$1; tmp=$2; expect=$3
staged=
trap 'rm -f -- "$tmp" ${staged:+"$staged"}' EXIT
test -f "$target" || exit 4
actual=$(sha256sum -- "$target") || exit 1
actual=${actual%% *}; actual=${actual#\\}
[ "$actual" = "$expect" ] || exit 3
# I3: the file's own mode is a boundary the user set from outside the container (0444,
# a different owning uid, a read-only bind mount, …). Replacing it via rename or
# truncating it in place would silently cross that boundary even though `claude` is
# allowed to — an editor such as vim, or a plain `echo > file` in the user's own shell,
# would refuse. This is stricter than spec §5 step 3's literal "if the directory is
# writable" branch, which never looks at the file's own permissions; the branch below
# only ever chooses *how* to write, never *whether*.
#
# The rename branch replaces whatever is at "$target" (a symlink planted there after
# the window opened is replaced, not followed). The in-place `cat >` fallback, taken
# only for a writable file in a read-only directory, DOES follow such a symlink and
# writes through it. That is accepted: the write runs as `claude`, so it can reach
# nothing Claude Code in the same container cannot already write.
[ -w "$target" ] || { echo "The file is read-only for the container user." >&2; exit 5; }
dir=$(dirname -- "$target"); name=$(basename -- "$target")
if [ -w "$dir" ]; then
staged=$(mktemp -- "$dir/.$name.triple-c-XXXXXX") || exit 1
cp -- "$tmp" "$staged" || exit 1
chmod --reference="$target" "$staged" 2>/dev/null
mv -f -- "$staged" "$target" || exit 1
else
cat -- "$tmp" > "$target" || exit 1
fi
sha256sum -- "$target""#;
/// A save refused because the file changed since its base hash. The frontend matches
/// this prefix; its copy lives in `app/src/viewer/ipcMessages.ts` (pinned by a test).
pub const CONFLICT_PREFIX: &str = "conflict:";
/// A save refused because the file no longer exists; mirrored in `ipcMessages.ts`.
pub const GONE_PREFIX: &str = "gone:";
/// The read-only refusal. The script echoes the same sentence (pinned by a test), but
/// the caller always gets this constant, whatever the script printed; mirrored in
/// `ipcMessages.ts`.
pub const READ_ONLY_MESSAGE: &str = "The file is read-only for the container user.";
/// I3: distinct from the generic failure code so the caller can hand back a specific,
/// readable message instead of whatever the script's own diagnostic text says.
const EXIT_READ_ONLY: i64 = 5;
pub enum WriteOutcome {
Saved(String),
Conflict,
Gone,
Failed(String),
}
pub fn classify_write(code: i64, stdout: &str, stderr: &str) -> WriteOutcome {
match code {
3 => WriteOutcome::Conflict,
4 => WriteOutcome::Gone,
EXIT_READ_ONLY => WriteOutcome::Failed(READ_ONLY_MESSAGE.into()),
0 => match stdout
.split_whitespace()
.next()
.map(|h| h.trim_start_matches('\\'))
.filter(|h| is_sha256_hex(h))
{
Some(h) => WriteOutcome::Saved(h.to_string()),
None => WriteOutcome::Failed(
"The container did not report the saved file's hash.".into(),
),
},
_ => WriteOutcome::Failed(clip_container_text(stderr)),
}
}
/// The write script's argv beyond `sh -c SCRIPT`: `$0=save`, `$1=target`, `$2=tmp`,
/// `$3=base_hash` — pulled out pure so the argument shape has a unit test (P8).
fn write_command(target: &str, tmp: &str, base_hash: &str) -> Vec<String> {
vec![
"sh".to_string(),
"-c".to_string(),
WRITE_SCRIPT.to_string(),
"save".to_string(),
target.to_string(),
tmp.to_string(),
base_hash.to_string(),
]
}
/// Refuses a payload too large to be editable, or a malformed base hash, before
/// anything is staged in the container (P8).
fn check_write_input(len: usize, base_hash: &str) -> Result<(), String> {
if len > MAX_WRITE_BYTES {
return Err("Files over 1 MiB are read-only in the viewer.".into());
}
if !is_sha256_hex(base_hash) {
return Err("The editor's base hash is malformed; reload the file.".into());
}
Ok(())
}
/// What a successful save reports: `hash` is the new base, `sha256_hex` of the bytes
/// we wrote; `disk_hash` is what the container hashed right after the swap. They differ
/// only when another writer landed in between, and then the editor must show "Changed
/// on disk" rather than adopt the other writer's hash as its base (M2).
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct SavedFile {
pub hash: String,
pub disk_hash: String,
}
/// `viewer_write_file`'s result, pure so the error-prefix contract has a unit test.
fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String> {
match outcome {
WriteOutcome::Saved(disk_hash) => Ok(SavedFile { hash: sha256_hex(bytes), disk_hash }),
WriteOutcome::Conflict => Err(format!(
"{} the file changed on disk since it was loaded.",
CONFLICT_PREFIX
)),
WriteOutcome::Gone => Err(format!("{} the file no longer exists.", GONE_PREFIX)),
WriteOutcome::Failed(msg) => Err(format!("Could not save the file: {}", msg)),
}
}
pub async fn write_file(
container_id: &str,
exec_manager: &ExecSessionManager,
target: &str,
bytes: &[u8],
base_hash: &str,
) -> Result<SavedFile, String> {
check_write_input(bytes.len(), base_hash)?;
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
let tmp_path = exec_manager
.write_file_to_container(container_id, &tmp_name, bytes)
.await?;
let cmd = write_command(target, &tmp_path, base_hash);
let (stdout, stderr, code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
saved_file(classify_write(code, &stdout, &stderr), bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_matches_coreutils() {
// `printf 'hello\n' | sha256sum`
assert_eq!(
sha256_hex(b"hello\n"),
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
);
assert!(is_sha256_hex(&sha256_hex(b"")));
assert!(!is_sha256_hex("ABC"));
assert!(!is_sha256_hex(&"g".repeat(64)));
}
#[test]
fn exit_codes_map_to_outcomes() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(matches!(classify_write(0, &format!("{} /x\n", h), ""), WriteOutcome::Saved(s) if s == h));
assert!(matches!(classify_write(3, "", ""), WriteOutcome::Conflict));
assert!(matches!(classify_write(4, "", ""), WriteOutcome::Gone));
assert!(matches!(classify_write(1, "", "cp: Permission denied"), WriteOutcome::Failed(m) if m.contains("Permission denied")));
// Success without a parseable hash is still a failure: the editor's base would be wrong.
assert!(matches!(classify_write(0, "junk", ""), WriteOutcome::Failed(_)));
}
/// I3: exit 5 is the script's read-only refusal, and it must not be swallowed by
/// the generic `_ => Failed(stderr)` arm — the caller gets a fixed, readable
/// message regardless of exactly what the script printed.
#[test]
fn exit_five_is_a_distinct_read_only_refusal() {
assert!(matches!(
classify_write(5, "", "The file is read-only for the container user."),
WriteOutcome::Failed(m) if m.contains("read-only")
));
}
/// M2: the new base is the hash of the bytes we wrote, never the script's
/// post-`mv` hash, which may belong to a writer that landed after us.
#[test]
fn a_save_takes_its_base_from_the_written_bytes() {
let ours = sha256_hex(b"new\n");
let same = saved_file(WriteOutcome::Saved(ours.clone()), b"new\n").unwrap();
assert_eq!(same, SavedFile { hash: ours.clone(), disk_hash: ours.clone() });
let foreign = sha256_hex(b"someone else's\n");
let raced = saved_file(WriteOutcome::Saved(foreign.clone()), b"new\n").unwrap();
assert_eq!(raced.hash, ours, "the base must be what we wrote");
assert_eq!(raced.disk_hash, foreign, "the foreign hash is reported, not adopted");
}
/// Important #4: the frontend matches these exact strings
/// (`app/src/viewer/ipcMessages.ts`), so pin them here too.
#[test]
fn save_errors_keep_the_prefix_contract() {
let conflict = saved_file(WriteOutcome::Conflict, b"").unwrap_err();
assert!(conflict.starts_with("conflict:"), "{conflict}");
assert_eq!(conflict, "conflict: the file changed on disk since it was loaded.");
let gone = saved_file(WriteOutcome::Gone, b"").unwrap_err();
assert!(gone.starts_with("gone:"), "{gone}");
assert_eq!(gone, "gone: the file no longer exists.");
let read_only = saved_file(classify_write(5, "", "whatever the script said"), b"").unwrap_err();
assert_eq!(read_only, "Could not save the file: The file is read-only for the container user.");
assert!(!read_only.starts_with(CONFLICT_PREFIX) && !read_only.starts_with(GONE_PREFIX));
let other = saved_file(classify_write(1, "", "No space left on device"), b"").unwrap_err();
assert_eq!(other, "Could not save the file: No space left on device");
// The script's own refusal text is the same sentence the caller is given.
assert!(WRITE_SCRIPT.contains(&format!("echo \"{}\" >&2; exit 5", READ_ONLY_MESSAGE)));
}
/// The TypeScript side keeps one copy of each matched string; a change on either
/// side without the other fails here.
#[test]
fn the_frontend_copies_of_the_ipc_messages_match() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/viewer/ipcMessages.ts");
let ts = std::fs::read_to_string(&path).expect("app/src/viewer/ipcMessages.ts");
for (name, value) in [
("CONFLICT_PREFIX", CONFLICT_PREFIX),
("GONE_PREFIX", GONE_PREFIX),
("READ_ONLY_MESSAGE", READ_ONLY_MESSAGE),
("NOT_RUNNING_PREFIX", crate::commands::file_commands::NOT_RUNNING_PREFIX),
] {
let line = format!("export const {} = \"{}\";", name, value);
assert!(ts.contains(&line), "ipcMessages.ts must contain `{line}`");
}
}
/// P15: a target path with a backslash makes `sha256sum` prefix the line;
/// the parsed hash must still be recognised as the saved hash.
#[test]
fn a_backslash_prefixed_saved_hash_is_still_recognised() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(matches!(
classify_write(0, &format!("\\{} /x\\y\n", h), ""),
WriteOutcome::Saved(s) if s == h
));
}
#[test]
fn the_write_script_checks_then_swaps_and_always_cleans_up() {
for needle in [
"test -f \"$target\" || exit 4",
"exit 3",
"chmod --reference=\"$target\"",
"mv -f --",
"cat -- \"$tmp\" > \"$target\"",
// I2/M3: the trap covers the staged file too, and it comes from `mktemp`.
"trap 'rm -f -- \"$tmp\" ${staged:+\"$staged\"}' EXIT",
"mktemp -- \"$dir/.$name.triple-c-XXXXXX\"",
// I1: a plain command substitution, not a pipeline `cut` could mask.
"actual=$(sha256sum -- \"$target\") || exit 1",
// I3: a read-only target is refused before any write is attempted.
"[ -w \"$target\" ] || { echo \"The file is read-only for the container user.\" >&2; exit 5; }",
] {
assert!(WRITE_SCRIPT.contains(needle), "missing: {}", needle);
}
// The old pipeline form must be gone, not merely superseded.
assert!(!WRITE_SCRIPT.contains("cut -d' ' -f1"));
}
/// P8: the write script's test list is binding, and the argument order is
/// exactly what a later edit could silently break.
#[test]
fn write_command_has_the_expected_argv_shape() {
let cmd = write_command("/w/t.txt", "/tmp/x", "abc123");
assert_eq!(
cmd,
vec![
"sh".to_string(),
"-c".to_string(),
WRITE_SCRIPT.to_string(),
"save".to_string(),
"/w/t.txt".to_string(),
"/tmp/x".to_string(),
"abc123".to_string(),
]
);
}
/// P8: the size cap and base-hash checks are unit-testable in isolation from
/// the async `write_file`.
#[test]
fn check_write_input_refuses_oversized_payload_and_malformed_hash() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(check_write_input(MAX_WRITE_BYTES, h).is_ok());
assert!(check_write_input(MAX_WRITE_BYTES + 1, h).is_err());
assert!(check_write_input(0, "not-a-hash").is_err());
}
// ── M10: WRITE_SCRIPT run for real, against a temp dir on the host ──────────
//
// The needle test above only proves the script *contains* certain substrings; it
// cannot catch the pipefail-shaped bug I1 was (the needle text was correct, the
// shell semantics were not). These run the exact `sh -c SCRIPT save target tmp
// hash` invocation `write_command` builds, so they pin the exit codes and cleanup
// behaviour that `write_file`/`classify_write` actually depend on. `sh` and the
// coreutils used here (`sha256sum`, `mktemp`, `dirname`, `basename`) are present
// on dev machines and CI alike.
#[cfg(unix)]
fn run_write_script(
target: &std::path::Path,
tmp: &std::path::Path,
base_hash: &str,
) -> (i32, String, String) {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(WRITE_SCRIPT)
.arg("save")
.arg(target)
.arg(tmp)
.arg(base_hash)
.output()
.expect("sh must be on PATH to run this test");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-write-{}-{}", name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn on_the_host_a_clean_save_replaces_the_file_and_cleans_up() {
let dir = unique_test_dir("clean");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let base = sha256_hex(b"old\n");
let (code, stdout, stderr) = run_write_script(&target, &tmp, &base);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let new_hash = sha256_hex(b"new\n");
assert!(stdout.contains(&new_hash), "stdout={stdout}");
// With no other writer, the reported disk hash is ours, so no conflict is shown.
let saved = saved_file(classify_write(code as i64, &stdout, &stderr), b"new\n").unwrap();
assert_eq!(saved, SavedFile { hash: new_hash.clone(), disk_hash: new_hash.clone() });
assert_eq!(std::fs::read(&target).unwrap(), b"new\n");
assert!(!tmp.exists(), "the staged /tmp payload must be cleaned up");
let _ = std::fs::remove_dir_all(&dir);
}
/// M2, for real: another writer lands between the script's `mv` and its final
/// `sha256sum` (simulated by a `sha256sum` shim on PATH that rewrites the target on
/// its second call). The save's base must still be the hash of our bytes, and the
/// foreign hash must come back as `disk_hash`, so the editor shows "Changed on disk".
#[cfg(unix)]
#[test]
fn on_the_host_a_write_that_lands_after_ours_is_reported_not_adopted() {
use std::os::unix::fs::PermissionsExt;
let real = std::process::Command::new("sh")
.args(["-c", "command -v sha256sum"])
.output()
.expect("sh");
let real = String::from_utf8_lossy(&real.stdout).trim().to_string();
assert!(!real.is_empty(), "sha256sum must be on PATH");
let dir = unique_test_dir("race");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let mark = dir.join("called-once");
let shim = bin.join("sha256sum");
std::fs::write(
&shim,
format!(
"#!/bin/sh\nif [ -e '{mark}' ]; then printf 'theirs\\n' > \"$2\"; fi\n: > '{mark}'\nexec '{real}' \"$@\"\n",
mark = mark.display(),
real = real
),
)
.unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let out = std::process::Command::new("sh")
.env("PATH", path)
.arg("-c")
.arg(WRITE_SCRIPT)
.arg("save")
.arg(&target)
.arg(&tmp)
.arg(sha256_hex(b"old\n"))
.output()
.unwrap();
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert_eq!(out.status.code(), Some(0), "stdout={stdout} stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"theirs\n", "the shim's write landed last");
let saved = saved_file(classify_write(0, &stdout, &stderr), b"new\n").unwrap();
assert_eq!(saved.hash, sha256_hex(b"new\n"));
assert_eq!(saved.disk_hash, sha256_hex(b"theirs\n"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn on_the_host_a_stale_base_hash_conflicts_and_leaves_everything_untouched() {
let dir = unique_test_dir("stale");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let wrong_base = sha256_hex(b"not what is on disk\n");
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &wrong_base);
assert_eq!(code, 3, "stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn on_the_host_a_missing_target_reports_gone() {
let dir = unique_test_dir("gone");
let target = dir.join("does-not-exist");
let tmp = dir.join("payload");
std::fs::write(&tmp, b"new\n").unwrap();
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"whatever"));
assert_eq!(code, 4, "stderr={stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
/// I1: a real read failure must be a real error (exit 1), never the exit-3
/// conflict a bare `sha256sum | cut` pipeline (no `pipefail` in POSIX sh) would
/// silently produce.
#[cfg(unix)]
#[test]
fn on_the_host_an_unreadable_target_is_an_error_not_a_conflict() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("unreadable");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&target).is_ok() {
// Running as root (or some other bypass): 0o000 does not block reads,
// so this scenario cannot be reproduced here.
eprintln!("skipping: still able to read a 0o000 file (root?)");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(
code, 1,
"an unreadable target must be a real error, not exit 3; stderr={stderr}"
);
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
}
/// I3: a target the container user cannot write is refused outright, never
/// replaced via rename.
#[cfg(unix)]
#[test]
fn on_the_host_a_read_only_target_is_refused_not_replaced() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("readonly");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o444)).unwrap();
if std::fs::OpenOptions::new().write(true).open(&target).is_ok() {
eprintln!("skipping: still able to write a 0o444 file (root?)");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(code as i64, EXIT_READ_ONLY, "stderr={stderr}");
assert!(stderr.contains("read-only"), "stderr={stderr}");
assert_eq!(
std::fs::read(&target).unwrap(),
b"old\n",
"a read-only file must not be replaced"
);
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
}
/// I2: a failed stage (here: an unreadable source payload, so `cp` fails after
/// `mktemp` has already created the destination) must not leave a partial
/// `.<name>.triple-c-<suffix>` behind in the user's own directory.
#[cfg(unix)]
#[test]
fn on_the_host_a_failed_stage_leaves_no_partial_file_behind() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("cpfail");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&tmp).is_ok() {
eprintln!("skipping: still able to read a 0o000 file (root?)");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(code, 1, "stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".t.txt.triple-c-"))
.collect();
assert!(leftovers.is_empty(), "staged file(s) left behind: {leftovers:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+138 -37
View File
@@ -1,7 +1,10 @@
mod auth_bridge; mod auth_bridge;
mod browser_view; mod browser_view;
#[cfg(test)]
mod command_census;
mod commands; mod commands;
mod docker; mod docker;
pub mod file_viewer;
mod install_helper; mod install_helper;
mod logging; mod logging;
mod models; mod models;
@@ -240,6 +243,7 @@ pub fn run() {
lifecycle, lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)), pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
}) })
.manage(file_viewer::registry::ViewerRegistry::default())
.setup(move |app| { .setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) { match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => { Ok(icon) => {
@@ -545,6 +549,13 @@ pub fn run() {
commands::file_commands::read_container_file, commands::file_commands::read_container_file,
commands::file_commands::rename_container_path, commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory, commands::file_commands::create_container_directory,
// Terminal file viewer
commands::file_viewer_commands::open_file_viewer,
commands::file_viewer_commands::viewer_get_state,
commands::file_viewer_commands::viewer_choose_file,
commands::file_viewer_commands::viewer_read_file,
commands::file_viewer_commands::viewer_poll_file,
commands::file_viewer_commands::viewer_write_file,
// AWS // AWS
commands::aws_commands::aws_sso_refresh, commands::aws_commands::aws_sso_refresh,
// Updates // Updates
@@ -835,30 +846,11 @@ mod tests {
&mut defined, &mut defined,
); );
// The registration list, read from this file rather than from a macro // The registration list, read from this file by the same parser `build.rs` uses to
// expansion so the test does not depend on `generate_handler!`'s shape. // declare the AppManifest — so if this test can see a command, the ACL can too.
let this = include_str!("lib.rs"); let ordered = crate::command_census::registered_commands(include_str!("lib.rs"))
let handler = this
.split_once("generate_handler![")
.and_then(|(_, rest)| rest.split_once("])"))
.map(|(inside, _)| inside)
.expect("lib.rs should contain a generate_handler! list"); .expect("lib.rs should contain a generate_handler! list");
// Line-based, not `split(',')`: the list is grouped under `// Docker` let registered: BTreeSet<String> = ordered.iter().cloned().collect();
// style comments, and splitting on commas glues each comment to the
// command that follows it. A `starts_with("//")` filter then drops that
// command — silently, and once per group.
let registered: BTreeSet<String> = handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(|l| {
l.trim_end_matches(',')
.rsplit("::")
.next()
.map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect();
assert!( assert!(
!defined.is_empty() && !registered.is_empty(), !defined.is_empty() && !registered.is_empty(),
@@ -887,23 +879,13 @@ mod tests {
// passed here. // passed here.
let mut seen: Vec<&str> = Vec::new(); let mut seen: Vec<&str> = Vec::new();
let mut duplicated: Vec<&str> = Vec::new(); let mut duplicated: Vec<&str> = Vec::new();
for line in handler for name in &ordered {
.lines() if seen.contains(&name.as_str()) {
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
{
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
let name = name.trim();
if name.is_empty() {
continue;
}
if seen.contains(&name) {
duplicated.push(name); duplicated.push(name);
} else { } else {
seen.push(name); seen.push(name);
} }
} }
}
assert!( assert!(
duplicated.is_empty(), duplicated.is_empty(),
"these are registered more than once: {:?}", "these are registered more than once: {:?}",
@@ -930,7 +912,10 @@ mod tests {
}) })
.collect(); .collect();
let mut sorted = listed.clone(); // Plugin and core grants: the exact reviewed list, unchanged by the lockdown.
let (bare, prefixed): (Vec<String>, Vec<String>) =
listed.iter().cloned().partition(|g| !g.contains(':'));
let mut sorted = prefixed;
sorted.sort(); sorted.sort();
let mut expected = vec![ let mut expected = vec![
"core:event:allow-listen", "core:event:allow-listen",
@@ -942,11 +927,31 @@ mod tests {
expected.sort(); expected.sort();
assert_eq!( assert_eq!(
sorted, expected, sorted, expected,
"the capability set changed. That is allowed — but it is the IPC \ "the plugin/core capability set changed. That is allowed — but it is the IPC \
surface a compromised webview can call, so update this list \ surface a compromised webview can call, so update this list \
deliberately rather than to make the test pass." deliberately rather than to make the test pass."
); );
// App commands: since build.rs declares the AppManifest, the bare `allow-*` grants
// are the complete list of app commands the main window may call. `build.rs` already
// fails the build when they disagree with generate_handler!; this keeps the reviewed
// rule ("every non-viewer command, exactly") visible where the plugin census lives.
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
let mut expected_bare: Vec<String> = registered
.iter()
.filter(|c| crate::command_census::expected_windows(c) == ["main"])
.map(|c| crate::command_census::allow_permission(c))
.collect();
expected_bare.sort();
let mut bare = bare;
bare.sort();
assert_eq!(
bare, expected_bare,
"default.json's app-command grants must be exactly the main-window commands"
);
assert!(bare.len() >= 100, "the census found {} app grants; the parser has stopped seeing the list", bare.len());
// Belt and braces: the `*:default` aliases are the specific trap here, // Belt and braces: the `*:default` aliases are the specific trap here,
// because they expand to a set the file never spells out. `store:*` in // because they expand to a set the file never spells out. `store:*` in
// particular was an arbitrary host-file read/write primitive. // particular was an arbitrary host-file read/write primitive.
@@ -964,4 +969,100 @@ mod tests {
); );
} }
} }
/// `build.rs` derives the AppManifest from the handler list and this reads back what
/// tauri-build actually embedded. `cargo test` runs the build script first, so
/// `gen/schemas/acl-manifests.json` is fresh. This guards against the committed/generated
/// artifact diverging from `generate_handler!` — a stale `acl-manifests.json`, or a
/// tauri-build naming change — using the same `registered_commands` parser `build.rs` used
/// to derive the manifest in the first place. It is *not* independent of a parser dropout on
/// its own: if `registered_commands` lost half the list, `build.rs` would declare half a
/// manifest and this would still compare it against the same half. That guarantee is
/// transitive, not local — `every_command_is_registered_exactly_once` covers it, by
/// cross-checking the parser's output against an independent `#[tauri::command]` scan, so a
/// parser regression that silently dropped commands fails there rather than going unnoticed
/// here.
#[test]
fn the_generated_app_manifest_matches_the_handler_list() {
use std::collections::BTreeSet;
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/gen/schemas/acl-manifests.json");
let raw = std::fs::read_to_string(path)
.expect("gen/schemas/acl-manifests.json is written by build.rs on every build");
let manifests: serde_json::Value =
serde_json::from_str(&raw).expect("acl-manifests.json must parse");
let app = manifests.get("__app-acl__").expect(
"build.rs must declare an AppManifest — without it tauri skips the ACL for every \
app command",
);
let embedded: BTreeSet<String> = app["permissions"]
.as_object()
.expect("the app manifest has a permissions map")
.keys()
.cloned()
.collect();
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
let expected: BTreeSet<String> = registered
.iter()
.flat_map(|c| {
let allow = crate::command_census::allow_permission(c);
let deny = format!("deny-{}", &allow["allow-".len()..]);
[allow, deny]
})
.collect();
assert!(registered.len() >= 100, "the parser sees {} commands", registered.len());
assert_eq!(
embedded, expected,
"the embedded app manifest and generate_handler! disagree: build.rs and \
tauri-build should have produced the same list"
);
assert!(
app["permission_sets"].as_object().is_some_and(|s| s.is_empty()),
"no permission sets: every grant is a literal allow-* string in a capability file"
);
assert!(app["default_permission"].is_null(), "no app `default` permission set");
}
/// `build.rs`'s `check_tauri_config` (inline `app.security.capabilities`, a JSON5/TOML tauri
/// config, `TAURI_CONFIG`) only runs inside the build script, so it only re-runs on a clean
/// build or in CI — cargo's incremental build has no reason to notice a new
/// `tauri.<platform>.conf.json` dropped into an already-built tree (CLAUDE.md, "Known
/// limit"). This runs the same check, using the same `command_census` functions build.rs
/// calls, directly against the real `app/src-tauri` directory on every `cargo test`, so that
/// gap is closed locally too.
#[test]
fn the_tauri_config_capability_check_runs_against_the_real_tree() {
let dir = env!("CARGO_MANIFEST_DIR");
let mut problems = Vec::new();
for entry in std::fs::read_dir(dir).expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = path
.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned();
match crate::command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config \
could declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(crate::command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(crate::command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
assert!(
problems.is_empty(),
"cargo test found what build.rs would refuse on a clean build: {problems:?}"
);
}
} }
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT, IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT, TEXT_PREVIEW_LIMIT,
decodeBase64, decodeBase64,
encodeBase64,
extensionOf, extensionOf,
imageMimeFor, imageMimeFor,
looksBinary, looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
expect(looksBinary(bytes)).toBe(false); expect(looksBinary(bytes)).toBe(false);
}); });
}); });
describe("encodeBase64", () => {
it("matches btoa on a small input", () => {
expect(encodeBase64(new Uint8Array([0xff, 0xd8, 0x00, 0x41]))).toBe(btoa("\xff\xd8\x00\x41"));
});
it("round-trips 1 MiB without overflowing the call stack", () => {
// Spreading a 1 MiB array into String.fromCharCode throws RangeError in V8.
const bytes = new Uint8Array(TEXT_PREVIEW_LIMIT);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff;
const back = decodeBase64(encodeBase64(bytes));
expect(back.length).toBe(bytes.length);
expect(back.every((b, i) => b === bytes[i])).toBe(true);
});
it("encodes an empty input as the empty string", () => {
expect(encodeBase64(new Uint8Array(0))).toBe("");
});
});
@@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
return bytes; return bytes;
} }
/**
* Bytes → base64. Built 32 KiB at a time: spreading a whole buffer into
* `String.fromCharCode` overflows the argument limit well below 1 MiB.
*/
export function encodeBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** /**
* The classic heuristic: a NUL byte early on means this is not text. Cheap, * The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question. * and it is what `git` and `grep` use to decide the same question.
@@ -1,10 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, act } from "@testing-library/react"; import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView"; import TerminalView, {
OSC8_HOVER_CLASS,
createOsc8LinkHandler,
supersedes,
} from "./TerminalView";
import { Terminal } from "@xterm/xterm";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { import {
uploadHostFileToTerminal, uploadHostFileToTerminal,
openUrlExternal, openUrlExternal,
openFileViewer,
} from "../../lib/tauri-commands"; } from "../../lib/tauri-commands";
import { import {
chooseSignInTarget, chooseSignInTarget,
@@ -41,6 +47,57 @@ const ptyOutput = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: number[] }) => void>(), listeners: new Map<string, (e: { payload: number[] }) => void>(),
})); }));
/**
* What `TerminalView` actually handed the `Terminal` constructor, and the
* instances it built.
*
* The real xterm is kept — these tests depend on its parser, its modes and its
* DOM — and only the constructor is wrapped, because the wiring of
* `linkHandler` is otherwise unobservable from outside: xterm decides when to
* call it from cell geometry that jsdom has no layout for, so deleting the
* `linkHandler:` line changed nothing any assertion could see.
*/
const xterm = vi.hoisted(() => ({
options: null as Record<string, unknown> | null,
instances: [] as unknown[],
}));
/**
* The click handler `TerminalView` hands `WebLinksAddon`.
*
* Captured for the same reason the `Terminal` constructor is: xterm decides
* when to call it from cell geometry jsdom has no layout for, so the only way
* to ask "does the plain-text-URL path apply the same gate as the OSC 8 one?"
* is to hold the function and call it.
*/
const webLinks = vi.hoisted(() => ({
handler: null as null | ((event: MouseEvent, uri: string) => void),
}));
vi.mock("@xterm/xterm", async (importOriginal) => {
const actual = await importOriginal<typeof import("@xterm/xterm")>();
class SpyTerminal extends actual.Terminal {
constructor(options?: ConstructorParameters<typeof actual.Terminal>[0]) {
super(options);
xterm.options = (options ?? null) as Record<string, unknown> | null;
xterm.instances.push(this);
}
}
return { ...actual, Terminal: SpyTerminal };
});
vi.mock("@xterm/addon-web-links", async (importOriginal) => {
const actual = await importOriginal<typeof import("@xterm/addon-web-links")>();
type Args = ConstructorParameters<typeof actual.WebLinksAddon>;
class SpyWebLinksAddon extends actual.WebLinksAddon {
constructor(...args: Args) {
super(...args);
webLinks.handler = (args[0] ?? null) as typeof webLinks.handler;
}
}
return { ...actual, WebLinksAddon: SpyWebLinksAddon };
});
/** /**
* Shift+Enter has to reach the container as ESC+CR. * Shift+Enter has to reach the container as ESC+CR.
* *
@@ -68,6 +125,7 @@ vi.mock("../../lib/tauri-commands", () => ({
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge), getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection), checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
openUrlExternal: vi.fn(async () => {}), openUrlExternal: vi.fn(async () => {}),
openFileViewer: vi.fn(async () => {}),
})); }));
vi.mock("@tauri-apps/api/event", () => ({ vi.mock("@tauri-apps/api/event", () => ({
@@ -157,6 +215,9 @@ beforeEach(() => {
useAppState.setState({ toasts: [] }); useAppState.setState({ toasts: [] });
document.body.innerHTML = ""; document.body.innerHTML = "";
useAppState.setState({ sessions: [] }); useAppState.setState({ sessions: [] });
xterm.options = null;
xterm.instances.length = 0;
webLinks.handler = null;
}); });
afterEach(() => { afterEach(() => {
@@ -1074,3 +1135,842 @@ describe("TerminalView — releasing a captured mouse", () => {
expect(terminalInput).not.toHaveBeenCalled(); expect(terminalInput).not.toHaveBeenCalled();
}); });
}); });
describe("the hover hint names the key that actually works", () => {
const platform = (value: string) =>
Object.defineProperty(navigator, "platform", { value, configurable: true });
const original = navigator.platform;
afterEach(() => platform(original));
const hoverHint = (
tracking: boolean,
macOptionClickForcesSelection = true,
): string => {
const host = document.createElement("div");
createOsc8LinkHandler(() => host, () => ({
mouseTracking: tracking,
hasSelection: false,
macOptionClickForcesSelection,
})).hover?.(new MouseEvent("mousemove"), "https://example.com/x", {
start: { x: 1, y: 1 },
end: { x: 1, y: 1 },
});
return host.textContent ?? "";
};
// The hint and the gate read one predicate; these pin that they cannot
// drift, because a hint naming a key the gate does not accept is the bug
// that was already fixed once on this branch.
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
platform("MacIntel");
expect(hoverHint(true)).toContain("Option+click");
expect(hoverHint(true)).not.toContain("Shift+click");
});
it("says Shift everywhere else", () => {
platform("Linux x86_64");
expect(hoverHint(true)).toContain("Shift+click");
});
// No program holds the mouse, so no modifier is needed — and naming one
// would tell the user to press a key the gate ignores.
it("names no modifier at all while nothing is tracking the mouse", () => {
platform("Linux x86_64");
const hint = hoverHint(false);
expect(hint).toContain("Click to open");
expect(hint).not.toContain("Shift+click");
platform("MacIntel");
expect(hoverHint(false)).not.toContain("Option+click");
});
// `macOptionClickForcesSelection` defaults to false in xterm and this view
// sets it true, so the Mac branch is only live because of that line. If it
// ever goes, Option stops being the force-selection modifier and the gate
// can never pass while a program holds the mouse — so the card must not go
// on naming a key that does nothing.
it("does not promise Option+click when the option behind it is off", () => {
platform("MacIntel");
const hint = hoverHint(true, false);
expect(hint).not.toContain("Option+click");
expect(hint).not.toContain("Shift+click");
});
});
describe("createOsc8LinkHandler — clicking a link Claude Code printed", () => {
/**
* The handler is exercised directly rather than through a rendered terminal.
*
* xterm decides *when* to call it from cell geometry, and jsdom gives every
* element a zero-sized box — so a test driving the mouse over the pane would
* be asserting that jsdom's layout engine exists, not that this app validates
* what it opens. What xterm hands over is the OSC 8 parameter verbatim, which
* is exactly what these arguments are.
*/
const range = {
start: { x: 1, y: 1 },
end: { x: 80, y: 1 },
} as unknown as Parameters<
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
>[2];
let host: HTMLDivElement;
let handler: ReturnType<typeof createOsc8LinkHandler>;
/**
* What the terminal answers about itself when the gate asks, per test.
*
* Mutable rather than fixed at construction because both of the first two
* change *under* the handler: the container sets the mouse mode with a
* DECSET, and the selection is whatever the gesture that ended in this
* mouseup left behind.
*/
let state: {
mouseTracking: boolean;
hasSelection: boolean;
macOptionClickForcesSelection: boolean;
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
state = {
mouseTracking: false,
hasSelection: false,
// What `TerminalView` sets on the real terminal.
macOptionClickForcesSelection: true,
};
handler = createOsc8LinkHandler(() => host, () => state);
});
afterEach(() => host.remove());
function hoverCard(): HTMLElement | null {
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
}
/** A real single click: one press, one release, `detail` 1. */
const click = (init: MouseEventInit = {}) =>
new MouseEvent("click", { button: 0, detail: 1, ...init });
it("refuses a target that fails validation, without reaching the opener", () => {
// The visible text can be anything; the parameter is what gets opened, and
// a container is free to put a scheme in it that the host must never hand
// to an OS-level opener.
handler.activate(click(), "javascript:alert(1)", range);
handler.activate(click(), "file:///etc/passwd", range);
handler.activate(click(), "https://claude.ai@evil.tld/authorize", range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("opens a valid target through the one sink", async () => {
const url =
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
await act(async () => {
handler.activate(click(), url, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(url);
});
describe("the gate on activation", () => {
const URL = "https://example.com/x";
/**
* The attack this gate exists for.
*
* xterm's mouse-reporting mousedown does *not* cancel anything —
* `cancelEvents` defaults to false — and the Linkifier is a descendant of
* the element those listeners are bound to, so the link layer sees every
* click first and `_handleMouseUp` activates with no modifier, button or
* mode check of its own. A TUI widget the user is meant to click can
* therefore be wrapped in an OSC 8 pointing anywhere, and a plain click
* opens the host browser on it while the mouse report still reaches the
* program, so nothing looks wrong. The modifier is the only thing that
* separates "I clicked the menu item" from "I asked to leave the app".
*/
it("refuses a plain click while a program is tracking the mouse", () => {
state.mouseTracking = true;
handler.activate(click(), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("opens on the force-selection modifier while tracking", async () => {
state.mouseTracking = true;
await act(async () => {
handler.activate(click({ shiftKey: true }), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("opens on a plain click when nothing holds the mouse", async () => {
// A normal shell. This is what `WebLinksAddon` does for the plain-text
// URLs in the same buffer, and asking for a modifier here would read as
// a broken link.
state.mouseTracking = false;
await act(async () => {
handler.activate(click(), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("ignores every button but the primary one", () => {
// Right-click is the context menu this pane already binds; middle-click
// is paste. Neither is a request to leave the app.
state.mouseTracking = false;
handler.activate(click({ button: 2 }), URL, range);
handler.activate(click({ button: 1 }), URL, range);
handler.activate(click({ button: 2, shiftKey: true }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
/**
* Selecting text is not asking to leave the app.
*
* `Linkifier._handleMouseUp` has no `detail` check, no drag threshold and
* no timestamp — it activates whenever the mouseup lands on the same link
* the mousedown did. `SelectionService` is bound on the *document* and the
* Linkifier on `screenElement`, so the selection gesture and the link
* activation both run, the link layer first. Every gesture below is one a
* user makes to *copy* a string, and none of them may open a browser.
*/
describe("a selection gesture is not a click", () => {
it("refuses a double-click, which selects the word under it", () => {
// xterm selects the word on the *mousedown* of the second click, so
// by this mouseup the selection is already there.
state.hasSelection = true;
handler.activate(click({ detail: 2 }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("refuses a triple-click, which selects the whole row", () => {
state.hasSelection = true;
handler.activate(click({ detail: 3 }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
// The one the click count cannot see: a drag is a single press and a
// single release, so `detail` is 1 throughout. Only the selection it
// left behind distinguishes it from a click.
it("refuses a drag that selected characters, at click count 1", () => {
state.hasSelection = true;
handler.activate(click(), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
/**
* The worst version, and the reason the modifier alone is not a gate.
*
* While a program holds the mouse, Shift/Option+drag is the *only* way
* to select text at all — so "the deliberate request to leave the app"
* and "I am copying this line" are byte-identical gestures. A container
* that wraps each of its output rows in an OSC 8 turns every legitimate
* copy into a browser open.
*/
it("refuses a force-selection drag while a program holds the mouse", () => {
state.mouseTracking = true;
state.hasSelection = true;
handler.activate(click({ shiftKey: true }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
// Belt to the selection check's braces: independent of whether xterm
// managed to select anything (a double-click on trailing whitespace
// selects nothing), a second click is not a first one.
it("refuses a repeat click even when nothing ended up selected", () => {
state.hasSelection = false;
handler.activate(click({ detail: 2 }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
});
/**
* The card and the gate must not disagree about what the user has to do.
*
* The hint is computed once, when the pointer arrives; the mode it was
* computed from is the container's to change, and `?1002l` takes effect
* synchronously with the write. So a card reading "Shift+click to open"
* can be on screen while the live mode says a bare click is enough —
* which is also the shape of the flicker attack in FINDING 2. The gate
* therefore honours the *stricter* of what was promised and what is true
* now: a modifier the card asked for is still required when the click
* lands.
*/
describe("what the card promised still binds when the click lands", () => {
it("keeps demanding the modifier after the container drops tracking", () => {
state.mouseTracking = true;
handler.hover?.(new MouseEvent("mousemove"), URL, range);
expect(host.textContent).toContain("+click to open");
// `?1002l`, mid-hover.
state.mouseTracking = false;
handler.activate(click(), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("still opens on the modifier the card named", async () => {
state.mouseTracking = true;
handler.hover?.(new MouseEvent("mousemove"), URL, range);
state.mouseTracking = false;
await act(async () => {
handler.activate(click({ shiftKey: true }), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("does not hold a stale demand against the next link", async () => {
state.mouseTracking = true;
handler.hover?.(new MouseEvent("mousemove"), URL, range);
handler.leave?.(new MouseEvent("mouseout"), URL, range);
// A plain shell now, and a fresh card that says so.
state.mouseTracking = false;
handler.hover?.(new MouseEvent("mousemove"), URL, range);
expect(host.textContent).toContain("Click to open");
await act(async () => {
handler.activate(click(), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
});
/**
* FINDING 6: the modifier is xterm's, including the option it hangs on.
*
* xterm's rule is `isMac ? altKey && macOptionClickForcesSelection :
* shiftKey`. Hardcoding `altKey` agrees with the app only for as long as
* the app keeps setting that option, and nothing tells you when it stops.
*/
describe("the Mac modifier follows the terminal's own option", () => {
const platform = (value: string) =>
Object.defineProperty(navigator, "platform", {
value,
configurable: true,
});
const original = navigator.platform;
afterEach(() => platform(original));
it("opens on Option+click while the option is on", async () => {
platform("MacIntel");
state.mouseTracking = true;
state.macOptionClickForcesSelection = true;
await act(async () => {
handler.activate(click({ altKey: true }), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("refuses Option+click when the terminal does not treat it as force-select", () => {
platform("MacIntel");
state.mouseTracking = true;
state.macOptionClickForcesSelection = false;
handler.activate(click({ altKey: true }), URL, range);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("ignores the option off a Mac, where Shift is the modifier", async () => {
platform("Linux x86_64");
state.mouseTracking = true;
state.macOptionClickForcesSelection = false;
await act(async () => {
handler.activate(click({ shiftKey: true }), URL, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
});
});
it("shows the real origin on hover, not the text on screen", () => {
// The point of the affordance. OSC 8 decouples label from target: the row
// can read `https://claude.ai` while the parameter points anywhere.
handler.hover?.(
new MouseEvent("mousemove"),
"https://evil.example.com/claude.ai/oauth/authorize?code=true",
range,
);
const card = hoverCard();
expect(card).not.toBeNull();
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
expect(origin?.textContent).toBe("https://evil.example.com");
expect(card!.textContent).not.toContain("https://claude.ai");
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
expect(hoverCard()).toBeNull();
});
it("keeps a very long origin whole, and gives way in the remainder instead", () => {
// The attacker picks the origin's length. `https://claude.ai.<300 a's>
// .evil.tld/` parses, passes every `sanitizeRelayUrl` rule, and under a
// non-shrinking flex item runs off the right edge of the pane — which
// hides the registrable domain just as effectively as an ellipsis would.
const origin = `https://claude.ai.${"a".repeat(300)}.${"b".repeat(200)}.evil.tld`;
handler.hover?.(new MouseEvent("mousemove"), `${origin}/oauth?code=1`, range);
const originEl = hoverCard()!.querySelector<HTMLElement>(
'[data-testid="osc8-hover-origin"]',
)!;
// Whole origin or nothing: every character is in the DOM...
expect(originEl.textContent).toBe(origin);
// ...and it is allowed to wrap rather than be clipped or pushed off-pane.
expect(originEl.style.flexShrink).not.toBe("0");
expect(originEl.style.whiteSpace).not.toBe("nowrap");
expect(originEl.style.overflowWrap).toBe("anywhere");
// The truncatable half is the remainder, and only the remainder.
const restEl = hoverCard()!.querySelector<HTMLElement>(
'[data-testid="osc8-hover-rest"]',
)!;
expect(restEl.style.textOverflow).toBe("ellipsis");
expect(restEl.style.whiteSpace).toBe("nowrap");
});
it("cannot take the pointer away from the link that summoned it", () => {
// The card is appended to `Terminal.element`, a *sibling* of the
// `screenElement` the Linkifier listens on, so `xterm-hover` buys nothing
// here: a card under the pointer means `mouseleave` on screenElement, the
// card is torn down, and the mouseup that would activate the link lands on
// the card instead of the terminal.
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
expect(hoverCard()!.style.pointerEvents).toBe("none");
});
it("says so on hover when the target would be refused", () => {
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
// Never echo the rejected target: it is untrusted text on its way to a DOM
// node, and the only thing worth saying is that clicking does nothing.
expect(card!.textContent).not.toContain("javascript:");
});
it("does not call a refused web address something other than a web address", () => {
// `https://claude.ai@evil.tld/` is a perfectly good URL; it is refused
// because the userinfo makes the visible host a lie. Telling the user it
// "is not a web address" is false, and a false explanation teaches them to
// distrust the card.
handler.hover?.(
new MouseEvent("mousemove"),
"https://claude.ai@evil.tld/authorize",
range,
);
expect(hoverCard()!.textContent).not.toContain("not a web address");
});
it("drops a stale card when the pane is no longer on screen", () => {
// `leave` only ever arrives from the Linkifier's `_clearCurrentLink`, and
// switching tabs from the keyboard moves no pointer: without this, the
// card is still sitting there when the user comes back.
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
expect(hoverCard()).not.toBeNull();
handler.dismiss();
expect(hoverCard()).toBeNull();
});
it("routes a file: target to the viewer and never to the opener", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "file:///workspace/p/src/a.ts", range);
expect(onOpenFile).toHaveBeenCalledWith("/workspace/p/src/a.ts");
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("with non-http targets now delivered, still refuses javascript: and garbage", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "javascript:alert(1)", range);
h.activate(click(), "not a url", range);
h.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
expect(onOpenFile).not.toHaveBeenCalled();
expect(openUrlExternal).not.toHaveBeenCalled();
// The refusal card (ruling (a)): present, no origin, never the target,
// never an offer to open it.
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.textContent).not.toContain("javascript:");
expect(card!.textContent).not.toContain("Open in viewer");
});
it("refuses a file: target whose escapes do not decode", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "file:///workspace/%E0%A4%A", range);
h.hover?.(new MouseEvent("mousemove"), "file:///workspace/%E0%A4%A", range);
expect(onOpenFile).not.toHaveBeenCalled();
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.textContent).not.toContain("Open in viewer");
});
it("the file: hover card names the viewer and the path", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
h.hover?.(new MouseEvent("mousemove"), "file:///workspace/p/README.md", range);
expect(hoverCard()?.textContent).toContain("Open in viewer");
expect(hoverCard()?.textContent).toContain("/workspace/p/README.md");
});
it("shows a relative path's card with the path as printed (preflight P6)", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
h.showFileCard("src/foo.ts");
expect(hoverCard()?.textContent).toContain("Open in viewer");
expect(hoverCard()?.textContent).toContain("src/foo.ts");
h.dismiss();
expect(hoverCard()).toBeNull();
});
it("holds a file-path click to the modifier its card promised", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
state.mouseTracking = true;
h.showFileCard("src/foo.ts");
expect(hoverCard()?.textContent).toContain("Shift+click");
state.mouseTracking = false;
expect(h.opensFileLink(click())).toBe(false);
expect(h.opensFileLink(click({ shiftKey: true }))).toBe(true);
// A card drawn with nothing tracking promises nothing.
h.showFileCard("src/foo.ts");
expect(h.opensFileLink(click())).toBe(true);
});
it("declares allowNonHttpProtocols so file: targets reach it", () => {
expect(createOsc8LinkHandler(() => host, () => state).allowNonHttpProtocols).toBe(true);
});
it("pushes the shared toast when the host opener fails", async () => {
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await act(async () => {
handler.activate(click(), "https://example.com/x", range);
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe("error");
expect(toasts[0].detail).toContain("no opener");
// Same card as every other dead-opener report in this view.
expect(toasts[0].dedupeKey).toBe("host-open-failed");
});
});
describe("the link handler is wired into the terminal, and reads its live mode", () => {
const range = {
start: { x: 1, y: 1 },
end: { x: 80, y: 1 },
} as unknown as Parameters<
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
>[2];
/** What the mounted view passed as `linkHandler`. */
function wiredHandler() {
const handler = xterm.options?.linkHandler as
| ReturnType<typeof createOsc8LinkHandler>
| undefined;
if (!handler) throw new Error("no linkHandler was passed to Terminal");
return handler;
}
/** Feed the terminal a DECSET the way the container would. */
async function write(data: string) {
const term = xterm.instances.at(-1) as { write(d: string, cb: () => void): void };
await act(
() => new Promise<void>((resolve) => term.write(data, resolve)),
);
}
it("passes one at all — without it OSC 8 links are inert", () => {
mountSession("claude");
const handler = wiredHandler();
expect(typeof handler.activate).toBe("function");
expect(typeof handler.hover).toBe("function");
});
it("opens a file: target in the viewer against the session's project", async () => {
vi.mocked(openFileViewer).mockClear();
mountSession("bash");
await act(async () => {
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///workspace/api/src/a%20b.ts",
range,
);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith(
"p1", "/workspace/api/src/a b.ts", undefined, undefined, undefined,
);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("registers the file-path provider, which opens the viewer at the matched line", async () => {
vi.mocked(openFileViewer).mockClear();
const register = vi.spyOn(Terminal.prototype, "registerLinkProvider");
try {
mountSession("bash");
const provider = register.mock.calls.at(-1)?.[0];
if (!provider) throw new Error("no link provider was registered");
await write("Edited src/foo.ts:42 today");
const links = vi.fn();
provider.provideLinks(1, links);
const [link] = links.mock.calls[0][0];
expect(link.text).toBe("src/foo.ts:42");
await act(async () => {
link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined);
} finally {
register.mockRestore();
}
});
it("says so in a toast when the viewer refuses to open", async () => {
vi.mocked(openFileViewer).mockRejectedValueOnce(new Error("No such file: x.ts"));
mountSession("bash");
await act(async () => {
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///x.ts",
range,
);
await Promise.resolve();
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts.at(-1)?.detail).toContain("No such file: x.ts");
expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open");
});
it("holds a plain-text file link to the modifier its hover card promised", async () => {
vi.mocked(openFileViewer).mockClear();
const platform = navigator.platform;
Object.defineProperty(navigator, "platform", { value: "Linux x86_64", configurable: true });
const register = vi.spyOn(Terminal.prototype, "registerLinkProvider");
try {
mountSession("claude");
const provider = register.mock.calls.at(-1)?.[0];
if (!provider) throw new Error("no link provider was registered");
await write("Edited src/foo.ts:42 today");
const links = vi.fn();
provider.provideLinks(1, links);
const [link] = links.mock.calls[0][0];
await write("\x1b[?1002h");
link.hover?.(new MouseEvent("mousemove"), link.text);
expect(document.body.textContent).toContain("Shift+click to open");
await write("\x1b[?1002l");
await act(async () => {
link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text);
await Promise.resolve();
});
expect(openFileViewer).not.toHaveBeenCalled();
await act(async () => {
link.activate(
new MouseEvent("click", { button: 0, detail: 1, shiftKey: true }),
link.text,
);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined);
} finally {
register.mockRestore();
Object.defineProperty(navigator, "platform", { value: platform, configurable: true });
}
});
it("says the project is not ready rather than doing nothing", async () => {
vi.mocked(openFileViewer).mockClear();
useAppState.setState({ sessions: [] });
render(<TerminalView sessionId="s-unknown" active />);
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///workspace/api/README.md",
range,
);
expect(openFileViewer).not.toHaveBeenCalled();
const toasts = useAppState.getState().toasts;
expect(toasts.at(-1)?.detail).toContain("not ready yet");
expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open");
});
// The gate has to ask the terminal, not a boolean captured at construction:
// the mode changes whenever the container prints a DECSET, which is several
// times a second in Claude Code.
it("refuses a plain click once the container turns mouse tracking on", async () => {
mountSession("claude");
await write("\x1b[?1002h");
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"https://example.com/x",
range,
);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("opens again once the container gives the mouse back", async () => {
mountSession("claude");
await write("\x1b[?1002h");
await write("\x1b[?1002l");
await act(async () => {
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"https://example.com/x",
range,
);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith("https://example.com/x");
});
});
/**
* FINDING 4: the sibling path opens the same browser.
*
* `WebLinksAddon` matches rendered text and activates through the same
* `Linkifier._handleMouseUp`, with the same absence of any check. It also
* covers the plain-text URLs that carry no OSC 8 parameter at all. (Since the
* file viewer, `allowNonHttpProtocols` is on, so every OSC 8 target reaches
* the OSC 8 handler, which refuses anything but `file:` and `http(s):`.)
* Both routes end at `openUrlExternal`, so both ask the same question first.
*/
describe("the plain-text URL path is gated the same way", () => {
function webLinksHandler() {
if (!webLinks.handler) throw new Error("no handler was passed to WebLinksAddon");
return webLinks.handler;
}
function term() {
return xterm.instances.at(-1) as unknown as {
write(d: string, cb: () => void): void;
select(column: number, row: number, length: number): void;
};
}
async function write(data: string) {
await act(() => new Promise<void>((resolve) => term().write(data, resolve)));
}
const click = (init: MouseEventInit = {}) =>
new MouseEvent("click", { button: 0, detail: 1, ...init });
const URL = "https://example.com/x";
it("opens on a plain click in an ordinary shell", async () => {
mountSession("bash");
await act(async () => {
webLinksHandler()(click(), URL);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("refuses a plain click while a program holds the mouse", async () => {
mountSession("claude");
await write("\x1b[?1002h");
webLinksHandler()(click(), URL);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("opens on the force-selection modifier while tracking", async () => {
mountSession("claude");
await write("\x1b[?1002h");
await act(async () => {
webLinksHandler()(click({ shiftKey: true }), URL);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
});
it("refuses the mouseup that ended a selection", async () => {
mountSession("bash");
await write("https://example.com/x");
await act(async () => {
term().select(0, 0, 5);
});
webLinksHandler()(click(), URL);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("refuses a repeat click", async () => {
mountSession("bash");
webLinksHandler()(click({ detail: 2 }), URL);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("still refuses a target that fails validation", async () => {
mountSession("bash");
webLinksHandler()(click(), "https://claude.ai@evil.tld/authorize");
expect(openUrlExternal).not.toHaveBeenCalled();
});
});
+677 -20
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm"; import { Terminal, type ILinkHandler } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
@@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput"; import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import { import {
awsSsoRefresh, awsSsoRefresh,
openFileViewer,
openPageInContainerBrowser, openPageInContainerBrowser,
openUrlExternal, openUrlExternal,
uploadHostFileToTerminal, uploadHostFileToTerminal,
@@ -21,6 +22,7 @@ import {
extendsUrl, extendsUrl,
parseUrlRelayOsc, parseUrlRelayOsc,
sanitizeRelayUrl, sanitizeRelayUrl,
urlOrigin,
} from "../../lib/urlRelay"; } from "../../lib/urlRelay";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget"; import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget"; import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
@@ -32,6 +34,8 @@ import UrlToast, {
import { trimSelection } from "./trimSelection"; import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer"; import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu"; import TerminalContextMenu from "./TerminalContextMenu";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
import type { FilePathMatch } from "../../lib/filePathLinks";
interface Props { interface Props {
sessionId: string; sessionId: string;
@@ -106,12 +110,605 @@ export function supersedes(
return extendsUrl(next.url, current.url); return extendsUrl(next.url, current.url);
} }
/**
* Marks the hover card, for xterm's stylesheet and for the tests.
*
* It does *not* make xterm route pointer events around the card. xterm only
* consults this class inside `Linkifier._handleMouseMove`, which is registered
* on `screenElement`; the card is appended to `Terminal.element`, a *sibling*
* of that node, so the check never sees it. What keeps the card out of the way
* is `pointerEvents: "none"` on the card itself see `hover` below for what
* goes wrong without it.
*/
export const OSC8_HOVER_CLASS = "xterm-hover";
/**
* Report a failed handoff to the host's browser.
*
* One sink, one card. See the long note on `handleOpenUrl` for what this catch
* does *not* catch on Linux; a click that appears to do nothing is the
* complaint either way, so every route that opens a URL says the same thing in
* the same place.
*/
function reportOpenFailure(e: unknown) {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
detail: String(e),
// A dead opener fails for every link in the buffer. One card.
dedupeKey: "host-open-failed",
});
}
/**
* Open a path in the file viewer for this terminal's project, or say why not.
* The session record (and so the project) can arrive after the first render;
* a click in that window gets a toast rather than silently doing nothing.
*/
function openInViewer(
projectId: string | undefined,
path: string,
line?: number,
col?: number,
endLine?: number,
): void {
if (!projectId) {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open the file",
detail: "This terminal's project is not ready yet",
dedupeKey: "file-viewer-open",
});
return;
}
openFileViewer(projectId, path, line, col, endLine).catch(reportViewerFailure);
}
/** Report a file-viewer open that the backend refused (not found, no container, cap). */
function reportViewerFailure(e: unknown): void {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open the file",
detail: e instanceof Error ? e.message : String(e),
dedupeKey: "file-viewer-open",
});
}
/**
* `ILinkHandler`, plus the one thing xterm never asks for.
*
* `leave` is only ever reached through `Linkifier._clearCurrentLink`, i.e. a
* pointer that moved. Switching tabs from the keyboard moves no pointer and
* the Linkifier's dispose path does not clear either, so the card outlives the
* pane and is still there when the user comes back. {@link dismiss} is how the
* view says "this pane is gone" without pretending to be a mouse event.
*/
export type Osc8LinkHandler = ILinkHandler & {
dismiss(): void;
/**
* Draw the "Open in viewer" card for a path matched in plain text by the
* file-path link provider. Takes the raw path (a relative path stays
* relative `file://src/x` would parse `src` as a host). Records the
* card's modifier promise exactly as `hover` does, so
* {@link opensFileLink} can hold the click to it.
*/
showFileCard(path: string): void;
/**
* The file-path provider's click gate: {@link opensOnClick} with whatever
* the card on screen promised. There is only ever one card, and `clear()`
* resets the promise with it, so sharing the flag with OSC 8 links is exact.
*/
opensFileLink(event: MouseEvent): boolean;
};
/**
* What an OSC 8 target is, now that `allowNonHttpProtocols` delivers every
* scheme here. `null` is anything this pane refuses: unparseable, a scheme
* other than `file:`/`http(s):`, or a `file:` path whose escapes do not decode.
* The host of a `file:` URL is ignored `ls --hyperlink` writes the machine's
* hostname there, and the viewer only ever reads the container.
*/
function classifyOsc8Target(
text: string,
): { kind: "file"; path: string } | { kind: "web" } | null {
let parsed: URL;
try {
parsed = new URL(text);
} catch {
return null;
}
if (parsed.protocol === "file:") {
try {
return { kind: "file", path: decodeURIComponent(parsed.pathname) };
} catch {
return null;
}
}
if (parsed.protocol === "http:" || parsed.protocol === "https:") return { kind: "web" };
return null;
}
/**
* Is a program holding the mouse?
*
* One expression, two readers that must never disagree: the status-bar badge
* (`syncMouseCapture`) and the gate on opening a link ({@link opensOnClick}).
* A gate that thought tracking was off while the badge said it was on would be
* the whole security hole back again.
*/
function terminalTracksMouse(term: Terminal): boolean {
return term.modes.mouseTrackingMode !== "none";
}
/**
* Everything the gate asks the terminal, sampled at the moment of the click.
*
* A struct rather than three getters because the three are read together and
* must describe one instant: `hasSelection` is only meaningful against the
* `mouseTracking` that decided which gestures could have produced it.
*/
export interface ClickContext {
/** {@link terminalTracksMouse} — the container's to change, at any time. */
mouseTracking: boolean;
/** Does the terminal hold a selection *right now*? See {@link opensOnClick}. */
hasSelection: boolean;
/** xterm's `macOptionClickForcesSelection`, read rather than assumed. */
macOptionClickForcesSelection: boolean;
}
function readClickContext(term: Terminal): ClickContext {
return {
mouseTracking: terminalTracksMouse(term),
hasSelection: term.hasSelection(),
macOptionClickForcesSelection:
term.options.macOptionClickForcesSelection ?? false,
};
}
/** xterm's `isMac` verbatim (`common/Platform.ts`), so we split where it does. */
function isMacPlatform(): boolean {
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
return ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
}
/**
* xterm's `SelectionService.shouldForceSelection`, mirrored.
*
* The modifier is not our choice and it must not drift: while a program holds
* the mouse, this is the one gesture the user already has for "this click is
* for the terminal, not for the program", so it is the gesture that may open a
* link. xterm's rule is
* `isMac ? e.altKey && rawOptions.macOptionClickForcesSelection : e.shiftKey`,
* and the option is read from the terminal rather than assumed: this view sets
* it true today, so the two agreed, but xterm's default is false and nothing
* would have reported the day that line went. A hardcoded `altKey` would then
* accept a modifier xterm no longer treats as force-select.
*
* The gate and the hint below both call this. A hint that names a key the gate
* does not accept is worse than no hint the user concludes the link is
* broken and that is a bug this branch has already shipped once, so the two
* are not allowed separate answers.
*/
function forcesSelection(
event: { altKey: boolean; shiftKey: boolean },
macOptionClickForcesSelection: boolean,
): boolean {
return isMacPlatform()
? event.altKey && macOptionClickForcesSelection
: event.shiftKey;
}
/**
* What the card tells the user to do, for the state the terminal is in *now*.
*
* Conditional because the gesture is: with no program tracking the mouse a
* plain click opens the link, and naming a modifier then would send the user
* hunting for a key that changes nothing. The last branch is the same rule
* once more: on a Mac with `macOptionClickForcesSelection` off there *is* no
* force-selection modifier, so {@link opensOnClick} can never pass while a
* program holds the mouse, and naming Option would be naming a dead key.
*/
function openHintLabel(ctx: ClickContext): string {
if (!ctx.mouseTracking) return "Click to open";
if (!isMacPlatform()) return "Shift+click to open";
if (!ctx.macOptionClickForcesSelection) {
return "Not clickable while a program holds the mouse";
}
return "Option+click to open";
}
/**
* Whether this mouseup is a request to leave the app for the host browser.
*
* xterm asks none of this. `Linkifier._handleMouseUp` activates whenever the
* mouseup lands on the same link the mousedown did no button check, no mode
* check, no `detail`, no drag threshold, no timestamp (`SelectionService` has
* a `_mouseDownTimeStamp`; the Linkifier has nothing). Four refusals, for four
* different mistakes:
*
* - **Anything but the primary button.** Without this a *right*-click
* activates the link as well as opening this pane's context menu, and a
* middle-click paste opens it too.
* - **A mouseup that ended a selection.** This is the load-bearing one, and
* the reason is that a drag is a single press and a single release, so its
* click count is 1 and nothing else distinguishes it from a click. Both
* gestures a user makes to *copy* a string end here: drag across a few
* characters, or double-click a word (xterm selects it on the second
* mousedown, so the selection is already in the model by the time this
* runs). Worse, while a program holds the mouse Shift/Option+drag is the
* *only* way to select at all byte-identical to the modifier below so
* without this check a container that wraps each output row in an OSC 8
* turns every legitimate copy into a browser open. The selection check is
* also cheap to be wrong about in the safe direction: xterm's
* `_handleSingleClick` clears the model on the mousedown of a plain click,
* so an old selection elsewhere in the buffer is already gone by the time a
* real click on a link arrives here.
*
* The limit of this check, stated because the paragraph above reads
* absolute: it sees a drag only once the drag has spanned a *cell*. A press
* and release inside one character cell, or a drag walked back to where it
* started, leaves `finalSelectionEnd === finalSelectionStart`, so
* `hasSelection()` is false and the link opens. Nothing reached the
* clipboard in that case and the card showed the real origin first, so the
* cost is small but it is the gap a mousedown/mouseup distance check
* would have closed, and it is the price of not keeping that second source
* of truth.
* - **A repeat click**, `detail > 1`. Belt to the above's braces: it holds
* even when the selection came out empty (a double-click on trailing
* whitespace selects nothing) and it does not depend on xterm having
* updated the selection model before the Linkifier's listener runs. It is
* `!== 1`, not `> 1`: a mouseup derived from a real click always carries
* `detail >= 1`, so `> 1` would have waved through anything synthesised
* with `detail` 0. Nothing in the container can dispatch a DOM event, so
* that is hardening rather than a hole being closed.
* Comparing mousedown and mouseup *coordinates* would be a third signal,
* but xterm hands this handler only the mouseup the mousedown is not
* ours to see without binding our own listener to the host element, which
* is a second source of truth about the same gesture.
* - **A plain click while a modifier is required.** OSC 8 lets the container
* wrap any clickable TUI widget a menu row, a "1. Yes", a file chip in
* a link to anywhere, and because the mouse report still reaches the
* program the widget also responds, so nothing looks wrong. Requiring the
* force-selection modifier there makes the two intents distinguishable.
*
* `modifierPromised` is that last requirement made sticky, and it is about the
* card rather than the click: the hint is rendered once, at hover, from a mode
* the container may change before the user's finger comes down. The gate
* honours the stricter of what the card promised and what is true now, so a
* card reading "Shift+click to open" cannot be on screen while a bare click
* opens the link.
*
* **What this does not close.** `mouseTracking` is a permission the attacker
* grants itself see `activate`.
*
* With nothing tracking the mouse and nothing promised, a bare click is
* correct and expected: it is what `WebLinksAddon` does for the plain-text
* URLs in the same buffer, which is why that handler applies this same gate.
*/
function opensOnClick(
event: MouseEvent,
ctx: ClickContext,
modifierPromised = false,
): boolean {
if (event.button !== 0) return false;
if (event.detail !== 1) return false;
if (ctx.hasSelection) return false;
if (!ctx.mouseTracking && !modifierPromised) return true;
return forcesSelection(event, ctx.macOptionClickForcesSelection);
}
/**
* Makes OSC 8 hyperlinks clickable, and shows where they actually go.
*
* ## Why xterm's own link matching is not enough
*
* `WebLinksAddon` matches *rendered text*, row by row. Claude Code prints its
* links as OSC 8 hyperlinks whose visible text is hard-wrapped into
* terminal-width pieces measured against 2.1.226, a 346-character sign-in
* URL arrives as five emissions, each carrying the whole URL in its OSC 8
* parameter and about 80 characters of it on screen (see `lib/urlDetector.ts`,
* which had to grow the same second branch). So the addon matches a fragment
* or nothing at all, which is the entire reason the URL toast exists. xterm
* hands `linkHandler` the complete parameter instead, however the label was
* sliced, so this covers exactly the case the addon cannot and the addon
* stays, because it covers the plain-text URLs in ordinary shell output that
* carry no OSC 8 at all.
*
* ## xterm applies no gate of its own, so this one does
*
* There is a tempting story in which xterm's mouse-reporting mousedown cancels
* the event before the link layer sees it, leaving only the force-selection
* modifier a way through. It is false in both halves. That branch calls
* `cancel(e)`, which is a no-op unless `cancelEvents` is set and it defaults to
* false; and the mouse-reporting listeners are bound on `Terminal.element`
* while the Linkifier is bound on `screenElement`, a descendant, so bubbling
* reaches the link first no matter what. `Linkifier._handleMouseUp` then
* activates the link with no check on the button, the modifier or the mouse
* mode.
*
* So the gate is {@link opensOnClick}, applied in `activate`, and everything it
* asks about is read from the terminal at the moment of the click rather than
* captured the container changes the mouse mode whenever it likes, and the
* selection is whatever the gesture that ended in this mouseup left behind.
*
* ## What the mouse mode is worth, honestly
*
* Reading it fresh makes it *current*; it does not make it *trustworthy*. The
* mode is set by the container, with a DECSET, and `?1002l` takes effect as
* soon as xterm *parses* it (on its queued write task, not synchronously with
* the container's output) so a hostile container can drop tracking for
* a few hundred milliseconds at a time and a plain click that lands in one of
* those windows passes the mode half of the gate. It cannot time the user's
* click, but it does not need to: a fraction of clicks is enough, and the only
* tell is the status-bar badge flickering. This is a **known residual**, not
* something this gate closes, and the freshness of the read must not be read
* as an answer to it.
*
* Two things narrow it, neither of which depends on the mode. The selection
* and click-count checks hold in either tracking state, so the gestures a user
* makes to copy text are refused whatever the container has the mode set to
* which removes the "wrap every row in an OSC 8 and harvest the shift-drags"
* version entirely. And the hover card's promise is sticky (see
* `modifierPromised`): the flicker now has to cover the *hover* as well as the
* click, because a card drawn while tracking was on goes on demanding the
* modifier after the container drops it. What remains is a container that
* drops tracking before the pointer arrives and holds it off until the click
* at which point the card also says "Click to open", so the user is at least
* not being told one thing and given another. The real fix is a signal the
* container cannot write, and there is none in this pane today.
*
* ## The hover card is the security half, not a nicety
*
* OSC 8 fully decouples the visible text from the target: the container can
* print `https://claude.ai` and link it anywhere. That is strictly worse than
* the userinfo spoofing `sanitizeRelayUrl` already rejects, because here
* nothing in the painted row is even *derived* from the destination. So the
* origin of the real target is shown before the user commits, the same way the
* URL toast shows it and for the same reason ({@link urlOrigin}'s note): the
* origin decides where the user's credentials end up, so it is rendered in
* full and the *remainder* is the only part an ellipsis may eat.
*
* The card sits at the bottom of the pane rather than beside the pointer
* where a browser puts it, and never underneath the cursor, so it cannot
* flicker the link out from under the hover that summoned it.
*
* @param getHost returns `Terminal.element`, which does not exist until
* `term.open()` has run hence a getter rather than the element.
* @param readState samples {@link ClickContext} a getter for the same
* reason, and the *only* reason: every one of those answers changes
* under us, between the hover and the click that follows it.
* @param onOpenFile receives the decoded path of a `file:` target, which
* opens in the file viewer rather than the host browser. Without it a
* `file:` target is inert.
*/
export function createOsc8LinkHandler(
getHost: () => HTMLElement | null,
readState: () => ClickContext,
onOpenFile?: (path: string) => void,
): Osc8LinkHandler {
let card: HTMLDivElement | null = null;
/**
* Did the card the user is looking at name a modifier?
*
* Written whenever a card is drawn, and cleared with it `hover()` clears
* and returns early when there is no host element, which leaves this false,
* the stricter of the two directions. xterm only activates a link
* it is currently hovering (`Linkifier._currentLink`), so there is always a
* fresh hover behind a click which is what makes this the promise the user
* actually read, rather than a stale one. See `opensOnClick`.
*/
let modifierPromised = false;
const clear = () => {
card?.remove();
card = null;
modifierPromised = false;
};
const span = (text: string, style: Partial<CSSStyleDeclaration>) => {
const el = document.createElement("span");
el.textContent = text;
Object.assign(el.style, style);
return el;
};
const makeCard = (): HTMLDivElement => {
const el = document.createElement("div");
el.className = OSC8_HOVER_CLASS;
el.dataset.testid = "osc8-hover";
Object.assign(el.style, {
position: "absolute",
left: "8px",
bottom: "8px",
maxWidth: "calc(100% - 16px)",
boxSizing: "border-box",
zIndex: "30",
// The card lands under the pointer for a link in the bottom rows, and
// it is not a sibling the Linkifier hit-tests around (see
// `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave`
// the moment the card appears — card removed, pointer back on the
// link, card back: a flicker loop — and worse, the `mouseup` that
// activates the link lands on the card, so the link cannot be opened
// at all. Nothing here is interactive, so nothing is lost.
pointerEvents: "none",
display: "flex",
alignItems: "baseline",
gap: "6px",
padding: "3px 8px",
fontSize: "12px",
fontFamily: "monospace",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
// The origin wraps, so the card grows downward rather than sideways;
// this is the backstop for anything that still cannot fit.
overflow: "hidden",
borderRadius: "6px",
boxShadow: "var(--shadow-overlay)",
color: "var(--text-primary)",
} as Partial<CSSStyleDeclaration>);
return el;
};
/** "Open in viewer", the path, and the same hint line as a web link. */
const fillFileCard = (el: HTMLDivElement, path: string, ctx: ClickContext) => {
el.appendChild(span("Open in viewer", { fontWeight: "700", flexShrink: "0" }));
const pathEl = span(path, {
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: "0",
});
pathEl.dataset.testid = "osc8-hover-path";
el.appendChild(pathEl);
el.appendChild(
span(openHintLabel(ctx), {
color: "var(--text-secondary)",
flexShrink: "0",
marginLeft: "4px",
}),
);
};
return {
activate(event, text) {
// Opening the host browser is the one thing in this pane the container
// may not provoke on its own *and* the one thing no selection gesture
// may provoke by accident. See `opensOnClick` — including the residual
// it does not close.
if (!opensOnClick(event, readState(), modifierPromised)) return;
const target = classifyOsc8Target(text);
if (!target) {
console.warn("Refusing to open a link with an unsupported or malformed target");
return;
}
if (target.kind === "file") {
if (!onOpenFile) return;
onOpenFile(target.path);
return;
}
// Same sink and same rule as the WebLinksAddon branch: this came off the
// container's output, so it is validated before it reaches the OS
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
const safe = sanitizeRelayUrl(text);
if (!safe) {
console.warn("Refusing to open a link that failed validation");
return;
}
openUrlExternal(safe).catch(reportOpenFailure);
},
hover(_event, text) {
clear();
const host = getHost();
if (!host) return;
const ctx = readState();
// Sampled here and held, because this is what the card is about to tell
// the user — and the gate has to honour it even if the container has
// moved on by the time they click.
modifierPromised = ctx.mouseTracking;
card = makeCard();
const target = classifyOsc8Target(text);
// Without a viewer to hand it to, a `file:` target falls through to the
// refusal card below rather than offering something the click won't do.
if (target?.kind === "file" && onOpenFile) {
fillFileCard(card, target.path, ctx);
host.appendChild(card);
return;
}
const safe = sanitizeRelayUrl(text);
const origin = safe && urlOrigin(safe);
if (!safe || !origin) {
// Nothing of the rejected target is echoed into the DOM — it is
// untrusted text, and the only useful thing to say is that the click
// will not do anything. Deliberately not "it is not a web address":
// `https://claude.ai@evil.tld/` and an over-length URL both are one,
// and a card that explains a refusal wrongly teaches the user to
// distrust the card.
card.appendChild(
span("This link will not be opened — it failed the URL safety check", {
color: "var(--text-secondary)",
}),
);
} else {
const rest = safe.startsWith(origin) ? safe.slice(origin.length) : safe;
const originEl = span(origin, {
fontWeight: "700",
// The part that decides where the credentials go, so all of it is
// shown: truncating it *is* the spoof, and so is pushing its tail
// off the right edge of the pane. The attacker picks the length —
// `https://claude.ai.<300 chars>.evil.tld` parses and passes every
// `sanitizeRelayUrl` rule — so "do not shrink" is not enough:
// `flex-shrink: 0` pins a flex item at its max-content width and the
// text never wraps, it just overflows. It wraps instead, onto as
// many lines as it needs, and the truncatable remainder below is the
// thing that gives way.
flexShrink: "1",
minWidth: "0",
overflowWrap: "anywhere",
whiteSpace: "normal",
});
originEl.dataset.testid = "osc8-hover-origin";
card.appendChild(originEl);
const restEl = span(rest, {
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: "0",
});
restEl.dataset.testid = "osc8-hover-rest";
card.appendChild(restEl);
const hint = span(openHintLabel(ctx), {
color: "var(--text-secondary)",
flexShrink: "0",
marginLeft: "4px",
});
card.appendChild(hint);
}
host.appendChild(card);
},
leave: clear,
dismiss: clear,
showFileCard(path) {
clear();
const host = getHost();
if (!host) return;
const ctx = readState();
// Same promise as `hover`: the card names a modifier, so the click is
// held to it even if the container drops tracking before it lands.
modifierPromised = ctx.mouseTracking;
card = makeCard();
fillFileCard(card, path, ctx);
host.appendChild(card);
},
opensFileLink(event) {
return opensOnClick(event, readState(), modifierPromised);
},
allowNonHttpProtocols: true,
};
}
export default function TerminalView({ sessionId, active }: Props) { export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null); const terminalContainerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null); const webglRef = useRef<WebglAddon | null>(null);
// Held only so the hover card can be taken down when this pane leaves the
// screen — see `Osc8LinkHandler.dismiss`.
const osc8LinkHandlerRef = useRef<Osc8LinkHandler | null>(null);
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null); const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
@@ -124,6 +721,13 @@ export default function TerminalView({ sessionId, active }: Props) {
const projectId = useAppState( const projectId = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
); );
// The file viewer opens against the session's project. Read through a ref
// because the link handlers are built in the mount effect, keyed on
// `sessionId` only, and the session record can arrive after the first render.
const projectIdRef = useRef<string | undefined>(projectId);
useEffect(() => {
projectIdRef.current = projectId;
}, [projectId]);
// Which program is on the other end of the PTY. Read through a ref because // Which program is on the other end of the PTY. Read through a ref because
// the key handler is registered once, in the mount effect keyed on // the key handler is registered once, in the mount effect keyed on
@@ -378,7 +982,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const syncMouseCapture = useCallback(() => { const syncMouseCapture = useCallback(() => {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
const captured = term.modes.mouseTrackingMode !== "none"; const captured = terminalTracksMouse(term);
if (captured === mouseCapturedRef.current) return; if (captured === mouseCapturedRef.current) return;
mouseCapturedRef.current = captured; mouseCapturedRef.current = captured;
setMouseCaptured(captured); setMouseCaptured(captured);
@@ -410,7 +1014,10 @@ export default function TerminalView({ sessionId, active }: Props) {
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const term = new Terminal({ // Annotated because `linkHandler` below refers to `term` (for the element
// it must anchor its hover card to, which does not exist until
// `term.open()`), and TypeScript cannot infer a type it is already using.
const term: Terminal = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
// Let the user select text even while a program holds the mouse. // Let the user select text even while a program holds the mouse.
@@ -420,6 +1027,18 @@ export default function TerminalView({ sessionId, active }: Props) {
// the only way to copy from a mouse-driven TUI is to take the mouse back // the only way to copy from a mouse-driven TUI is to take the mouse back
// first. `SelectionService.shouldForceSelection`. // first. `SelectionService.shouldForceSelection`.
macOptionClickForcesSelection: true, macOptionClickForcesSelection: true,
// OSC 8 hyperlinks — the form Claude Code prints its links in, and the
// one `WebLinksAddon` structurally cannot match. See
// `createOsc8LinkHandler`, including why opening one while a program
// holds the mouse needs the same Shift/Option the line above is about.
// Both arguments are getters because neither answer exists yet: the
// element arrives with `term.open()`, and the mouse mode changes
// whenever the container prints a DECSET.
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
() => term.element ?? null,
() => readClickContext(term),
(path) => openInViewer(projectIdRef.current, path),
)),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: { theme: {
background: "#0d1117", background: "#0d1117",
@@ -453,31 +1072,58 @@ export default function TerminalView({ sessionId, active }: Props) {
// misses OAuth URLs that end mid-line). // misses OAuth URLs that end mid-line).
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/; const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
const webLinksAddon = new WebLinksAddon((_event, uri) => { const webLinksAddon = new WebLinksAddon((event, uri) => {
// Same sink, same rule: what xterm matched came off the container's // Same gate and same sink as `createOsc8LinkHandler`, because this
// output, so it is validated before it reaches the OS opener. A click // reaches the same `openUrlExternal` through the same
// here is a deliberate act on visible text, but "visible" is exactly // `Linkifier._handleMouseUp`, which checks nothing here either. Without
// what a userinfo-spoofed URL subverts. // it a container that prints a plausible-looking `https://` row in a TUI
// got a browser open on a plain click while it held the mouse, and a
// double-click that merely selected a URL opened it.
//
// `allowNonHttpProtocols` is now **on**, so `OscLinkProvider` hands every
// OSC 8 target to `createOsc8LinkHandler`, which parses it and refuses
// anything but `file:` (the file viewer) and `http(s):` itself. This
// branch remains the only handler for plain-text URLs.
//
// No `modifierPromised`: this path paints an underline rather than a
// card, so it promises the user nothing to be held to.
//
// This branch is the one where the click really is an act on visible
// text — the match *is* the painted characters — so the spoof it has to
// survive is a userinfo-spoofed URL, which `sanitizeRelayUrl` rejects.
// An OSC 8 link is not like that at all: its label and its target are
// unrelated strings, which is why that handler shows the target's origin
// on hover before a click can happen. Neither branch replaces the other:
// this one covers plain-text URLs in ordinary shell output, which carry
// no hyperlink parameter for xterm to hand over.
if (!opensOnClick(event, readClickContext(term))) return;
const safe = sanitizeRelayUrl(uri); const safe = sanitizeRelayUrl(uri);
if (!safe) { if (!safe) {
console.warn("Refusing to open a link that failed validation"); console.warn("Refusing to open a link that failed validation");
return; return;
} }
// Same failure reporting as the toast's Open button — see the long note openUrlExternal(safe).catch(reportOpenFailure);
// on `handleOpenUrl`, including what this catch does *not* catch on
// Linux. A click that appears to do nothing is the complaint either way.
openUrlExternal(safe).catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
detail: String(e),
// A dead opener fails for every link in the buffer. One card.
dedupeKey: "host-open-failed",
}),
);
}, { urlRegex }); }, { urlRegex });
term.loadAddon(webLinksAddon); term.loadAddon(webLinksAddon);
// File paths in plain text open in the file viewer. Registered after the
// addon so URLs are claimed first; the matcher also refuses anything
// inside a `scheme://` span. The hover card is the OSC 8 handler's, fed the
// raw path, and the click gate honours the modifier that card promised.
const filePathLinks = term.registerLinkProvider(
createFilePathLinkProvider(
term,
(m: FilePathMatch) =>
openInViewer(projectIdRef.current, m.path, m.line, m.col, m.endLine),
// Held to what the card promised, like OSC 8 links (see `showFileCard`).
(event) => osc8LinkHandlerRef.current?.opensFileLink(event) ?? false,
{
show: (path) => osc8LinkHandlerRef.current?.showFileCard(path),
hide: () => osc8LinkHandlerRef.current?.dismiss(),
},
),
);
term.open(containerRef.current); term.open(containerRef.current);
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding // Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
@@ -761,11 +1407,22 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null; webglRef.current = null;
filePathLinks.dispose();
term.dispose(); term.dispose();
termRef.current = null; termRef.current = null;
osc8LinkHandlerRef.current = null;
}; };
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
// A hover card only ever clears on a *pointer* leaving the link, so switching
// tabs from the keyboard leaves one hanging over a pane nobody is looking at,
// to be found still there on the way back. Hiding the wrapper does not fire
// `mouseleave`, so nothing else would.
useEffect(() => {
if (active) return;
osc8LinkHandlerRef.current?.dismiss();
}, [active]);
// Manage WebGL lifecycle and re-fit when tab becomes active. // Manage WebGL lifecycle and re-fit when tab becomes active.
// Only the active terminal holds a WebGL context to avoid exhausting // Only the active terminal holds a WebGL context to avoid exhausting
// the browser's limited pool (~8-16 contexts). // the browser's limited pool (~8-16 contexts).
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
const fakeTerm = (rows: Array<[string, boolean]>) => ({
buffer: {
active: {
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
},
},
}) as unknown as Parameters<typeof createFilePathLinkProvider>[0];
describe("createFilePathLinkProvider", () => {
it("reports 1-based inclusive ranges and activates through the gate", () => {
const onOpen = vi.fn();
const gate = vi.fn(() => true);
const provider = createFilePathLinkProvider(fakeTerm([["Edited src/foo.ts:42 today", false]]), onOpen, gate);
const links = vi.fn();
provider.provideLinks(1, links);
const [list] = links.mock.calls[0];
expect(list).toHaveLength(1);
expect(list[0].range).toEqual({ start: { x: 8, y: 1 }, end: { x: 20, y: 1 } });
expect(list[0].text).toBe("src/foo.ts:42");
list[0].activate(new MouseEvent("click"), list[0].text);
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: "src/foo.ts", line: 42 }));
});
it("does nothing when the gate refuses", () => {
const onOpen = vi.fn();
const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), onOpen, () => false);
const links = vi.fn();
provider.provideLinks(1, links);
links.mock.calls[0][0][0].activate(new MouseEvent("click"), "src/foo.ts");
expect(onOpen).not.toHaveBeenCalled();
});
it("spans a wrapped path across rows", () => {
const provider = createFilePathLinkProvider(fakeTerm([["see /workspace/p/", false], ["src/foo.ts:7", true]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(2, links);
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
});
it("answers undefined for a row with nothing", () => {
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(1, links);
expect(links).toHaveBeenCalledWith(undefined);
});
it("answers undefined for a row past the end of the buffer", () => {
const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(5, links);
expect(links).toHaveBeenCalledWith(undefined);
});
it("hands the hover the raw path, relative as printed (preflight P6)", () => {
const hover = { show: vi.fn(), hide: vi.fn() };
const provider = createFilePathLinkProvider(
fakeTerm([["Edited src/foo.ts:42 today", false]]), vi.fn(), () => true, hover,
);
const links = vi.fn();
provider.provideLinks(1, links);
const link = links.mock.calls[0][0][0];
link.hover(new MouseEvent("mousemove"), link.text);
expect(hover.show).toHaveBeenCalledWith("src/foo.ts");
link.leave(new MouseEvent("mouseout"), link.text);
expect(hover.hide).toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
/**
* xterm `ILinkProvider` for file paths in the buffer.
*
* Registered after `WebLinksAddon` so URLs are claimed first; `findFilePathLinks`
* also refuses anything inside a `scheme://` span, so the two never overlap.
* Ranges are 1-based on both axes with an *inclusive* end column (xterm's
* contract), and `provideLinks`' row is 1-based while `getLine` is 0-based.
*/
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import { findFilePathLinks, type FilePathMatch } from "../../lib/filePathLinks";
import { joinWrappedRows, offsetToCell } from "../../lib/xtermLineJoin";
export interface FilePathHover {
/** `path` is the raw matched path — relative paths stay relative. */
show(path: string): void;
hide(): void;
}
export function createFilePathLinkProvider(
term: Pick<Terminal, "buffer">,
onOpen: (match: FilePathMatch) => void,
gate: (event: MouseEvent) => boolean,
hover?: FilePathHover,
): ILinkProvider {
return {
provideLinks(bufferLineNumber, callback) {
const row = bufferLineNumber - 1;
const line = term.buffer.active.getLine(row);
if (!line) return callback(undefined);
const joined = joinWrappedRows(term.buffer.active, row);
// `offsetToCell` has no row to map onto when nothing was joined.
if (joined.rowStarts.length === 0) return callback(undefined);
const matches = findFilePathLinks(joined.text);
if (matches.length === 0) return callback(undefined);
const links: ILink[] = matches
.map((m): ILink => ({
range: { start: offsetToCell(joined, m.start), end: offsetToCell(joined, m.end - 1) },
text: joined.text.slice(m.start, m.end),
decorations: { pointerCursor: true, underline: true },
activate: (event) => {
if (!gate(event)) return;
onOpen(m);
},
hover: () => hover?.show(m.path),
leave: () => hover?.hide(),
}))
// Only links that touch the row being asked about (xterm asks per row).
.filter((l) => l.range.start.y <= bufferLineNumber && l.range.end.y >= bufferLineNumber);
callback(links.length ? links : undefined);
},
};
}
+9
View File
@@ -33,6 +33,15 @@
/* Two radii only: controls and panels. */ /* Two radii only: controls and panels. */
--radius-control: 6px; --radius-control: 6px;
--radius-panel: 8px; --radius-panel: 8px;
/* Syntax colours for the file viewer's editor (viewer/viewerTheme.ts). Same
GitHub-dark palette TerminalView.tsx already uses for ANSI, expressed as
tokens rather than hard-coded hex per the styling convention above. */
--syntax-keyword: #ff7b72;
--syntax-string: #a5d6ff;
--syntax-number: #79c0ff;
--syntax-function: #d2a8ff;
--syntax-type: #ffa657;
--syntax-property: #7ee787;
color-scheme: dark; color-scheme: dark;
} }
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { findFilePathLinks } from "./filePathLinks";
const one = (text: string) => {
const m = findFilePathLinks(text);
expect(m, text).toHaveLength(1);
return m[0];
};
describe("findFilePathLinks — what is a path", () => {
it.each([
["src/foo.ts", "src/foo.ts"],
["/workspace/x/README.md", "/workspace/x/README.md"],
["./scripts/build.sh", "./scripts/build.sh"],
["../other/Cargo.toml", "../other/Cargo.toml"],
["Makefile", "Makefile"],
["Dockerfile", "Dockerfile"],
["CLAUDE.md", "CLAUDE.md"],
[".gitignore", ".gitignore"],
["app/src-tauri/src/lib.rs", "app/src-tauri/src/lib.rs"],
["my-dir/some_file.test.tsx", "my-dir/some_file.test.tsx"],
])("matches %s", (text, path) => {
expect(one(text).path).toBe(path);
});
it.each([
"1.2.3",
"v2.11.0",
"example.com",
"claude.ai",
"e.g.",
"https://example.com/a/b.ts",
"http://localhost:1420/viewer.html",
"foo",
"a.b",
"10.0.0.1",
"and/or",
"src/components",
])("does not match %s", (text) => {
expect(findFilePathLinks(text)).toEqual([]);
});
it("matches a slash-less token only with a known source/doc extension", () => {
expect(one("index.ts").path).toBe("index.ts");
expect(one("notes.md").path).toBe("notes.md");
expect(findFilePathLinks("archive.xyz")).toEqual([]);
// With a slash, any extension will do.
expect(one("dist/archive.xyz").path).toBe("dist/archive.xyz");
});
});
describe("findFilePathLinks — line and column suffixes", () => {
it("parses :line", () => {
expect(one("src/foo.ts:42")).toMatchObject({ path: "src/foo.ts", line: 42 });
});
it("parses :line:col", () => {
expect(one("src/foo.ts:42:7")).toMatchObject({ path: "src/foo.ts", line: 42, col: 7 });
});
it("parses :start-end", () => {
expect(one("app/src/lib/urlRelay.ts:139-150")).toMatchObject({ path: "app/src/lib/urlRelay.ts", line: 139, endLine: 150 });
});
it("parses #L42 and #L40-L50", () => {
expect(one("README.md#L42")).toMatchObject({ path: "README.md", line: 42 });
expect(one("README.md#L40-L50")).toMatchObject({ path: "README.md", line: 40, endLine: 50 });
});
it("does not read a trailing colon as a line", () => {
expect(one("Edited src/foo.ts:")).toMatchObject({ path: "src/foo.ts", line: undefined });
});
});
describe("findFilePathLinks — markdown wrapping and offsets", () => {
it.each([
["`src/foo.ts`", 1, 11],
["(src/foo.ts)", 1, 11],
["[src/foo.ts]", 1, 11],
['"src/foo.ts"', 1, 11],
["'src/foo.ts'", 1, 11],
["see src/foo.ts.", 4, 14],
["see src/foo.ts, then", 4, 14],
["see src/foo.ts;", 4, 14],
])("strips wrapping in %s", (text, start, end) => {
expect(one(text)).toMatchObject({ path: "src/foo.ts", start, end });
});
it("keeps the :line suffix inside the span", () => {
// "at `" is 4 characters; the span covers `src/foo.ts:42` (13 chars).
expect(one("at `src/foo.ts:42`")).toMatchObject({ path: "src/foo.ts", line: 42, start: 4, end: 17 });
});
it("finds several paths in one line, in order", () => {
const m = findFilePathLinks("Read src/a.ts and src/b.rs:3, wrote docs/c.md");
expect(m.map((x) => x.path)).toEqual(["src/a.ts", "src/b.rs", "docs/c.md"]);
expect(m[1].line).toBe(3);
});
it("skips anything inside a URL", () => {
expect(findFilePathLinks("see https://github.com/o/r/blob/main/src/foo.ts:12 now")).toEqual([]);
expect(one("see https://x.io/a and src/foo.ts").path).toBe("src/foo.ts");
});
it("ignores a Claude tool header like ⏺ Read(src/foo.ts) except for the path", () => {
expect(one("⏺ Read(src/foo.ts)").path).toBe("src/foo.ts");
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Finds file paths in a line of terminal text.
*
* Pure: the xterm glue (`components/terminal/filePathLinkProvider.ts`) turns
* buffer rows into a string and string offsets back into cells; this decides
* what a path is. Deliberately conservative a false link is an annoying
* underline, a missed one is a copy-paste so a token needs either a `/` or
* a known extension, and never sits inside a URL.
*/
export interface FilePathMatch {
/** Indices into the input; `end` exclusive. Covers path + suffix, not wrapping. */
start: number;
end: number;
path: string;
line?: number;
col?: number;
endLine?: number;
}
/** Extensions that make a slash-less token (`index.ts`, `notes.md`) a path. */
const KNOWN_EXTENSIONS = new Set([
"md", "markdown", "txt", "rst", "json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf",
"env", "lock", "js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "sh", "bash", "zsh",
"fish", "ps1", "html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less", "sql",
"graphql", "proto", "diff", "patch", "csv", "tsv", "log", "svg", "png", "jpg", "jpeg", "gif",
"webp",
]);
/** Extensionless names that are files by convention. */
const KNOWN_BASENAMES = new Set([
"Makefile", "Dockerfile", "Rakefile", "Gemfile", "Procfile", "Vagrantfile", "LICENSE",
"README", "CHANGELOG", "PKGBUILD",
]);
/**
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
*/
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
const TRAILING_PUNCT = /[.,;:]+$/;
function isPathLike(token: string): boolean {
if (VERSION_LIKE.test(token)) return false;
const base = token.slice(token.lastIndexOf("/") + 1);
if (base === "" || base === "." || base === "..") return false;
if (KNOWN_BASENAMES.has(base)) return true;
const hasSlash = token.includes("/");
const dot = base.lastIndexOf(".");
if (dot === 0) {
// Dotfile (.gitignore, .env). With a slash the name itself counts as
// "having an extension"; without one it must be a known dotfile.
if (hasSlash) return true;
return KNOWN_EXTENSIONS.has(base.slice(1).toLowerCase()) || base === ".gitignore" || base === ".env";
}
if (dot < 0) return false; // no extension at all — never a path
// A real extension. With a slash any extension will do; without one it
// must be a known source/doc extension.
if (hasSlash) return true;
return KNOWN_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
}
function urlSpans(text: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
for (const m of text.matchAll(URL_SCHEME)) {
const start = m.index ?? 0;
// A URL runs to the next whitespace or closing bracket/quote.
const rest = text.slice(start);
const len = rest.search(/[\s)\]'"`>]/);
spans.push([start, len < 0 ? text.length : start + len]);
}
return spans;
}
export function findFilePathLinks(text: string): FilePathMatch[] {
const urls = urlSpans(text);
const insideUrl = (i: number) => urls.some(([s, e]) => i >= s && i < e);
const out: FilePathMatch[] = [];
for (const m of text.matchAll(TOKEN)) {
const start = m.index ?? 0;
let token = m[0];
if (insideUrl(start)) continue;
// Trailing sentence punctuation is not part of the name.
const trimmed = token.replace(TRAILING_PUNCT, "");
if (trimmed !== token) token = trimmed;
if (token.endsWith("/")) token = token.slice(0, -1);
if (!token || !isPathLike(token)) continue;
let end = start + token.length;
// `line`/`col`/`endLine` are set explicitly to `undefined` (rather than
// left absent) so callers that assert on them with `toMatchObject` see
// the key, not a missing property.
const match: FilePathMatch = { start, end, path: token, line: undefined, col: undefined, endLine: undefined };
// The suffix sits right after the *trimmed* token: `TOKEN` may have
// consumed a trailing `.` that `TRAILING_PUNCT` then removed, so search
// from `start + token.length`, not from the end of the raw match.
const after = text.slice(start + token.length);
const s = LINE_SUFFIX.exec(after);
if (s) {
if (s[1] !== undefined) {
match.line = Number(s[1]);
if (s[2] !== undefined) match.col = Number(s[2]);
if (s[3] !== undefined) match.endLine = Number(s[3]);
} else if (s[4] !== undefined) {
match.line = Number(s[4]);
if (s[5] !== undefined) match.endLine = Number(s[5]);
}
end += s[0].length;
match.end = end;
}
out.push(match);
}
return out;
}
+20 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types"; import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -413,3 +413,22 @@ export const getMigrationState = (projectId: string) =>
* Rejects with a string already phrased for a toast. */ * Rejects with a string already phrased for a toast. */
export const openUrlExternal = (url: string) => export const openUrlExternal = (url: string) =>
invoke<void>("open_url_external", { url }); invoke<void>("open_url_external", { url });
// ---- Terminal file viewer ----
export const openFileViewer = (
projectId: string,
path: string,
line?: number,
col?: number,
endLine?: number,
) => invoke<void>("open_file_viewer", { projectId, path, line, col, endLine });
export const viewerGetState = () => invoke<ViewerState>("viewer_get_state");
export const viewerReadFile = (maxBytes: number) =>
invoke<ViewerFile>("viewer_read_file", { maxBytes });
export const viewerPollFile = () => invoke<ViewerPoll>("viewer_poll_file");
export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
export const viewerChooseFile = (index: number) =>
invoke<ViewerState>("viewer_choose_file", { index });
+46
View File
@@ -954,3 +954,49 @@ export interface MigrationState {
options: MigrationOptions; options: MigrationOptions;
plan: MigrationPlan | null; plan: MigrationPlan | null;
} }
// ---- Terminal file viewer (commands/file_viewer_commands.rs) ----
export interface ViewerLocation {
line: number | null;
col: number | null;
end_line: number | null;
}
export type ViewerTargetState =
| { kind: "resolved"; container_path: string }
| { kind: "choose"; candidates: string[] }
| { kind: "not_found"; tried: string[] };
export interface ViewerState {
project_id: string;
project_name: string;
/** What was clicked, for the title and the not-found message. */
raw_path: string;
state: ViewerTargetState;
initial: ViewerLocation;
}
export interface ViewerFile {
contents_base64: string;
truncated: boolean;
size: number;
/** SHA-256 hex of the returned bytes; equals the file's hash when `truncated` is false. */
hash: string;
editable: boolean;
readonly_reason: string | null;
}
/** A successful save (`write.rs`'s `SavedFile`). */
export interface ViewerSaved {
/** SHA-256 of the bytes written: the editor's new base hash. */
hash: string;
/** What the container hashed right after the swap; differs from `hash` only if another writer landed first. */
disk_hash: string;
}
export interface ViewerPoll {
exists: boolean;
hash: string | null;
size: number | null;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { joinWrappedRows, MAX_JOINED_LENGTH, offsetToCell, type RowSource } from "./xtermLineJoin";
/** rows[i] = [text, isWrapped] */
const buffer = (rows: Array<[string, boolean]>): RowSource => ({
getLine: (y) =>
rows[y] ? { isWrapped: rows[y][1], translateToString: (trim?: boolean) => (trim ? rows[y][0].trimEnd() : rows[y][0]) } : undefined,
});
describe("joinWrappedRows", () => {
it("returns a single unwrapped row as-is", () => {
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
});
it("walks up to the row that started the wrap and down through continuations", () => {
const b = buffer([
["unrelated", false],
["/workspace/very/long/pa", false],
["th/to/file.ts:12 and mo", true],
["re text", true],
["next line", false],
]);
const fromMiddle = joinWrappedRows(b, 2);
expect(fromMiddle.text).toBe("/workspace/very/long/path/to/file.ts:12 and more text");
expect(fromMiddle.firstRow).toBe(1);
expect(fromMiddle.rowStarts).toEqual([0, 23, 46]);
expect(joinWrappedRows(b, 1)).toEqual(fromMiddle);
expect(joinWrappedRows(b, 3)).toEqual(fromMiddle);
});
it("stops at the length budget", () => {
const rows: Array<[string, boolean]> = [["a".repeat(1000), false]];
for (let i = 0; i < 5; i++) rows.push(["b".repeat(1000), true]);
const j = joinWrappedRows(buffer(rows), 0);
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
expect(j.rowStarts.length).toBe(2);
});
});
describe("offsetToCell", () => {
it("maps offsets to 1-based cells on the right row", () => {
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
expect(offsetToCell(j, 0)).toEqual({ x: 1, y: 5 });
expect(offsetToCell(j, 2)).toEqual({ x: 3, y: 5 });
expect(offsetToCell(j, 3)).toEqual({ x: 1, y: 6 });
expect(offsetToCell(j, 7)).toEqual({ x: 2, y: 7 });
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Joins an xterm buffer row with its wrapped continuations.
*
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
* built package does not export so the walk is repeated here, with the same
* 2048-character budget. Rows are read with `translateToString(true)`, which
* trims the right edge; a wrap never ends in trailing spaces xterm would keep,
* so the join is exact for the text a path can occur in.
*
* Wide characters (CJK, emoji) occupy two cells but one string index, so a
* column computed from a string offset drifts right of the glyph on such rows.
* The addon corrects this with `getCell`; v1 accepts the drift (underline
* lands a cell early; the click still resolves the same link).
*/
export const MAX_JOINED_LENGTH = 2048;
/** Minimal slice of xterm's IBuffer this needs. */
export interface RowSource {
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
}
export interface JoinedLine {
text: string;
/** 0-based index of the first buffer row that contributed. */
firstRow: number;
/** For each contributed row (in order), the string offset at which it starts. */
rowStarts: number[];
}
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
let top = row;
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
const parts: string[] = [];
let length = 0;
let y = top;
for (;;) {
const line = buffer.getLine(y);
if (!line) break;
if (y !== top && !line.isWrapped) break;
const text = line.translateToString(true);
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
parts.push(text);
length += text.length;
y++;
}
const rowStarts: number[] = [];
let offset = 0;
for (const p of parts) {
rowStarts.push(offset);
offset += p.length;
}
return { text: parts.join(""), firstRow: top, rowStarts };
}
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
let rowIdx = 0;
for (let i = 0; i < joined.rowStarts.length; i++) {
if (joined.rowStarts[i] <= offset) rowIdx = i;
}
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
}
+472
View File
@@ -0,0 +1,472 @@
import { describe, it, expect } from "vitest";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { dirname, join, relative, resolve, sep } from "path";
import { builtinModules } from "module";
import ts from "typescript";
/**
* The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only
* invoke the app commands its file grants; `build.rs` proves every command is granted in the
* file its name says it belongs to. This proves the other half: the code that *runs* in each
* window imports only wrappers that window is granted. A wrapper imported on the wrong side
* fails here, not with `Command … not allowed by ACL` in a release build.
*
* It works on imports rather than `invoke(` literals because the viewer never calls invoke:
* everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import
* `@tauri-apps/api/core` (that rule is what makes this test complete).
*
* Every file is parsed with the TypeScript compiler (`ts.createSourceFile`), not scanned with
* regexes, so comments, strings, template substitutions and regex literals are the parser's
* problem rather than ours. Anything the walk below cannot account for a computed `import()`,
* a path alias, a namespace of the wrappers handed around as a value throws (fail-closed);
* nothing is ever skipped quietly.
*/
const srcDir = resolve(__dirname, "..");
const capDir = resolve(srcDir, "../src-tauri/capabilities");
const nodeModulesDir = resolve(srcDir, "../node_modules");
const WRAPPERS = resolve(srcDir, "lib/tauri-commands.ts");
const VIEWER_ENTRY = resolve(srcDir, "viewer/main.tsx");
const toPermission = (command: string) => `allow-${command.replace(/_/g, "-")}`;
function readCapability(file: string) {
const cap = JSON.parse(readFileSync(resolve(capDir, file), "utf-8")) as {
windows: string[];
permissions: (string | { identifier: string })[];
};
const ids = cap.permissions.map((p) => (typeof p === "string" ? p : p.identifier));
return {
windows: cap.windows,
bare: ids.filter((id) => !id.includes(":")).sort(),
prefixed: ids.filter((id) => id.includes(":")).sort(),
};
}
/** A code extension: anything Vite would run as a module rather than serve as an asset. */
const CODE_EXTENSION = /\.(mjs|js|mts|ts|jsx|tsx|cjs|cts)$/;
/** Every code file under src/, tests and src/test included. */
function codeFiles(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) codeFiles(path, out);
else if (CODE_EXTENSION.test(name)) out.push(path);
}
return out;
}
/** Code that ships in a window: not under src/test, not a `*.test.*`, not a declaration file. */
const isAppSource = (file: string) =>
!relative(srcDir, file).startsWith(`test${sep}`) && !/\.test\.[^./]+$/.test(file) && !/\.d\.[cm]?ts$/.test(file);
const rel = (file: string) => relative(srcDir, file);
function fail(file: string, node: ts.Node | undefined, message: string): never {
const where = node
? `:${node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1}`
: "";
throw new Error(`${rel(file)}${where}: ${message}`);
}
function parse(file: string): ts.SourceFile {
const kind = /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
return ts.createSourceFile(file, readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true, kind);
}
/** Depth-first visit of every node (JSDoc is not a child, so comments never show up). */
function walk(node: ts.Node, visit: (n: ts.Node) => void) {
visit(node);
ts.forEachChild(node, (child) => walk(child, visit));
}
/** Specifiers that reach Tauri's raw `invoke`: `core` itself, and the package root, which
* re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */
const INVOKE_SPECIFIER = /^@tauri-apps\/api(\/(core|index)(\.[cm]?js)?)?\/?$/;
/** Vite 6's default `resolve.extensions`, in its order (vite.config.ts does not override it). */
const VITE_EXTENSIONS = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"];
/** A bare npm package name (optionally scoped) followed by an optional subpath. */
const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i;
const isFile = (p: string) => existsSync(p) && statSync(p).isFile();
const isDir = (p: string) => existsSync(p) && statSync(p).isDirectory();
/**
* Vite 6's `tryCleanFsResolve` for a relative path, step for step, so the file analysed is the
* file Vite would load: the exact path if it is a file; else a `.js`/`.mjs`/`.cjs`/`.jsx` path's
* TypeScript twin (`.js` `.ts`, then `.tsx`); else `path + ext` over VITE_EXTENSIONS in order
* (so `shadow.mjs` beats `shadow.ts`, and `./evil.impl` finds `evil.impl.ts`); else, for a
* directory, `index + ext` in the same order. A directory with a package.json would switch Vite to
* package-entry resolution, which this test does not model, so it fails closed.
*/
function viteResolveRelative(path: string, from: string, node: ts.Node): string | undefined {
if (isFile(path)) return path;
if (/\.(?:js|mjs|cjs|jsx)$/.test(path)) {
const ext = path.slice(path.lastIndexOf("."));
const stem = path.slice(0, -ext.length);
const twin = [stem + ext.replace("js", "ts"), ...(ext === ".js" ? [`${stem}.tsx`] : [])].find(isFile);
if (twin) return twin;
}
const withExt = VITE_EXTENSIONS.map((e) => path + e).find(isFile);
if (withExt) return withExt;
if (isDir(path)) {
if (existsSync(join(path, "package.json"))) {
fail(from, node, `imports directory ${rel(path)}, which has a package.json this test does not model`);
}
return VITE_EXTENSIONS.map((e) => join(path, `index${e}`)).find(isFile);
}
return undefined;
}
/**
* Resolves a module specifier to the source file it names, or `null` for something that is not
* part of `src/`'s module graph (a real package, a node builtin, an asset). Everything else
* throws: a path alias, a Vite query suffix (`?worker`, `?raw`), a relative path that leaves
* `src/` or names nothing.
*/
function resolveSpecifier(from: string, spec: string, node: ts.Node): string | null {
if (spec.includes("?") || spec.includes("#")) {
fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`);
}
if (spec.startsWith("./") || spec.startsWith("../")) {
const found = viteResolveRelative(resolve(dirname(from), spec), from, node);
if (!found) fail(from, node, `cannot resolve import "${spec}"`);
if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`);
return CODE_EXTENSION.test(found) ? found : null; // css, svg, json, … — an asset, not a module
}
if (spec.startsWith("node:") || builtinModules.includes(spec)) return null;
const pkg = PACKAGE_NAME.exec(spec)?.[1];
if (pkg && existsSync(join(nodeModulesDir, pkg, "package.json"))) return null;
fail(
from,
node,
`import "${spec}" is neither relative nor an installed package — likely a path alias. This test ` +
`only understands relative imports and real dependencies; teach it the alias rather than letting ` +
`the file drop out of the closure.`,
);
}
const stringLiteralText = (node: ts.Node | undefined) =>
node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
interface ModuleFacts {
/** Every module specifier the file names: static imports, `export … from`, literal `import()`. */
specifiers: string[];
/** The source files those specifiers resolve to (packages and assets excluded). */
targets: string[];
/** Wrapper names the file reaches from `lib/tauri-commands.ts`. */
wrapperNames: string[];
}
/**
* Parses one file and returns its module edges and the wrappers it reaches. Wrapper usage is:
* named imports and named re-exports (by their exported name), and `X.name` / `X?.name` (or
* `typeof X.name` in a type) where `X` is a namespace import of the wrappers. Fails closed on
* everything else that could carry a wrapper: a default import, `export *` / `export * as` of the wrappers, `import()` of them (it
* resolves to the namespace object), a computed `import()`, `require`, `import X = require`,
* `import.meta.glob`, and any reference to a namespace alias other than `X.name`.
*
* Namespace references are matched by identifier text, not symbol: every Identifier spelled `X`
* anywhere in the file must be the object of a property access (or the name *of* one, `o.X`,
* which is not a reference). A local that shadows `X` needs a declaration spelled `X`, and that
* declaration is itself such an Identifier, so shadowing fails closed rather than confusing it.
*/
function analyzeModule(file: string): ModuleFacts {
const sf = parse(file);
const facts: ModuleFacts = { specifiers: [], targets: [], wrapperNames: [] };
const namespaceAliases = new Map<string, ts.Identifier>();
const edge = (spec: string, node: ts.Node) => {
facts.specifiers.push(spec);
const target = resolveSpecifier(file, spec, node);
if (target) facts.targets.push(target);
return target;
};
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt)) {
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
const clause = stmt.importClause;
if (target !== WRAPPERS || !clause) continue;
if (clause.name) fail(file, stmt, "default-imports tauri-commands.ts, which has no default export");
const bindings = clause.namedBindings;
if (bindings && ts.isNamespaceImport(bindings)) namespaceAliases.set(bindings.name.text, bindings.name);
if (bindings && ts.isNamedImports(bindings)) {
for (const el of bindings.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
}
} else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
if (target !== WRAPPERS) continue;
const clause = stmt.exportClause;
if (!clause || ts.isNamespaceExport(clause)) {
fail(
file,
stmt,
`re-exports tauri-commands.ts with "export *${clause ? " as …" : ""}", which cannot be audited — ` +
`re-export wrappers by name (export { wrapperName } from "…/tauri-commands")`,
);
}
for (const el of clause.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
} else if (ts.isImportEqualsDeclaration(stmt) && ts.isExternalModuleReference(stmt.moduleReference)) {
fail(file, stmt, `"import … = require(…)" is not followed by this test; use an ES import`);
}
}
walk(sf, (node) => {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const spec = stringLiteralText(node.arguments[0]);
if (spec === undefined || node.arguments.length === 0) {
fail(file, node, "import() with a computed specifier cannot be followed; use a string literal");
}
if (edge(spec, node) === WRAPPERS) {
fail(file, node, "import() of tauri-commands.ts yields the whole namespace object; import wrappers by name");
}
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") {
fail(file, node, "require() is not followed by this test; use an ES import");
} else if (
ts.isPropertyAccessExpression(node) &&
ts.isMetaProperty(node.expression) &&
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
node.name.text.startsWith("glob")
) {
fail(file, node, "import.meta.glob pulls in modules this test cannot enumerate");
} else if (ts.isIdentifier(node) && namespaceAliases.has(node.text)) {
if (node === namespaceAliases.get(node.text)) return; // the `import * as X` binding itself
const parent = node.parent;
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return; // `o.X` — not a reference
if (ts.isPropertyAccessExpression(parent) && parent.expression === node && ts.isIdentifier(parent.name)) {
facts.wrapperNames.push(parent.name.text);
return;
}
// `typeof X.name` in a type: a QualifiedName, type-only, counted anyway (the safe direction).
if (ts.isQualifiedName(parent) && parent.left === node && ts.isTypeQueryNode(parent.parent)) {
facts.wrapperNames.push(parent.right.text);
return;
}
fail(
file,
node,
`"${node.text}" (a namespace import of tauri-commands.ts) is used in \`${parent.getText().slice(0, 60)}\` ` +
`rather than as "${node.text}.wrapperName" — only direct member access can be audited; import ` +
`the wrappers by name instead`,
);
}
});
return facts;
}
/**
* `export const NAME = … invoke<T>("command", …)` NAME command, read from the AST: each exported
* const calls `invoke` exactly once, with a string literal. `invoke` must be imported by name from
* `@tauri-apps/api/core` and appear nowhere except as the callee of such a call.
*/
function wrapperCommands(): Map<string, string> {
const sf = parse(WRAPPERS);
const map = new Map<string, string>();
const callsByWrapper = new Map<string, ts.CallExpression[]>();
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt) && INVOKE_SPECIFIER.test(stringLiteralText(stmt.moduleSpecifier)!)) {
const b = stmt.importClause?.namedBindings;
const onlyInvoke =
!stmt.importClause?.name &&
b !== undefined &&
ts.isNamedImports(b) &&
b.elements.every((el) => !el.propertyName && el.name.text === "invoke");
if (!onlyInvoke) fail(WRAPPERS, stmt, `must import exactly { invoke } from ${stringLiteralText(stmt.moduleSpecifier)}`);
}
if (
ts.isVariableStatement(stmt) &&
stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
stmt.declarationList.flags & ts.NodeFlags.Const
) {
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) fail(WRAPPERS, decl, "an exported wrapper must be a plain `export const NAME`");
callsByWrapper.set(decl.name.text, []);
}
}
}
walk(sf, (node) => {
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
) {
fail(WRAPPERS, node, "tauri-commands.ts may not load modules dynamically (import()/require())");
}
if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return;
const call = node.parent;
if (!ts.isCallExpression(call) || call.expression !== node) {
fail(WRAPPERS, node, "invoke is referenced other than as a direct call");
}
let decl: ts.Node = call;
while (!(ts.isVariableDeclaration(decl) && decl.parent.parent.parent === sf)) {
decl = decl.parent;
if (decl === sf) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
}
const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText());
if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
// Only inside the wrapper's own function body: anything else (`export const x = invoke(…)`, an
// IIFE, a default argument) runs at module load in every window that imports this file.
const init = (decl as ts.VariableDeclaration).initializer;
const inBody =
init !== undefined &&
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) &&
call.pos >= init.body.pos &&
call.end <= init.body.end &&
!enclosedInIife(call, init);
if (!inBody) fail(WRAPPERS, call, "invoke must be called inside the wrapper's function body, not at module load");
calls.push(call);
});
for (const [name, calls] of callsByWrapper) {
expect(calls, `${name} must call invoke exactly once`).toHaveLength(1);
const command = stringLiteralText(calls[0].arguments[0]) ?? "<not a string literal>";
expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(
/^[a-z0-9_]+$/,
);
map.set(name, command);
}
expect(map.size).toBeGreaterThan(100);
return map;
}
/** Whether `node` sits in a function expression that is called on the spot, between it and `outer`. */
function enclosedInIife(node: ts.Node, outer: ts.Node): boolean {
for (let n = node.parent; n !== outer; n = n.parent) {
let fn: ts.Node = n;
if (!(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) continue;
while (ts.isParenthesizedExpression(fn.parent)) fn = fn.parent;
if (ts.isCallExpression(fn.parent) && fn.parent.expression === fn) return true;
}
return false;
}
/** Module specifiers a file names static imports, `export from`, `import = require`, and the
* argument of `import()`/`require()` without resolving anything or applying the closure rules,
* so it can run over test files too. A computed `import()`/`require()` argument yields `null`. */
function namedSpecifiers(file: string): (string | null)[] {
const out: (string | null)[] = [];
walk(parse(file), (node) => {
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
out.push(stringLiteralText(node.moduleSpecifier) ?? null);
} else if (ts.isExternalModuleReference(node)) {
out.push(stringLiteralText(node.expression) ?? null);
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
) {
out.push(stringLiteralText(node.arguments[0]) ?? null);
}
});
return out;
}
const factsCache = new Map<string, ModuleFacts>();
function factsOf(file: string): ModuleFacts {
let facts = factsCache.get(file);
if (!facts) {
facts = analyzeModule(file);
factsCache.set(file, facts);
}
return facts;
}
/** Transitive closure from the viewer entry over static imports, `export … from` and `import()`. */
function viewerClosure(): Set<string> {
const seen = new Set<string>();
const queue = [VIEWER_ENTRY];
while (queue.length > 0) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
for (const target of factsOf(file).targets) if (!seen.has(target)) queue.push(target);
}
return seen;
}
describe("capability files match the code each window runs", () => {
const defaultCap = readCapability("default.json");
const viewerCap = readCapability("file-viewer.json");
const allCode = codeFiles(srcDir);
const files = allCode.filter(isAppSource);
it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => {
// Every code file, tests included: the viewer closure can reach anything a relative import can.
const offenders = allCode
.filter((f) => f !== WRAPPERS)
.filter((f) => namedSpecifiers(f).some((s) => s === null || INVOKE_SPECIFIER.test(s)))
.map(rel);
expect(offenders, "imports @tauri-apps/api(/core), or loads a computed specifier").toEqual([]);
});
it("the windows lists are the reviewed ones", () => {
expect(defaultCap.windows).toEqual(["main"]);
expect(viewerCap.windows).toEqual(["file-viewer-*"]);
});
it("the plugin/core grants are the reviewed ones", () => {
expect(defaultCap.prefixed).toEqual([
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"dialog:allow-open",
"dialog:allow-save",
]);
expect(viewerCap.prefixed).toEqual([
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]);
});
it("the viewer window imports exactly the wrappers file-viewer.json grants", () => {
const wrappers = wrapperCommands();
const closure = viewerClosure();
expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true);
const viewerCommands = new Set<string>();
for (const file of closure) {
for (const name of factsOf(file).wrapperNames) {
const command = wrappers.get(name);
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
viewerCommands.add(command!);
}
}
const granted = [...viewerCommands].map(toPermission).sort();
expect(granted).toEqual(viewerCap.bare);
});
it("the main window imports only wrappers default.json grants, and none of the viewer's", () => {
const wrappers = wrapperCommands();
const closure = viewerClosure();
const mainCommands = new Set<string>();
for (const file of files) {
if (closure.has(file)) continue;
for (const name of factsOf(file).wrapperNames) {
const command = wrappers.get(name);
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
mainCommands.add(command!);
}
}
expect(mainCommands.size).toBeGreaterThan(50);
const ungranted = [...mainCommands].map(toPermission).filter((p) => !defaultCap.bare.includes(p)).sort();
expect(ungranted, "main-window code imports wrappers default.json does not grant").toEqual([]);
const crossed = [...mainCommands].filter((c) => viewerCap.bare.includes(toPermission(c))).sort();
expect(crossed, "main-window code imports viewer-only wrappers").toEqual([]);
});
it("every wrapper's command is granted in exactly one capability file", () => {
const wrappers = wrapperCommands();
const both: string[] = [];
const neither: string[] = [];
for (const command of new Set(wrappers.values())) {
const p = toPermission(command);
const inDefault = defaultCap.bare.includes(p);
const inViewer = viewerCap.bare.includes(p);
if (inDefault && inViewer) both.push(command);
if (!inDefault && !inViewer) neither.push(command);
}
expect(both).toEqual([]);
expect(neither, "granted nowhere — cargo check would fail too, but you may not have run it").toEqual([]);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { EditorView } from "@codemirror/view";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
});
const mount = (initialDoc: string) => {
const ref = createRef<CodeEditorHandle>();
const onDocChanged = vi.fn();
const utils = render(
<CodeEditor
ref={ref}
initialDoc={initialDoc}
readOnly={false}
language={null}
lineWrapping={false}
initialLocation={{ line: null, col: null, end_line: null }}
onDocChanged={onDocChanged}
onSave={() => {}}
/>,
);
const view = EditorView.findFromDOM(utils.container.querySelector(".cm-editor") as HTMLElement)!;
return { ref, view, onDocChanged };
};
describe("CodeEditor.setDoc (a reload)", () => {
it("keeps the cursor and the scroll position, and is not an edit", () => {
const { ref, view, onDocChanged } = mount("one\ntwo\nthree\nfour\n");
act(() => { view.dispatch({ selection: { anchor: 9 } }); }); // inside "three"
// jsdom has no layout, so give the scroller a real, settable scrollTop.
let top = 0;
Object.defineProperty(view.scrollDOM, "scrollTop", { configurable: true, get: () => top, set: (v: number) => { top = v; } });
view.scrollDOM.scrollTop = 120;
act(() => { ref.current!.setDoc("one\ntwo\nTHREE\nfour\nfive\n"); });
expect(view.state.doc.toString()).toBe("one\ntwo\nTHREE\nfour\nfive\n");
expect(view.state.selection.main.head).toBe(9);
expect(view.scrollDOM.scrollTop).toBe(120);
expect(onDocChanged).not.toHaveBeenCalled();
});
it("clamps the cursor when the new text is shorter", () => {
const { ref, view } = mount("a long first line\n");
act(() => { view.dispatch({ selection: { anchor: 15 } }); });
act(() => { ref.current!.setDoc("short"); });
expect(view.state.selection.main.head).toBe(5);
});
it("a user edit is reported as a change", () => {
const { view, onDocChanged } = mount("x");
act(() => { view.dispatch({ changes: { from: 1, insert: "y" } }); });
expect(onDocChanged).toHaveBeenCalledTimes(1);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { Annotation, EditorState, Compartment, EditorSelection, type Extension } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { bracketMatching, indentOnInput } from "@codemirror/language";
import type { ViewerLocation } from "../lib/types";
import { viewerTheme } from "./viewerTheme";
import { highlightExtension, setHighlight } from "./highlightLine";
export interface CodeEditorHandle {
getDoc(): string;
/** Replace the whole document, keeping scroll and a clamped cursor. Does not mark dirty. */
setDoc(text: string): void;
goTo(loc: ViewerLocation): void;
focus(): void;
}
export interface CodeEditorProps {
initialDoc: string;
readOnly: boolean;
language: Extension | null;
lineWrapping: boolean;
initialLocation: ViewerLocation;
onDocChanged(): void;
onSave(): void;
}
/** A `dispatch` from `setDoc` is a reload, not a user edit; the listener must not mark it dirty. */
const reloadTag = Annotation.define<boolean>();
function readOnlyExt(readOnly: boolean): Extension[] {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)];
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(props, ref) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const readOnlyCompartment = useRef(new Compartment());
const languageCompartment = useRef(new Compartment());
const wrapCompartment = useRef(new Compartment());
const callbacks = useRef(props);
callbacks.current = props;
useEffect(() => {
if (!host.current) return;
const v = new EditorView({
parent: host.current,
state: EditorState.create({
doc: props.initialDoc,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSpecialChars(),
drawSelection(),
history(),
bracketMatching(),
indentOnInput(),
search({ top: true }),
highlightExtension(),
viewerTheme,
keymap.of([
{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } },
...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab,
]),
readOnlyCompartment.current.of(readOnlyExt(props.readOnly)),
languageCompartment.current.of(props.language ?? []),
wrapCompartment.current.of(props.lineWrapping ? EditorView.lineWrapping : []),
EditorView.updateListener.of((u) => {
if (u.docChanged && !u.transactions.some((tr) => tr.annotation(reloadTag))) callbacks.current.onDocChanged();
}),
],
}),
});
view.current = v;
goTo(v, props.initialLocation);
return () => { v.destroy(); view.current = null; };
// The editor is created once per mount; later prop changes go through compartments below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
view.current?.dispatch({ effects: readOnlyCompartment.current.reconfigure(readOnlyExt(props.readOnly)) });
}, [props.readOnly]);
useEffect(() => {
view.current?.dispatch({ effects: languageCompartment.current.reconfigure(props.language ?? []) });
}, [props.language]);
useEffect(() => {
view.current?.dispatch({ effects: wrapCompartment.current.reconfigure(props.lineWrapping ? EditorView.lineWrapping : []) });
}, [props.lineWrapping]);
useImperativeHandle(ref, () => ({
getDoc: () => view.current?.state.doc.toString() ?? "",
setDoc: (text) => {
const v = view.current;
if (!v) return;
const scrollTop = v.scrollDOM.scrollTop;
const head = Math.min(v.state.selection.main.head, text.length);
v.dispatch({
changes: { from: 0, to: v.state.doc.length, insert: text },
selection: EditorSelection.single(head),
annotations: reloadTag.of(true),
});
v.scrollDOM.scrollTop = scrollTop;
},
goTo: (loc) => { if (view.current) goTo(view.current, loc); },
focus: () => view.current?.focus(),
}));
return <div ref={host} className="h-full min-h-0" data-testid="code-editor" />;
});
function goTo(v: EditorView, loc: ViewerLocation): void {
if (loc.line === null) return;
const from = loc.line;
const to = loc.end_line ?? loc.line;
const lineNo = Math.min(Math.max(1, from), v.state.doc.lines);
const line = v.state.doc.line(lineNo);
const pos = Math.min(line.from + Math.max(0, (loc.col ?? 1) - 1), line.to);
v.dispatch({
selection: EditorSelection.cursor(pos),
effects: [setHighlight.of({ from, to }), EditorView.scrollIntoView(pos, { y: "center" })],
});
}
+408
View File
@@ -0,0 +1,408 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, fireEvent } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import EditorPane from "./EditorPane";
import { encodeBase64 } from "../components/projects/home/filePreview";
import type { ViewerState } from "../lib/types";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const b64 = (s: string) => btoa(s);
const commands = vi.hoisted(() => ({
viewerReadFile: vi.fn(),
viewerPollFile: vi.fn(),
viewerWriteFile: vi.fn(),
}));
vi.mock("../lib/tauri-commands", () => commands);
const windowApi = vi.hoisted(() => ({ closeRequested: null as null | ((e: { preventDefault(): void }) => Promise<void> | void), destroy: vi.fn(), listeners: new Map<string, (e: { payload: unknown }) => void>() }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
onCloseRequested: async (cb: typeof windowApi.closeRequested) => { windowApi.closeRequested = cb; return () => {}; },
listen: async (name: string, cb: (e: { payload: unknown }) => void) => { windowApi.listeners.set(name, cb); return () => {}; },
destroy: windowApi.destroy,
}),
}));
const state: ViewerState = {
project_id: "p", project_name: "Demo", raw_path: "notes.md",
state: { kind: "resolved", container_path: "/workspace/demo/notes.md" },
initial: { line: 1, col: null, end_line: null },
};
const textFile = (text: string, hash: string, extra: Partial<{ truncated: boolean; editable: boolean }> = {}) => ({
contents_base64: b64(text), truncated: false, size: text.length, hash, editable: true, readonly_reason: null, ...extra,
});
/** Mark the buffer dirty through the pane's test hook (jsdom cannot drive CodeMirror's contenteditable). */
const edit = () => fireEvent(document, new CustomEvent("triple-c-test-edit"));
const clickSave = async () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^save$/i })); }); };
/** A real edit through CodeMirror, so the saved bytes carry it. */
const typeInto = (from: number, to: number, insert: string) => {
const view = EditorView.findFromDOM(document.querySelector(".cm-editor") as HTMLElement);
if (!view) throw new Error("no editor");
act(() => { view.dispatch({ changes: { from, to, insert } }); });
};
const bytesB64 = (bytes: number[]) => encodeBase64(new Uint8Array(bytes));
const utf8 = (s: string) => Array.from(new TextEncoder().encode(s));
const READ_ONLY = "Could not save the file: The file is read-only for the container user.";
const NOT_RUNNING = "Start the project before checking this file for changes — it runs inside the running container.";
const saved = (hash: string, diskHash = hash) => ({ hash, disk_hash: diskHash });
const poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
describe("EditorPane", () => {
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
const rect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = rect;
});
beforeEach(() => {
// Only the poll's interval is faked. Testing Library's async utilities
// settle through a real setTimeout(0), which fully faked timers freeze.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
commands.viewerReadFile.mockReset().mockResolvedValue(textFile("hello\n", H1));
commands.viewerPollFile.mockReset().mockResolvedValue({ exists: true, hash: H1, size: 6 });
commands.viewerWriteFile.mockReset().mockResolvedValue(saved(H2));
windowApi.destroy.mockReset();
});
afterEach(() => vi.useRealTimers());
it("loads the file and shows the path", async () => {
render(<EditorPane state={state} />);
expect(await screen.findByText("/workspace/demo/notes.md")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledWith(1024 * 1024);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("a changed poll on a clean document reloads silently", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockResolvedValue(textFile("changed\n", H2));
await poll();
expect(await screen.findByText(/Reloaded/)).toBeInTheDocument();
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
});
it("a changed poll on a dirty document shows the banner instead of reloading", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
await poll();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
});
it("a truncated file reloads once per change, not on every poll", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", "a".repeat(64), { truncated: true }));
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
await poll(); // seeds diskHash = H1 from the poll
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 9 });
commands.viewerReadFile.mockResolvedValue(textFile("bigger", "b".repeat(64), { truncated: true }));
await poll();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
await poll(2000);
await poll(2000);
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
});
it("a gone file shows the banner and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
commands.viewerPollFile.mockResolvedValue({ exists: false, hash: null, size: null });
await poll();
expect(await screen.findByText(/in the container/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("saves the buffer against the loaded hash", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("a save conflict shows the Changed on disk banner with both choices", async () => {
commands.viewerWriteFile.mockRejectedValue(new Error("conflict: the file changed on disk since it was loaded."));
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Overwrite on save/ })).toBeInTheDocument();
});
it("after a conflict, Overwrite on save saves against the freshly polled hash", async () => {
// A string rejection, as Tauri's invoke delivers it.
commands.viewerWriteFile.mockRejectedValueOnce("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
await clickSave();
const overwrite = await screen.findByRole("button", { name: /Overwrite on save/ });
await act(async () => { fireEvent.click(overwrite); });
commands.viewerWriteFile.mockResolvedValue(saved(H2));
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("Reload (discard mine) replaces the buffer with the disk copy", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockResolvedValue(textFile("theirs\n", H2));
await poll();
const reload = await screen.findByRole("button", { name: /Reload/ });
await act(async () => { fireEvent.click(reload); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("theirs");
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("a save refused because the file is read-only says so and keeps the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("any other save failure is shown as it came", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText("Could not save the file: disk full")).toBeInTheDocument();
});
it("a poll refused because the container is down shows Container not running and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockRejectedValue(NOT_RUNNING);
await poll();
expect(await screen.findByText(/until the project starts again/)).toBeInTheDocument();
expect(screen.getByText("Container not running")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("any other poll failure says what failed, not that the container is down, and clears on a good poll", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockRejectedValue("Could not check the file: sha256sum: Permission denied");
await poll();
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not check the file: sha256sum: Permission denied/);
expect(screen.getByText("Could not check for changes")).toBeInTheDocument();
expect(screen.queryByText(/Container not running/)).toBeNull();
// The write re-checks the hash itself, so saving stays possible.
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H1, size: 6 });
await poll(2000);
expect(screen.queryByText(/Permission denied/)).toBeNull();
expect(screen.getByText("Unsaved")).toBeInTheDocument();
});
it("a save that another writer overtook shows Changed on disk instead of Saved", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
await clickSave();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(screen.getByText("Changed on disk")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
// The next poll sees the same foreign hash: the banner stays, nothing is reloaded over the buffer.
await poll();
expect(screen.getByText(/while you were editing/)).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
// Overwrite now saves against what is actually on disk.
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Overwrite on save/ })); });
commands.viewerWriteFile.mockResolvedValue(saved(H2));
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("Save and close does not close when another writer overtook the save", async () => {
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(windowApi.destroy).not.toHaveBeenCalled();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
});
it("a failed first read offers Retry, which loads the file", async () => {
commands.viewerReadFile.mockRejectedValueOnce(NOT_RUNNING.replace("checking this file for changes", "opening files"));
render(<EditorPane state={state} />);
expect(await screen.findByText(/Start the project before opening files/)).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Retry" });
await act(async () => { fireEvent.click(retry); });
expect(await screen.findByText("Saved")).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("a failed first read is retried by the poll until it succeeds", async () => {
commands.viewerReadFile.mockRejectedValueOnce("Docker is busy").mockRejectedValueOnce("Docker is still busy");
render(<EditorPane state={state} />);
expect(await screen.findByText("Docker is busy")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
await poll();
expect(await screen.findByText("Docker is still busy")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
expect(commands.viewerPollFile).not.toHaveBeenCalled();
await poll(2000);
expect(await screen.findByText("Saved")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
// Loaded: the poll is back to polling, not re-reading.
await poll(2000);
expect(commands.viewerPollFile).toHaveBeenCalled();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
});
it("closing with unsaved edits is intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
const prevent = vi.fn();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
expect(prevent).toHaveBeenCalled();
expect(await screen.findByText(/Unsaved changes/)).toBeInTheDocument();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Discard/ })); });
expect(windowApi.destroy).toHaveBeenCalled();
});
it("closing a clean document is not intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
const prevent = vi.fn();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
expect(prevent).not.toHaveBeenCalled();
expect(screen.queryByText(/Unsaved changes/)).toBeNull();
});
it("a one-character edit to a CRLF file saves with every CRLF intact", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("a\r\nb\r\nc\r\n", H1));
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 3, "B"); // the editor holds "a\nb\nc\n"
expect(screen.getByText("Unsaved")).toBeInTheDocument();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("a\r\nB\r\nc\r\n"), H1);
});
it("a file with a UTF-8 BOM keeps its BOM on save", async () => {
const BOM = [0xef, 0xbb, 0xbf];
commands.viewerReadFile.mockResolvedValue({ ...textFile("", H1), contents_base64: bytesB64([...BOM, ...utf8("hi\n")]) });
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 2, "!");
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(bytesB64([...BOM, ...utf8("hi!\n")]), H1);
});
it("a reload that fails is retried on the next poll", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockRejectedValueOnce("Could not read the file: I/O error").mockResolvedValue(textFile("changed\n", H2));
await poll();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
await poll(2000);
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
expect(screen.getByText("Reloaded")).toBeInTheDocument();
});
it("ignores a poll issued before a save completed", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
let answer: (p: { exists: boolean; hash: string; size: number }) => void = () => {};
commands.viewerPollFile.mockImplementationOnce(() => new Promise((r) => { answer = r; }));
await poll(); // this poll is now in flight, carrying the pre-save hash
await clickSave(); // lands as H2
edit();
await act(async () => { answer({ exists: true, hash: H1, size: 6 }); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("a conflict whose follow-up poll has no hash shows an error instead of offering an overwrite", async () => {
commands.viewerWriteFile.mockRejectedValue("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: null, size: 7 });
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent(/could not be checked/);
expect(screen.queryByRole("button", { name: /Overwrite on save/ })).toBeNull();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
});
it("states why a file is read-only as visible text", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", H1, { truncated: true }));
render(<EditorPane state={state} />);
expect(await screen.findByText("Read-only")).toBeInTheDocument();
expect(screen.getByText("Files over 1 MiB are read-only.")).toBeVisible();
});
it("a save error is announced as an alert", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save the file: disk full");
});
it("Save and close saves, then closes the window", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(windowApi.destroy).toHaveBeenCalled();
});
it("a save that fails while closing keeps the window open and the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(windowApi.destroy).not.toHaveBeenCalled();
expect(screen.getByText(/Unsaved changes/)).toBeInTheDocument();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
});
});
+343
View File
@@ -0,0 +1,343 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Extension } from "@codemirror/state";
import Button from "../components/ui/Button";
import StatusIndicator, { type StatusTone } from "../components/ui/StatusIndicator";
import { decodeBase64, encodeBase64, imageMimeFor, previewLimit } from "../components/projects/home/filePreview";
import { viewerPollFile, viewerReadFile, viewerWriteFile } from "../lib/tauri-commands";
import type { ViewerFile, ViewerLocation, ViewerState } from "../lib/types";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
import { CONFLICT_PREFIX, GONE_PREFIX, READ_ONLY_MESSAGE } from "./ipcMessages";
import { classifyViewerFile, type Editability } from "./editability";
import { languageFor, wrapsLines } from "./languages";
import { decodeViewerText, encodeViewerText, type TextFormat } from "./textFormat";
import { useViewerPolling } from "./useViewerPolling";
import { canSave, initialViewerState, pollEffect, reduceViewer } from "./viewerState";
const POLL_MS = 2000;
export const GOTO_EVENT = "file-viewer-goto";
const READ_ONLY_SAVE =
"This file is read-only for the container user, so it was not saved. Your text is kept: change the file's permissions in the container and save again, or copy your text out.";
const CONFLICT_UNCHECKED =
"The file changed on disk, but its new version could not be checked, so it cannot be overwritten safely. Copy your text out if you need it, then reload.";
/** A banner-worthy save failure; `reload` adds a "Reload (discard mine)" button. */
interface SaveError { text: string; reload?: boolean }
type View =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "text"; doc: string; editability: Editability }
| { kind: "image"; url: string; editability: Editability }
| { kind: "binary"; editability: Editability };
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** `write.rs`'s refusal to replace a file the container user may not write. */
const isReadOnlyRefusal = (msg: string) => msg.includes(READ_ONLY_MESSAGE);
export default function EditorPane({ state }: { state: ViewerState }) {
const path = state.state.kind === "resolved" ? state.state.container_path : "";
const [view, setView] = useState<View>({ kind: "loading" });
const [language, setLanguage] = useState<Extension | null>(null);
const [doc, dispatch] = useReducer(reduceViewer, initialViewerState);
const [closing, setClosing] = useState(false);
const [saveError, setSaveError] = useState<SaveError | null>(null);
const editor = useRef<CodeEditorHandle>(null);
const docRef = useRef(doc);
docRef.current = doc;
const closingRef = useRef(closing);
closingRef.current = closing;
/** Bumped synchronously on every user edit, so async work can tell an edit happened meanwhile. */
const editGen = useRef(0);
const saving = useRef(false);
/** Bumped when a save's write settles; a poll issued before that is stale. */
const saveGen = useRef(0);
/** Line ending and BOM of the loaded text, restored on save. */
const textFormat = useRef<TextFormat>({ bom: false, eol: "\n" });
const imageUrl = useRef<string | null>(null);
const markEdited = useCallback(() => {
editGen.current += 1;
dispatch({ type: "edited" });
}, []);
/** Put a freshly read file on screen: text into the editor, or an image/binary view. */
const show = useCallback((file: ViewerFile) => {
const bytes = decodeBase64(file.contents_base64);
const classified = classifyViewerFile(path, file, bytes);
if (imageUrl.current) { URL.revokeObjectURL(imageUrl.current); imageUrl.current = null; }
if (classified.kind === "image") {
const url = URL.createObjectURL(new Blob([bytes], { type: imageMimeFor(path) ?? "application/octet-stream" }));
imageUrl.current = url;
setView({ kind: "image", url, editability: classified });
} else if (classified.kind === "binary") {
setView({ kind: "binary", editability: classified });
} else {
const { text, editability, format } = decodeViewerText(bytes, classified);
textFormat.current = format;
setView({ kind: "text", doc: text, editability });
editor.current?.setDoc(text);
}
}, [path]);
useEffect(() => () => { if (imageUrl.current) URL.revokeObjectURL(imageUrl.current); }, []);
/** Bumped per initial-load attempt (and on unmount/path change); a stale attempt's result is dropped. */
const loadGen = useRef(0);
/**
* The initial read. Re-run by "Retry" and by the poll while the window shows
* a load error, so a window opened while the container was restarting
* recovers on its own instead of staying dead.
*/
const load = useCallback(async () => {
const gen = ++loadGen.current;
try {
const file = await viewerReadFile(previewLimit(path));
if (loadGen.current !== gen) return;
show(file);
dispatch({ type: "loaded", hash: file.hash, truncated: file.truncated });
} catch (e) {
if (loadGen.current === gen) setView({ kind: "error", message: errorText(e) });
}
}, [path, show]);
useEffect(() => {
void load();
return () => { loadGen.current += 1; };
}, [load]);
const retryLoad = useCallback(() => {
setView({ kind: "loading" });
void load();
}, [load]);
// The language loads lazily and separately, so the text is on screen (and
// polling runs) without waiting for a grammar chunk.
useEffect(() => {
let cancelled = false;
languageFor(path).then((l) => { if (!cancelled) setLanguage(l); }, () => {});
return () => { cancelled = true; };
}, [path]);
/**
* The one reload path (P3/P14), for a clean poll-driven reload and for
* "Reload (discard mine)". `polledHash` is the poll's full-file hash, which
* a truncated read's own (prefix) hash can never equal. With `onlyIfClean`,
* an edit made while the read was in flight wins: nothing is replaced, and
* the next poll shows the banner instead.
*/
const reloadFromDisk = useCallback(async (polledHash: string | null, onlyIfClean: boolean) => {
const gen = editGen.current;
const file = await viewerReadFile(previewLimit(path));
if (onlyIfClean && editGen.current !== gen) return;
show(file);
dispatch({ type: "reloaded", hash: file.hash, truncated: file.truncated, polledHash });
}, [path, show]);
// Poll (spec §5). A reload replaces the document only when the reducer says so.
// While the first read has failed, each tick retries that read instead.
// Always enabled, so a loading -> error flip does not fire an immediate extra read.
useViewerPolling(POLL_MS, async () => {
if (view.kind === "loading") return;
if (view.kind === "error") { await load(); return; }
// A poll that overlaps a save can carry the pre-save hash; skip it (M2).
if (saving.current) return;
const gen = saveGen.current;
let poll;
try {
poll = await viewerPollFile();
} catch (e) {
if (saveGen.current === gen) dispatch({ type: "poll_failed", message: errorText(e) });
return;
}
if (saveGen.current !== gen) return;
const before = docRef.current;
const after = reduceViewer(before, { type: "polled", poll });
dispatch({ type: "polled", poll });
if (pollEffect(before, after) === "reload") {
try { await reloadFromDisk(after.diskHash, true); } catch (e) { dispatch({ type: "poll_failed", message: errorText(e) }); }
}
}, true);
const editable = view.kind === "text" && view.editability.editable;
const saveEnabled = canSave(doc, editable);
const save = useCallback(async () => {
const handle = editor.current;
const baseHash = docRef.current.baseHash;
if (!saveEnabled || !handle || !baseHash || saving.current) return;
saving.current = true;
setSaveError(null);
const gen = editGen.current;
try {
const bytes = encodeViewerText(handle.getDoc(), textFormat.current);
const result = await viewerWriteFile(encodeBase64(bytes), baseHash).then(
(saved) => ({ ok: true as const, saved }),
(e: unknown) => ({ ok: false as const, msg: errorText(e) }),
);
saveGen.current += 1;
if (result.ok) {
const { hash, disk_hash: diskHash } = result.saved;
dispatch({ type: "saved", hash, diskHash });
if (editGen.current !== gen) dispatch({ type: "edited" }); // typed while the save was in flight
// Another writer landed right after ours: the reducer shows "Changed on
// disk", and the window stays open so the user can decide.
else if (closingRef.current && diskHash === hash) await getCurrentWindow().destroy();
} else if (result.msg.startsWith(CONFLICT_PREFIX)) {
await adoptConflict();
} else if (result.msg.startsWith(GONE_PREFIX)) {
dispatch({ type: "save_gone" });
} else if (isReadOnlyRefusal(result.msg)) {
setSaveError({ text: READ_ONLY_SAVE });
} else {
setSaveError({ text: result.msg });
}
} finally {
saving.current = false;
}
}, [saveEnabled]);
/**
* The disk changed between polls. Poll now (P4), so "Overwrite on save"
* adopts the current hash rather than the stale one. With no hash to adopt,
* an overwrite would only conflict again, so say so instead (M3).
*/
async function adoptConflict() {
let poll;
try {
poll = await viewerPollFile();
} catch (e) {
dispatch({ type: "poll_failed", message: errorText(e) });
dispatch({ type: "save_conflict" });
return;
}
if (poll.exists && poll.hash === null) { setSaveError({ text: CONFLICT_UNCHECKED, reload: true }); return; }
dispatch({ type: "polled", poll });
if (poll.exists) dispatch({ type: "save_conflict" });
}
// Ctrl/Cmd+S outside the editor; the editor's own keymap handles it inside
// (and prevents the default, which is how this listener knows to skip it).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || !(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "s") return;
e.preventDefault();
void save();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [save]);
// Close guard + goto (spec §3/§5).
useEffect(() => {
const win = getCurrentWindow();
let disposed = false;
const unlisten: Array<() => void> = [];
const keep = (u: () => void) => { if (disposed) u(); else unlisten.push(u); };
void win.onCloseRequested((event) => {
if (docRef.current.doc === "dirty") { event.preventDefault(); setClosing(true); }
}).then(keep);
void win.listen<ViewerLocation>(GOTO_EVENT, (e) => editor.current?.goTo(e.payload)).then(keep);
return () => { disposed = true; unlisten.forEach((u) => u()); };
}, []);
useEffect(() => {
if (import.meta.env.MODE !== "test") return;
document.addEventListener("triple-c-test-edit", markEdited);
return () => document.removeEventListener("triple-c-test-edit", markEdited);
}, [markEdited]);
const reloadDiscarding = useCallback(async () => {
setSaveError(null);
try { await reloadFromDisk(docRef.current.diskHash, false); } catch (e) { setSaveError({ text: errorText(e) }); }
}, [reloadFromDisk]);
const badge = useMemo((): { tone: StatusTone; label: string; detail?: string } | null => {
if (view.kind === "loading" || view.kind === "error") return null;
if (doc.containerDown) return { tone: "error", label: "Container not running" };
if (doc.pollError) return { tone: "error", label: "Could not check for changes" };
if (doc.disk === "gone") return { tone: "error", label: "File no longer exists" };
if (!view.editability.editable) return { tone: "off", label: "Read-only", detail: view.editability.reason ?? undefined };
if (doc.disk === "changed") return { tone: "busy", label: "Changed on disk" };
if (doc.doc === "dirty") return { tone: "busy", label: "Unsaved" };
if (doc.justReloaded) return { tone: "ok", label: "Reloaded" };
return { tone: "ok", label: "Saved" };
}, [doc, view]);
return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)]">
<header className="flex items-center gap-3 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono" title={path}>{path}</span>
<span className="text-[var(--text-secondary)]">{state.project_name}</span>
<span className="ml-auto flex items-center" aria-live="polite">
{badge && <StatusIndicator tone={badge.tone} label={badge.label} />}
{badge?.detail && <span className="ml-2 text-[var(--text-secondary)]">{badge.detail}</span>}
</span>
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save</Button>
</header>
{doc.containerDown && <Banner tone="error" text="Container not running — the file cannot be read or saved until the project starts again." />}
{doc.pollError && <Banner tone="error" text={`${doc.pollError} — changes on disk go undetected until this clears; the viewer keeps trying.`} />}
{doc.disk === "gone" && <Banner tone="error" text="This file no longer exists in the container. Your text is kept so you can copy it; saving is disabled." />}
{doc.disk === "changed" && doc.doc === "dirty" && (
<Banner text="Changed on disk while you were editing.">
<Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>
<Button size="sm" onClick={() => dispatch({ type: "overwrite_on_save" })}>Overwrite on save</Button>
</Banner>
)}
{saveError && (
<Banner tone="error" text={saveError.text}>
{saveError.reload && <Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>}
</Banner>
)}
{closing && (
<Banner text="Unsaved changes — save before closing?">
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save and close</Button>
<Button variant="danger" size="sm" onClick={() => void getCurrentWindow().destroy()}>Discard</Button>
<Button size="sm" onClick={() => setClosing(false)}>Cancel</Button>
</Banner>
)}
<main className="min-h-0 flex-1">
{view.kind === "loading" && <p className="p-4 text-sm text-[var(--text-secondary)]">Loading</p>}
{view.kind === "error" && (
<div className="flex flex-col items-start gap-2 p-4 text-sm">
<p>{view.message}</p>
<p className="text-[var(--text-secondary)]">The viewer retries every few seconds.</p>
<Button size="sm" onClick={retryLoad}>Retry</Button>
</div>
)}
{view.kind === "binary" && <p className="p-4 text-sm">{view.editability.reason}</p>}
{view.kind === "image" && <img src={view.url} alt={path} className="max-h-full max-w-full object-contain p-4" />}
{view.kind === "text" && (
<CodeEditor
ref={editor}
initialDoc={view.doc}
readOnly={!view.editability.editable}
language={language}
lineWrapping={wrapsLines(path)}
initialLocation={state.initial}
onDocChanged={markEdited}
onSave={() => void save()}
/>
)}
</main>
</div>
);
}
/** A warning is a polite status; an error (a failed save, a lost file or container) is an alert. */
function Banner({ text, tone = "warning", children }: { text: string; tone?: "warning" | "error"; children?: ReactNode }) {
const colours = tone === "error"
? "border-[var(--error)] bg-[var(--error-muted)]"
: "border-[var(--warning)] bg-[var(--warning-muted)]";
return (
<div role={tone === "error" ? "alert" : "status"} className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 text-xs ${colours}`}>
<span>{text}</span>
{children}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ViewerState } from "../lib/types";
import ViewerApp from "./ViewerApp";
const commands = vi.hoisted(() => ({
viewerGetState: vi.fn(),
viewerChooseFile: vi.fn(),
}));
vi.mock("../lib/tauri-commands", () => commands);
// The editor itself is covered by EditorPane.test; here only the routing matters.
vi.mock("./EditorPane", () => ({
default: ({ state }: { state: ViewerState }) => (
<p>editor for {state.state.kind === "resolved" ? state.state.container_path : "?"}</p>
),
}));
const base = { project_id: "p", project_name: "Demo", raw_path: "foo.ts", initial: { line: 3, col: null, end_line: null } };
describe("ViewerApp", () => {
beforeEach(() => {
commands.viewerGetState.mockReset();
commands.viewerChooseFile.mockReset();
});
it("opens the editor for a resolved file", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/a/foo.ts" } });
render(<ViewerApp />);
expect(await screen.findByText("editor for /workspace/a/foo.ts")).toBeInTheDocument();
});
it("lists every path it tried when the file is not found", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "not_found", tried: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
render(<ViewerApp />);
expect(await screen.findByText(/Could not find/)).toBeInTheDocument();
expect(screen.getByText("/workspace/a/foo.ts")).toBeInTheDocument();
expect(screen.getByText("/workspace/b/foo.ts")).toBeInTheDocument();
});
it("choosing a candidate asks the backend by index and opens the result", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
commands.viewerChooseFile.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/b/foo.ts" } });
render(<ViewerApp />);
const second = await screen.findByRole("button", { name: "/workspace/b/foo.ts" });
await act(async () => { fireEvent.click(second); });
expect(commands.viewerChooseFile).toHaveBeenCalledWith(1);
expect(await screen.findByText("editor for /workspace/b/foo.ts")).toBeInTheDocument();
});
it("shows a failure to load the state", async () => {
commands.viewerGetState.mockRejectedValue("This window is not a file viewer.");
render(<ViewerApp />);
expect(await screen.findByText("This window is not a file viewer.")).toBeInTheDocument();
});
it("keeps the choice list when choosing fails, and says why", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts"] } });
commands.viewerChooseFile.mockRejectedValue("That choice is no longer available.");
render(<ViewerApp />);
const only = await screen.findByRole("button", { name: "/workspace/a/foo.ts" });
await act(async () => { fireEvent.click(only); });
expect(await screen.findByText("That choice is no longer available.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "/workspace/a/foo.ts" })).toBeInTheDocument();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import Button from "../components/ui/Button";
import { viewerChooseFile, viewerGetState } from "../lib/tauri-commands";
import type { ViewerState } from "../lib/types";
import EditorPane from "./EditorPane";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
export default function ViewerApp() {
const [state, setState] = useState<ViewerState | { error: string } | null>(null);
const [chooseError, setChooseError] = useState<string | null>(null);
useEffect(() => {
viewerGetState().then(setState, (e) => setState({ error: errorText(e) }));
}, []);
if (state === null) return <p className="p-4 text-sm text-[var(--text-secondary)]">Loading</p>;
if ("error" in state) return <p className="p-4 text-sm">{state.error}</p>;
const choose = (index: number) => {
setChooseError(null);
viewerChooseFile(index).then(setState, (e) => setChooseError(errorText(e)));
};
switch (state.state.kind) {
case "resolved":
return <EditorPane state={state} />;
case "not_found":
return (
<div className="p-4 text-sm">
<p>Could not find <span className="font-mono">{state.raw_path}</span> in the container. Looked in:</p>
<ul className="mt-2 list-disc pl-6 font-mono text-xs text-[var(--text-secondary)]">
{state.state.tried.map((p) => <li key={p}>{p}</li>)}
</ul>
</div>
);
case "choose":
return (
<div className="p-4 text-sm">
<p>Several files match <span className="font-mono">{state.raw_path}</span>. Open which?</p>
<ul className="mt-2 flex flex-col items-start gap-1">
{state.state.candidates.map((p, i) => (
<li key={p}>
<Button size="sm" onClick={() => choose(i)}>
<span className="font-mono">{p}</span>
</Button>
</li>
))}
</ul>
{chooseError && <p role="alert" className="mt-2">{chooseError}</p>}
</div>
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyViewerFile } from "./editability";
import type { ViewerFile } from "../lib/types";
const file = (over: Partial<ViewerFile> = {}): ViewerFile => ({
contents_base64: "", truncated: false, size: 10, hash: "0".repeat(64), editable: true, readonly_reason: null, ...over,
});
const text = new TextEncoder().encode("hello\n");
describe("classifyViewerFile", () => {
it("text in a write root is editable", () => {
expect(classifyViewerFile("/workspace/a/x.md", file(), text)).toEqual({ kind: "text", editable: true, reason: null });
});
it("a truncated file is read-only and says why", () => {
const r = classifyViewerFile("/workspace/a/big.log", file({ truncated: true }), text);
expect(r.editable).toBe(false);
expect(r.reason).toMatch(/1 MiB/);
});
it("Rust's refusal wins and is quoted", () => {
const r = classifyViewerFile("/etc/hosts", file({ editable: false, readonly_reason: "Only /workspace, /home/claude and /tmp can be written." }), text);
expect(r).toEqual({ kind: "text", editable: false, reason: "Only /workspace, /home/claude and /tmp can be written." });
});
it("images and binaries are never editable", () => {
expect(classifyViewerFile("/workspace/a/x.png", file(), new Uint8Array([137, 80]))).toMatchObject({ kind: "image", editable: false });
expect(classifyViewerFile("/workspace/a/x.bin", file(), new Uint8Array([0, 1, 2]))).toMatchObject({ kind: "binary", editable: false });
});
});
+15
View File
@@ -0,0 +1,15 @@
import { imageMimeFor, looksBinary, TEXT_PREVIEW_LIMIT } from "../components/projects/home/filePreview";
import type { ViewerFile } from "../lib/types";
export type ViewerKind = "text" | "image" | "binary";
export interface Editability { kind: ViewerKind; editable: boolean; reason: string | null }
const MIB = TEXT_PREVIEW_LIMIT / (1024 * 1024);
export function classifyViewerFile(path: string, file: ViewerFile, bytes: Uint8Array): Editability {
if (imageMimeFor(path)) return { kind: "image", editable: false, reason: "Images are shown, not edited." };
if (looksBinary(bytes)) return { kind: "binary", editable: false, reason: "This file is not text." };
if (file.truncated) return { kind: "text", editable: false, reason: `Files over ${MIB} MiB are read-only.` };
if (!file.editable) return { kind: "text", editable: false, reason: file.readonly_reason ?? "This location is read-only." };
return { kind: "text", editable: true, reason: null };
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { highlightExtension, highlightLineField, lineRangeToPositions, setHighlight } from "./highlightLine";
describe("lineRangeToPositions", () => {
const doc = Text.of(["one", "two", "three"]);
it("maps 1-based inclusive lines to document offsets", () => {
expect(lineRangeToPositions(doc, 2, 2)).toEqual({ from: 4, to: 4 });
expect(lineRangeToPositions(doc, 1, 3)).toEqual({ from: 0, to: 8 });
});
it("clamps past the end and refuses nonsense", () => {
expect(lineRangeToPositions(doc, 2, 99)).toEqual({ from: 4, to: 8 });
expect(lineRangeToPositions(doc, 99, 100)).toEqual({ from: 8, to: 8 });
expect(lineRangeToPositions(doc, 0, 1)).toEqual({ from: 0, to: 0 });
expect(lineRangeToPositions(doc, 3, 1)).toEqual({ from: 8, to: 8 });
});
});
describe("highlightLineField", () => {
it("decorates every line in the range and clears on null", () => {
let state = EditorState.create({ doc: "a\nb\nc\nd", extensions: [highlightExtension()] });
state = state.update({ effects: setHighlight.of({ from: 2, to: 3 }) }).state;
let count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(2);
state = state.update({ effects: setHighlight.of(null) }).state;
count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
export const setHighlight = StateEffect.define<{ from: number; to: number } | null>();
const lineMark = Decoration.line({ class: "cm-triple-c-target" });
export function lineRangeToPositions(doc: Text, from: number, to: number): { from: number; to: number } | null {
const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), doc.lines);
const a = clamp(from);
const b = Math.max(a, clamp(to));
return { from: doc.line(a).from, to: doc.line(b).from };
}
export const highlightLineField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
let next = value.map(tr.changes);
for (const e of tr.effects) {
if (!e.is(setHighlight)) continue;
if (e.value === null) { next = Decoration.none; continue; }
const range = lineRangeToPositions(tr.state.doc, e.value.from, e.value.to);
if (!range) { next = Decoration.none; continue; }
const marks = [];
for (let pos = range.from; pos <= range.to; ) {
const line = tr.state.doc.lineAt(pos);
marks.push(lineMark.range(line.from));
if (line.to + 1 > tr.state.doc.length) break;
pos = line.to + 1;
}
next = Decoration.set(marks, true);
}
return next;
},
provide: (f) => EditorView.decorations.from(f),
});
export function highlightExtension(): Extension {
return [highlightLineField];
}
+21
View File
@@ -0,0 +1,21 @@
/**
* The error strings the Rust side of the viewer produces and this side matches
* on. This file is the one TypeScript copy; the Rust originals are
*
* - `CONFLICT_PREFIX`, `GONE_PREFIX`, `READ_ONLY_MESSAGE` in
* `src-tauri/src/file_viewer/write.rs` (`viewer_write_file` errors), and
* - `NOT_RUNNING_PREFIX` in `src-tauri/src/commands/file_commands.rs`
* (`require_running` and the viewer's "no container" refusal).
*
* `write.rs`'s test `the_frontend_copies_of_the_ipc_messages_match` reads this
* file and fails if any literal here drifts from its Rust original.
*/
/** A save refused because the file changed on disk since its base hash. */
export const CONFLICT_PREFIX = "conflict:";
/** A save refused because the file no longer exists. */
export const GONE_PREFIX = "gone:";
/** A save refused because the container user may not write the file. */
export const READ_ONLY_MESSAGE = "The file is read-only for the container user.";
/** Any command refused because the project's container is not running. */
export const NOT_RUNNING_PREFIX = "Start the project before";
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { languageFor, wrapsLines } from "./languages";
describe("languageFor", () => {
it.each(["a.ts", "a.tsx", "a.js", "a.jsx", "a.mjs", "a.rs", "a.py", "a.json", "a.yaml", "a.yml", "a.toml", "a.sh", "a.bash", "a.css", "a.html", "a.md", "Dockerfile", "Cargo.lock", "README"])(
"resolves %s without throwing", async (name) => {
const result = await languageFor(`/workspace/${name}`);
if (name === "README") {
expect(result).toBeNull();
} else {
expect(result).not.toBeNull();
}
});
it("returns null for an unknown extension", async () => {
await expect(languageFor("/workspace/x.xyz")).resolves.toBeNull();
});
it("returns an extension for markdown", async () => {
await expect(languageFor("/workspace/x.md")).resolves.not.toBeNull();
});
});
describe("wrapsLines", () => {
it("wraps prose, not code", () => {
expect(wrapsLines("x.md")).toBe(true);
expect(wrapsLines("x.txt")).toBe(true);
expect(wrapsLines("x.rs")).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Extension CodeMirror language, loaded on demand so a window only pays for
* the grammar it shows. Dynamic `import()` becomes a same-origin chunk, fine
* under `script-src 'self'`.
*/
import type { Extension } from "@codemirror/state";
import { extensionOf } from "../components/projects/home/filePreview";
type Loader = () => Promise<Extension>;
const BY_EXTENSION: Record<string, Loader> = {
md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
rs: () => import("@codemirror/lang-rust").then((m) => m.rust()),
py: () => import("@codemirror/lang-python").then((m) => m.python()),
json: () => import("@codemirror/lang-json").then((m) => m.json()),
jsonc: () => import("@codemirror/lang-json").then((m) => m.json()),
yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
css: () => import("@codemirror/lang-css").then((m) => m.css()),
html: () => import("@codemirror/lang-html").then((m) => m.html()),
htm: () => import("@codemirror/lang-html").then((m) => m.html()),
toml: () => stream("toml"),
lock: () => stream("toml"),
sh: () => stream("shell"),
bash: () => stream("shell"),
zsh: () => stream("shell"),
};
const BY_BASENAME: Record<string, Loader> = {
dockerfile: () => stream("shell"),
makefile: () => stream("shell"),
};
async function stream(mode: "toml" | "shell"): Promise<Extension> {
const { StreamLanguage } = await import("@codemirror/language");
const parser = mode === "toml"
? (await import("@codemirror/legacy-modes/mode/toml")).toml
: (await import("@codemirror/legacy-modes/mode/shell")).shell;
return StreamLanguage.define(parser);
}
export function languageFor(path: string): Promise<Extension | null> {
const ext = extensionOf(path);
const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
const loader = BY_EXTENSION[ext] ?? BY_BASENAME[base];
return loader ? loader() : Promise.resolve(null);
}
const PROSE = new Set(["md", "markdown", "txt", "rst", "log", ""]);
export function wrapsLines(path: string): boolean {
return PROSE.has(extensionOf(path));
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import ViewerApp from "./ViewerApp";
import "../index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ViewerApp />
</React.StrictMode>,
);
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { decodeViewerText, encodeViewerText } from "./textFormat";
import type { Editability } from "./editability";
const editable: Editability = { kind: "text", editable: true, reason: null };
const bytes = (s: string) => new TextEncoder().encode(s);
const BOM = [0xef, 0xbb, 0xbf];
/** What CodeMirror hands back: every line break normalised to "\n". */
const asEditorText = (s: string) => s.replace(/\r\n?/g, "\n");
describe("decodeViewerText / encodeViewerText", () => {
it("round-trips an LF file byte for byte", () => {
const d = decodeViewerText(bytes("a\nb\n"), editable);
expect(d.format).toEqual({ bom: false, eol: "\n" });
expect(Array.from(encodeViewerText(asEditorText(d.text), d.format))).toEqual(Array.from(bytes("a\nb\n")));
});
it("keeps CRLF line endings through the editor's LF buffer", () => {
const d = decodeViewerText(bytes("a\r\nb\r\nc"), editable);
expect(d.format.eol).toBe("\r\n");
const edited = asEditorText(d.text).replace("b", "B");
expect(new TextDecoder().decode(encodeViewerText(edited, d.format))).toBe("a\r\nB\r\nc");
});
it("uses the dominant separator for a mixed file", () => {
expect(decodeViewerText(bytes("a\r\nb\r\nc\nd"), editable).format.eol).toBe("\r\n");
expect(decodeViewerText(bytes("a\nb\nc\r\nd"), editable).format.eol).toBe("\n");
expect(decodeViewerText(bytes("a\rb\rc"), editable).format.eol).toBe("\r");
});
it("strips a UTF-8 BOM from the text and puts it back on save", () => {
const d = decodeViewerText(new Uint8Array([...BOM, ...bytes("hi\n")]), editable);
expect(d.text).toBe("hi\n");
expect(d.format.bom).toBe(true);
expect(Array.from(encodeViewerText("hi\n", d.format))).toEqual([...BOM, ...bytes("hi\n")]);
});
it("makes invalid UTF-8 read-only rather than rewriting it", () => {
const d = decodeViewerText(new Uint8Array([0x61, 0xff, 0x62]), editable);
expect(d.editability).toMatchObject({ editable: false, reason: expect.stringMatching(/not valid UTF-8/) });
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Byte-faithful text for the editor: a save must change only what the user
* edited. CodeMirror normalises every line break to "\n" and the UTF-8
* decoder drops a BOM, so both are recorded on load and restored on save.
*/
import type { Editability } from "./editability";
export type LineEnding = "\n" | "\r\n" | "\r";
export interface TextFormat { bom: boolean; eol: LineEnding }
const BOM = [0xef, 0xbb, 0xbf];
const NOT_UTF8 = "This file is not valid UTF-8, so it is read-only.";
/** The most common separator in the text; "\n" on a tie or with no breaks. */
function dominantEol(text: string): LineEnding {
let crlf = 0, lf = 0, cr = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c === 13) {
if (text.charCodeAt(i + 1) === 10) { crlf++; i++; } else cr++;
} else if (c === 10) lf++;
}
if (crlf > lf && crlf >= cr) return "\r\n";
if (cr > lf && cr > crlf) return "\r";
return "\n";
}
export function decodeViewerText(
bytes: Uint8Array,
editability: Editability,
): { text: string; editability: Editability; format: TextFormat } {
const bom = bytes.length >= 3 && BOM.every((b, i) => bytes[i] === b);
const body = bom ? bytes.subarray(3) : bytes;
let text: string;
if (!editability.editable) {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
} else {
// An editable file must round-trip, so invalid UTF-8 (which the lenient
// decoder would turn into U+FFFD, and a save would write back) is read-only.
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body);
} catch {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
editability = { kind: "text", editable: false, reason: NOT_UTF8 };
}
}
return { text, editability, format: { bom, eol: dominantEol(text) } };
}
/** The editor's "\n"-joined text back to the file's bytes. */
export function encodeViewerText(text: string, format: TextFormat): Uint8Array {
const body = new TextEncoder().encode(format.eol === "\n" ? text : text.split("\n").join(format.eol));
if (!format.bom) return body;
const out = new Uint8Array(body.length + 3);
out.set(BOM, 0);
out.set(body, 3);
return out;
}
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useViewerPolling } from "./useViewerPolling";
describe("useViewerPolling", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const setVisibility = (state: DocumentVisibilityState) => {
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
};
it("ticks on the interval only while visible, and once immediately on becoming visible", async () => {
setVisibility("visible");
const tick = vi.fn(async () => {});
renderHook(() => useViewerPolling(2000, tick, true));
expect(tick).toHaveBeenCalledTimes(1); // initial
await vi.advanceTimersByTimeAsync(4000);
expect(tick).toHaveBeenCalledTimes(3);
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(6000);
expect(tick).toHaveBeenCalledTimes(3);
setVisibility("visible");
expect(tick).toHaveBeenCalledTimes(4);
});
it("does not overlap ticks and stops when disabled", async () => {
setVisibility("visible");
let resolve: () => void = () => {};
const tick = vi.fn(() => new Promise<void>((r) => { resolve = r; }));
const { rerender } = renderHook(({ on }) => useViewerPolling(1000, tick, on), { initialProps: { on: true } });
await vi.advanceTimersByTimeAsync(3000);
expect(tick).toHaveBeenCalledTimes(1);
resolve();
await vi.advanceTimersByTimeAsync(1000);
expect(tick).toHaveBeenCalledTimes(2);
rerender({ on: false });
resolve();
await vi.advanceTimersByTimeAsync(5000);
expect(tick).toHaveBeenCalledTimes(2);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
/** A visibility-gated interval that never overlaps its own ticks (spec §5). */
export function useViewerPolling(intervalMs: number, tick: () => Promise<void>, enabled: boolean): void {
const tickRef = useRef(tick);
tickRef.current = tick;
useEffect(() => {
if (!enabled) return;
let disposed = false;
let inFlight = false;
let timer: ReturnType<typeof setInterval> | null = null;
const run = async () => {
if (disposed || inFlight || document.visibilityState !== "visible") return;
inFlight = true;
try { await tickRef.current(); } finally { inFlight = false; }
};
const start = () => { if (timer === null) timer = setInterval(run, intervalMs); };
const stop = () => { if (timer !== null) { clearInterval(timer); timer = null; } };
const onVisibility = () => {
if (document.visibilityState === "visible") { void run(); start(); } else { stop(); }
};
document.addEventListener("visibilitychange", onVisibility);
onVisibility();
return () => {
disposed = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [intervalMs, enabled]);
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { canSave, initialViewerState, pollEffect, reduceViewer, type ViewerDocState } from "./viewerState";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const loaded = (truncated = false): ViewerDocState =>
reduceViewer(initialViewerState, { type: "loaded", hash: H1, truncated });
const poll = (s: ViewerDocState, hash: string | null, exists = true) =>
reduceViewer(s, { type: "polled", poll: { exists, hash, size: exists ? 1 : null } });
describe("reduceViewer", () => {
it("seeds both hashes from an untruncated load", () => {
expect(loaded()).toMatchObject({ doc: "clean", disk: "same", baseHash: H1, diskHash: H1 });
});
it("leaves diskHash unknown after a truncated load, so the first poll seeds it silently", () => {
const s = loaded(true);
expect(s.diskHash).toBeNull();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "same", diskHash: H2 });
expect(pollEffect(s, after)).toBe("none");
});
it("an unchanged poll is a no-op", () => {
const s = loaded();
expect(pollEffect(s, poll(s, H1))).toBe("none");
});
it("a changed poll on a clean doc reloads", () => {
const s = loaded();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "changed", diskHash: H2, doc: "clean" });
expect(pollEffect(s, after)).toBe("reload");
const reloaded = reduceViewer(after, { type: "reloaded", hash: H2, truncated: false, polledHash: H2 });
expect(reloaded).toMatchObject({ disk: "same", baseHash: H2, diskHash: H2, justReloaded: true });
});
it("a truncated reload adopts the polled hash, not the prefix hash; the next identical poll is a no-op", () => {
// A truncated load never gets a comparable full-file hash of its own, so a
// poll-driven reload of a large file must seed diskHash from the poll's
// hash (spec Decision 2) -- otherwise every poll re-triggers a reload.
const seeded = poll(loaded(true), H2);
const changed = poll(seeded, H3);
expect(pollEffect(seeded, changed)).toBe("reload");
const reloaded = reduceViewer(changed, { type: "reloaded", hash: H1, truncated: true, polledHash: changed.diskHash });
expect(reloaded).toMatchObject({ disk: "same", diskHash: H3, baseHash: H1, justReloaded: true });
expect(pollEffect(reloaded, poll(reloaded, H3))).toBe("none");
});
it("a clean doc still marked changed (its reload failed) retries on the next identical poll", () => {
const changed = poll(loaded(), H2);
expect(pollEffect(changed, poll(changed, H2))).toBe("reload");
});
it("a changed poll on a dirty doc shows the banner and never reloads", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const after = poll(s, H2);
expect(after).toMatchObject({ doc: "dirty", disk: "changed" });
expect(pollEffect(s, after)).toBe("banner");
expect(pollEffect(after, poll(after, H2))).toBe("none");
});
it("overwrite-on-save adopts the disk hash as the base", () => {
const s = poll(reduceViewer(loaded(), { type: "edited" }), H2);
const o = reduceViewer(s, { type: "overwrite_on_save" });
expect(o).toMatchObject({ baseHash: H2, disk: "same", overwrite: true, doc: "dirty" });
expect(canSave(o, true)).toBe(true);
});
it("a save clears dirty and aligns hashes; a conflict marks disk changed", () => {
const s = reduceViewer(loaded(), { type: "edited" });
expect(reduceViewer(s, { type: "saved", hash: H2, diskHash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
expect(reduceViewer(s, { type: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
});
it("a save another writer overtook keeps our base but shows Changed on disk (M2)", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const raced = reduceViewer(s, { type: "saved", hash: H2, diskHash: H3 });
expect(raced).toMatchObject({ doc: "dirty", disk: "changed", baseHash: H2, diskHash: H3, overwrite: false });
expect(canSave(raced, true)).toBe(false);
// The next poll reporting that same foreign hash is quiet: the banner stays up.
const next = poll(raced, H3);
expect(next).toMatchObject({ disk: "changed", doc: "dirty" });
expect(pollEffect(raced, next)).toBe("none");
// Overwrite adopts what is on disk, not our own hash.
expect(reduceViewer(next, { type: "overwrite_on_save" })).toMatchObject({ baseHash: H3, disk: "same" });
});
it("a gone file disables saving but keeps the buffer state", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const gone = poll(s, null, false);
expect(gone).toMatchObject({ disk: "gone", doc: "dirty" });
expect(canSave(gone, true)).toBe(false);
expect(pollEffect(s, gone)).toBe("banner");
});
it("a poll refused as not running flags the container down and a good one clears it", () => {
// Regression: start from a dirty doc, not a clean one -- otherwise
// canSave(down, true) is false purely because doc !== "dirty", and the
// assertion never actually exercises containerDown.
const dirty = reduceViewer(loaded(), { type: "edited" });
const down = reduceViewer(dirty, {
type: "poll_failed",
message: "Start the project before checking this file for changes — it runs inside the running container.",
});
expect(down).toMatchObject({ containerDown: true, pollError: null });
expect(canSave(down, true)).toBe(false);
expect(poll(down, H1).containerDown).toBe(false);
});
it("any other poll failure is kept as its own message, does not claim the container is down, and clears on a good poll", () => {
const dirty = reduceViewer(loaded(), { type: "edited" });
const down = reduceViewer(dirty, { type: "poll_failed", message: "Start the project before checking this file for changes — files live in its container." });
const failed = reduceViewer(down, { type: "poll_failed", message: "Could not check the file: Permission denied" });
expect(failed).toMatchObject({ containerDown: false, pollError: "Could not check the file: Permission denied" });
expect(canSave(failed, true)).toBe(true);
expect(poll(failed, H1)).toMatchObject({ pollError: null, containerDown: false });
expect(poll(failed, null, false)).toMatchObject({ pollError: null, disk: "gone" });
});
it("a hash-less poll and a gone file reappearing both clear the flags; the reappeared file is same", () => {
const gone = poll(loaded(), null, false);
expect(poll(gone, H1)).toMatchObject({ disk: "same" });
expect(poll(gone, null)).toMatchObject({ disk: "gone", containerDown: false, pollError: null });
});
it("canSave needs dirty + editable + disk in sync", () => {
expect(canSave(loaded(), true)).toBe(false);
const dirty = reduceViewer(loaded(), { type: "edited" });
expect(canSave(dirty, true)).toBe(true);
expect(canSave(dirty, false)).toBe(false);
expect(canSave(poll(dirty, H2), true)).toBe(false);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* The viewer's reload/dirty/conflict rules as a pure reducer (spec §5).
*
* Two hashes, deliberately: `baseHash` is what the buffer was loaded from or
* last saved as -- the save's precondition. `diskHash` is the last full-file
* hash the poll reported. They differ only for a truncated (read-only) load,
* where the read's hash covers a prefix and can never equal `sha256sum`; the
* poll then seeds `diskHash` without triggering a reload.
*
* The same prefix-vs-full-file split applies to a poll-driven reload of a
* truncated file: the fresh read's hash is still only a prefix hash, so a
* `reloaded` action for a truncated file adopts the *polled* hash as the new
* `diskHash` rather than the read's own hash. Without this, a large file
* would re-download on every poll tick forever (spec Decision 2).
*/
import type { ViewerPoll } from "../lib/types";
import { NOT_RUNNING_PREFIX } from "./ipcMessages";
export type DocStatus = "clean" | "dirty";
export type DiskStatus = "same" | "changed" | "gone";
export interface ViewerDocState {
doc: DocStatus;
disk: DiskStatus;
/** Hash the buffer was loaded from / last saved as. */
baseHash: string | null;
/** Last known full-file hash on disk (null until known). */
diskHash: string | null;
/** The last poll was refused because the project's container is not running. */
containerDown: boolean;
/**
* The last poll failed for any other reason (an unreadable file, a Docker
* hiccup), with the backend's sentence. Changes on disk go unseen until a
* poll succeeds, but saving stays possible: the write re-checks the hash.
*/
pollError: string | null;
/** Set for one render after a clean reload; UI shows "Reloaded". */
justReloaded: boolean;
/** True when the user chose "Overwrite on save" after a disk change. */
overwrite: boolean;
}
export type ViewerAction =
| { type: "loaded"; hash: string; truncated: boolean }
| { type: "edited" }
| { type: "polled"; poll: ViewerPoll }
| { type: "poll_failed"; message: string }
| { type: "reloaded"; hash: string; truncated: boolean; polledHash: string | null }
| { type: "overwrite_on_save" }
| { type: "saved"; hash: string; diskHash: string }
| { type: "save_conflict" }
| { type: "save_gone" };
export const initialViewerState: ViewerDocState = {
doc: "clean",
disk: "same",
baseHash: null,
diskHash: null,
containerDown: false,
pollError: null,
justReloaded: false,
overwrite: false,
};
export function reduceViewer(state: ViewerDocState, action: ViewerAction): ViewerDocState {
const s = { ...state, justReloaded: false };
switch (action.type) {
case "loaded":
return { ...initialViewerState, baseHash: action.hash, diskHash: action.truncated ? null : action.hash };
case "edited":
return { ...s, doc: "dirty" };
case "polled": {
const ok = { ...s, containerDown: false, pollError: null };
if (!action.poll.exists) return { ...ok, disk: "gone" };
const hash = action.poll.hash;
if (hash === null) return ok;
if (ok.diskHash === null) return { ...ok, diskHash: hash, disk: ok.disk === "gone" ? "same" : ok.disk };
if (hash === ok.diskHash) return { ...ok, disk: ok.disk === "gone" ? "same" : ok.disk };
// Changed on disk. "Overwrite on save" adopted a base; a further change
// on disk invalidates it again.
return { ...ok, diskHash: hash, disk: "changed", overwrite: false };
}
case "poll_failed":
// Only the backend's "Start the project before …" refusal means the
// container is down; anything else is reported as what it says.
return action.message.startsWith(NOT_RUNNING_PREFIX)
? { ...s, containerDown: true, pollError: null }
: { ...s, containerDown: false, pollError: action.message };
case "reloaded":
return {
...s,
doc: "clean",
disk: "same",
baseHash: action.hash,
diskHash: action.truncated ? action.polledHash : action.hash,
justReloaded: true,
overwrite: false,
};
case "overwrite_on_save":
return { ...s, baseHash: s.diskHash, disk: "same", overwrite: true };
case "saved":
// The base is always the hash of the bytes written. If the disk already
// held something else right after the swap, another writer landed after
// us: the buffer is not what is on disk, so say "Changed on disk" (with
// Reload / Overwrite) rather than adopt the other writer's hash (M2).
if (action.diskHash !== action.hash) {
return { ...s, doc: "dirty", disk: "changed", baseHash: action.hash, diskHash: action.diskHash, overwrite: false };
}
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, overwrite: false };
case "save_conflict":
return { ...s, disk: "changed", overwrite: false };
case "save_gone":
return { ...s, disk: "gone" };
}
}
/** What EditorPane does after a poll: nothing, reload silently, or show the banner. */
export function pollEffect(before: ViewerDocState, after: ViewerDocState): "none" | "reload" | "banner" {
if (after.disk === "gone") return before.disk === "gone" ? "none" : "banner";
if (after.disk !== "changed") return "none";
// A clean doc still marked "changed" means its reload failed; retry it
// rather than leave stale text under a "Changed on disk" badge.
if (after.doc === "clean") return "reload";
return after.diskHash === before.diskHash ? "none" : "banner";
}
export function canSave(state: ViewerDocState, editable: boolean): boolean {
return editable && state.doc === "dirty" && state.disk === "same" && !state.containerDown;
}
+42
View File
@@ -0,0 +1,42 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import type { Extension } from "@codemirror/state";
// Syntax colours come from the `--syntax-*` tokens in index.css (P12), not
// hard-coded hex, even though the values match the GitHub-dark ANSI palette
// TerminalView.tsx already uses.
export const viewerTheme: Extension = [
EditorView.theme(
{
"&": { backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", height: "100%", fontSize: "13px" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", caretColor: "var(--accent)" },
".cm-scroller": { overflow: "auto" },
".cm-gutters": { backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)", borderRight: "1px solid var(--border-color)" },
".cm-activeLine": { backgroundColor: "var(--accent-muted)" },
".cm-activeLineGutter": { backgroundColor: "var(--accent-muted)" },
".cm-triple-c-target": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "var(--accent-muted)" },
".cm-panels": { backgroundColor: "var(--bg-secondary)", color: "var(--text-primary)", borderBottom: "1px solid var(--border-color)" },
".cm-searchMatch": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "var(--success-muted)" },
},
{ dark: true },
),
syntaxHighlighting(
HighlightStyle.define([
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--syntax-keyword)" },
{ tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" },
{ tag: [t.comment, t.lineComment, t.blockComment], color: "var(--text-secondary)", fontStyle: "italic" },
{ tag: [t.number, t.bool, t.null, t.atom], color: "var(--syntax-number)" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syntax-function)" },
{ tag: [t.typeName, t.className, t.namespace], color: "var(--syntax-type)" },
{ tag: [t.propertyName, t.attributeName], color: "var(--syntax-property)" },
{ tag: t.heading, fontWeight: "bold", color: "var(--accent)" },
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strong, fontWeight: "bold" },
{ tag: t.link, color: "var(--accent)", textDecoration: "underline" },
{ tag: t.invalid, color: "var(--syntax-keyword)", textDecoration: "underline wavy" },
]),
),
];
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Triple-C — file</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/viewer/main.tsx"></script>
</body>
</html>
+9
View File
@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
@@ -22,4 +23,12 @@ export default defineConfig({
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("index.html", import.meta.url)),
viewer: fileURLToPath(new URL("viewer.html", import.meta.url)),
},
},
},
}); });
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,536 @@
# App-command lockdown via Tauri AppManifest — design
Date: 2026-09-22
Status: approved in conversation (user); revised against the implemented viewer (`bf22910`) and
the review rulings in "Decisions made during review"; plan at
`docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md`
Follows: `2026-09-22-terminal-file-viewer-design.md` (the viewer windows this exists to confine)
## Goal
Make every application command (the `generate_handler!` list in `lib.rs`) permission-gated per
window, so a `file-viewer-*` window can invoke exactly its five `viewer_*` commands and nothing
else, and the `main` window keeps exactly what it invokes today. Close the residual risk the
viewer spec records in its §6: "a compromised viewer window can still invoke other app commands,
because `build.rs` does not restrict app commands per window."
Non-goals: scoping arguments of app commands (the ACL only allows/denies command names; argument
validation stays in Rust as today), changing any plugin grant, `removeUnusedCommands` (see §3.6).
## 1. Current state (verified)
- `app/src-tauri/build.rs` is `tauri_build::build()`, i.e. `try_build(Attributes::default())`
with an empty `AppManifest` (tauri-build 2.6.0 `src/lib.rs:454`).
- Versions from `Cargo.lock`: tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0,
tauri-codegen 2.6.0, tauri-macros 2.6.0. Everything below is read from those sources under
`~/.cargo/registry/src/index.crates.io-*/`.
- 115 commands are registered in `generate_handler!` (`lib.rs:469-607`): the 109 that predate
the viewer plus its six (`open_file_viewer`, `viewer_get_state`, `viewer_choose_file`,
`viewer_read_file`, `viewer_poll_file`, `viewer_write_file`). All 115 are invoked from
`app/src`, every one through an `export const` wrapper in `lib/tauri-commands.ts`, which is
the only non-test file that imports `@tauri-apps/api/core` (the earlier direct
`invoke("terminal_input")` in `hooks/useTerminal.ts` is gone; only a comment mentions it).
No invoke name exists without a registration. None uses `#[command(rename = …)]`, so the
invoke name is always the function identifier.
- `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions.
`capabilities/file-viewer.json` is `windows: ["file-viewer-*"]` and grants four core
permissions. `tauri.conf.json` has no `app.security.capabilities` key, so every file under
`capabilities/` is active (tauri-build `acl.rs:424-429`).
- Both files already have an exact-set census test: `the_capability_grants_are_the_ones_that_were_reviewed`
(`lib.rs:924`) for `default.json`, and `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`
plus `the_default_capability_is_scoped_to_the_main_window_only` (`file_viewer/mod.rs:63,89`)
for `file-viewer.json` and the `windows` lists. All three will fail as written the moment
bare `allow-*` grants appear and are updated in the same change (§3.4).
- The viewer's five `viewer_*` commands take `window: tauri::Window` and gate on the caller's
label inside `commands/file_viewer_commands.rs`; `open_file_viewer` is main-only by the same
mechanism. The `default.json` description, `file-viewer.json` description and `CLAUDE.md`
(Backend Structure and Key Conventions) all currently describe app commands as ungated by
capability and point at this spec as the pending fix; all three are rewritten here (§3.5).
- The only other non-main window is the browser-view pop-out, label `browser-view-<id>`
(`browser_view/popout.rs:71,104`), `WebviewUrl::External`. No capability names it and no
capability has `remote`, so it has no IPC at all — before and after this change.
- `gen/schemas/acl-manifests.json` and `gen/schemas/capabilities.json` are **tracked in git**
and rewritten by tauri-build on every build; the app manifest will show up there as a
reviewable diff.
- CI (`.gitea/workflows/build-app-preview.yml`, `build-app.yml`) runs `npm run build`
(`tsc && vite build`) and `tauri build`. It runs **neither `cargo test` nor `vitest`**. Any
check that must hold on every merge therefore has to fail the *build*, not a test.
## 2. Mechanism (tauri-build 2.6.0 / tauri-utils 2.9.0 / tauri 2.11.0)
### 2.1 What `AppManifest::commands` generates
```rust
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["check_docker", /* … */])),
)
```
- `commands` takes `&'static [&'static str]` (`tauri-build/src/acl.rs:87-114`).
- `app_manifest_permissions` (`acl.rs:265-335`) calls
`tauri_utils::acl::build::autogenerate_command_permissions`, which for each command writes
**into the source tree** at `src-tauri/permissions/autogenerated/<command>.toml`
(`tauri-utils/src/acl/build.rs:289-317`):
```toml
[[permission]]
identifier = "allow-<command with _ → ->"
commands.allow = ["<command verbatim>"]
[[permission]]
identifier = "deny-<command with _ → ->"
commands.deny = ["<command verbatim>"]
```
Naming: only `_` is replaced by `-` (`build.rs:290`). `viewer_read_file`
`allow-viewer-read-file`. Identifiers may not contain `_` (`identifier.rs:96-109`), so the
kebab form is mandatory in capability files; the command name inside stays snake_case.
- Then `permissions/**/*` is globbed too (default `permissions_path_pattern`), so any hand-written
`.toml`/`.json` under `permissions/` is also part of the app manifest. **Stale files are never
deleted**: a command dropped from `.commands()` leaves its `.toml` behind, still defining a
permission a capability can reference. §3.2 handles this.
- No app `default` permission set is generated (`acl.rs` has no such logic; only
`InlinedPlugin` has `DefaultPermissionRule`).
- The manifest is stored under key `__app-acl__` (`APP_ACL_KEY`, `tauri-utils/src/acl/mod.rs:50`)
in `gen/schemas/acl-manifests.json`, copied to `OUT_DIR/acl-manifests.json`, and embedded by
`generate_context!` (`tauri-codegen/src/context.rs:393-417`, `Resolved::resolve`).
- The app manifest exists iff it has at least one permission, permission set or default
(`acl.rs:400-443`, `has_app_manifest`). `.commands(&[])` is the same as no manifest.
### 2.2 How capabilities reference app permissions
- Bare identifier, no prefix: `"allow-viewer-read-file"`. Resolution takes
`permission_id.get_prefix().unwrap_or(APP_ACL_KEY)` (`resolved.rs:340-372`); `core:event:…`
and `dialog:…` keep working unchanged.
- `windows` / `webviews` are glob patterns via `glob::Pattern` (`resolved.rs:199-208`,
matched in `authority.rs:460-461`), so `"file-viewer-*"` is valid. Labels are **not**
validated against configured windows at build time (no such check in tauri-build or
tauri-codegen).
- `local` defaults to `true`; both the bundled `tauri://localhost` document and the Vite
`devUrl` count as local (`webview/mod.rs:1698-1740`, `is_local_url`: tauri protocol, or
relative to `devUrl`/`frontendDist`). The viewer's `viewer.html` is served the same way as
`index.html`, so it is local in both dev and release.
### 2.3 Runtime enforcement
`Webview::on_message` (`tauri-2.11.0/src/webview/mod.rs:1794-1849`):
```rust
// we only check ACL on plugin commands or if the app defined its ACL manifest
if (plugin_command.is_some() || has_app_acl_manifest)
&& request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
&& invoke.acl.is_none()
{ /* reject */ }
```
- **Today** (`has_app_acl_manifest == false`): app commands skip the ACL entirely. That is the
hole.
- **With a manifest**: `resolve_access` (`ipc/authority.rs:439-471`) must return an allow whose
capability `windows`/`webviews` glob matches the caller and whose context matches the origin.
Otherwise the invoke is rejected **before** the `generate_handler!` closure runs; the
`#[command]` wrappers do no allow/deny logic of their own.
- **Unlisted command = denied.** A registered command with no `allow-*` permission anywhere never
enters `allowed_commands`, so `resolve_access` returns `None`. Debug message:
`"<cmd> not allowed. Command not found"`. There is no build-time warning for this case.
- **Listed but not registered** = a permission nobody can use; no build error. If granted and
invoked, the handler's fallthrough returns "unhandled" — harmless, but §3.2 rejects it anyway.
- **`deny-*` is global.** `resolve_access` tests `denied_commands.get(cmd).map(..).is_some()`,
which is true for any deny entry regardless of window or origin. Never grant a `deny-*` in the
viewer capability expecting it to stay confined; the design uses allow-lists only, and the
build check bans `deny-*` outright.
- Denial text: debug builds get `resolve_access_message` (`authority.rs:229-437`), e.g.
`"<cmd> not allowed. Permissions associated with this command: allow-<slug>"` or
`"<cmd> not allowed on window \"file-viewer-3\", … allowed on: [windows: \"main\", …]"`.
Release builds get `"Command <cmd> not allowed by ACL"`. The frontend's `refusalText.ts`
wrapper-stripping already tolerates the `invoke failed:`/`Error:` prefixes.
### 2.4 Build-time failure modes
- Unknown bare permission in a capability file (typo, or a command missing from the manifest):
`validate_capabilities` (`tauri-build/src/acl.rs:353-392`) bails with
`Permission <id> not found, expected one of …`; `build()` exits 1. This also fires for any
bare identifier when there is no app manifest at all.
- Capabilities inlined in `tauri.conf.json` are *not* validated there and fail later in
`generate_context!` as a panic (`failed to resolve ACL`). Keep capabilities as files.
- Duplicate capability identifiers across files are an error (`build.rs:204-244`).
- A bad glob in `windows` is a resolve-time error.
- The build script's CWD is `src-tauri`; the `./permissions/autogenerated` path is relative to it.
### 2.5 Dev vs release
Enforcement is identical. `cfg(debug_assertions)` changes only the error text and whether the
raw ACL map is retained for messages (`authority.rs:29-30, 77-101`). `tauri dev` versus `tauri
build` differs only in `custom-protocol`, which does not touch the ACL. A command missing from a
capability is therefore caught the first time anyone opens the affected screen in `tauri dev`,
with a message naming the permission.
## 3. Design
### 3.1 Capability files
`capabilities/default.json` (unchanged `windows: ["main"]`) gains one bare `allow-<slug>` entry
per main-window command — 110: the 109 that predate the viewer plus `open_file_viewer`. The
existing five plugin/core grants stay exactly as they are. The `description` is amended (§3.5).
`capabilities/file-viewer.json` (exists, `windows: ["file-viewer-*"]`, four core grants)
gains exactly `allow-viewer-get-state`, `allow-viewer-choose-file`, `allow-viewer-read-file`,
`allow-viewer-poll-file`, `allow-viewer-write-file`, beside its four core grants.
No permission sets, no `default`, no hand-written files under `permissions/`. The project's
existing test already refuses `*:default` aliases because they expand to sets the file does
not spell out; the same rule applies to app permissions. Enumeration is the point.
Assignment of every command (110 go to **main**; nothing but the viewer's five goes to
**file-viewer**). The rule is a function, not a table: a command whose name starts with
`viewer_` belongs to `file-viewer-*`, every other command belongs to `main`
(`command_census::expected_windows`, §3.2). The table is the rule applied to today's list:
| Group (`lib.rs` comment) | Commands | Window |
|---|---|---|
| Docker | `check_docker`, `check_image_exists`, `build_image`, `get_container_info` | main |
| Projects | `list_projects`, `add_project`, `remove_project`, `update_project`, `start_project_container`, `stop_project_container`, `rebuild_project_container`, `reconcile_project_statuses` | main |
| Notes | `list_notes`, `save_note`, `delete_note` | main |
| Migration | `get_container_staleness`, `migrate_project_to_base`, `confirm_migration`, `rollback_migration`, `get_migration_state` | main |
| Auth bridge | `set_auth_bridge_enabled`, `get_auth_bridge_status` | main |
| Browser view | `set_browser_view_enabled`, `get_browser_view_status`, `check_browser_view_support`, `install_browser_view_support`, `install_browser_view_browser`, `open_browser_view_popout`, `close_browser_view_popout`, `get_browser_view_popout_state`, `set_browser_view_popout_always_on_top`, `open_page_in_container_browser`, `set_container_page_viewport`, `get_container_page_state`, `close_container_page`, `set_browser_view_match_window`, `get_browser_view_match_window` | main |
| Claude token | `acquire_claude_token`, `submit_claude_token_code`, `cancel_claude_token`, `has_claude_token`, `clear_claude_token`, `sweep_claude_token_snapshots` | main |
| Settings | `get_settings`, `update_settings`, `pull_image`, `detect_aws_config`, `inspect_ca_cert_path`, `list_aws_profiles`, `detect_host_timezone` | main |
| Settings export/import | `export_settings`, `preview_settings_import`, `apply_settings_import` | main |
| Terminal | `open_terminal_session`, `terminal_input`, `terminal_resize`, `close_terminal_session`, `paste_image_to_terminal`, `upload_host_file_to_terminal`, `start_audio_bridge`, `send_audio_data`, `stop_audio_bridge` | main |
| Files | `list_container_files`, `download_container_backup`, `download_container_file`, `upload_files_to_container`, `read_container_file`, `rename_container_path`, `create_container_directory` | main |
| AWS | `aws_sso_refresh` | main |
| Updates | `get_app_version`, `check_for_updates`, `check_image_update` | main |
| Help | `get_help_content` | main |
| URL open | `open_url_external` | main |
| Install helper | `detect_install_options`, `run_docker_install` | main |
| Web terminal | `start_web_terminal`, `stop_web_terminal`, `get_web_terminal_status`, `regenerate_web_terminal_token` | main |
| STT | `get_stt_status`, `start_stt`, `stop_stt`, `build_stt_image`, `pull_stt_image`, `transcribe_audio` | main |
| Gateway | `get_gateway_status`, `start_gateway`, `stop_gateway`, `check_gateway_health`, `build_gateway_image`, `pull_gateway_image`, `set_gateway_api_key`, `clear_gateway_api_key`, `get_gateway_auth_token`, `regenerate_gateway_auth_token` | main |
| Inspect | `list_claude_sessions`, `resume_session_command`, `list_container_capabilities`, `list_scheduled_tasks`, `add_scheduled_task`, `update_scheduled_task`, `get_scheduled_task_log`, `set_scheduled_task_enabled`, `run_scheduled_task_now`, `remove_scheduled_task`, `get_scheduler_notifications`, `clear_scheduler_notifications` | main |
| Terminal file viewer | `open_file_viewer` | main |
| Terminal file viewer | `viewer_get_state`, `viewer_choose_file`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file` | file-viewer |
Windows other than main: `browser-view-*` (pop-out) invokes nothing and must stay listed in no
capability — the pane's iframe inside main is a remote origin and has no IPC either. The web
terminal is a WebSocket server, not IPC, and is unaffected.
The Rust label checks in the viewer commands (`open_file_viewer` main-only; `viewer_*`
caller's-own-label-only) stay. The ACL says *which* window may call; the label check says *whose*
registry entry it operates on. They are not redundant.
### 3.2 `build.rs`: manifest derived from `generate_handler!`, checked against capabilities
The manifest must not be a second hand-maintained list. The logic lives in a new
`src/command_census.rs` — plain functions over strings and `serde_json::Value`, no reference to
the crate — that is compiled into **both** the build script (`#[path = "src/command_census.rs"]
mod command_census;` in `build.rs`) and the test build (`#[cfg(test)] mod command_census;` in
`lib.rs`), so the parser the build uses is the parser the tests test and the two cannot drift.
Its surface:
```rust
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>; // handler list, in order, duplicates kept
pub fn allow_permission(command: &str) -> String; // "viewer_read_file" → "allow-viewer-read-file"
pub fn expected_windows(command: &str) -> &'static [&'static str]; // "viewer_*" → ["file-viewer-*"], else ["main"]
pub struct CapabilityFile { pub name: String, pub windows: Vec<String>, pub bare: Vec<String> }
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>;
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String>; // every violation, or empty
```
`build.rs` becomes:
1. `registered_commands` is the line-based extraction the existing
`every_command_is_registered_exactly_once` test uses today (`lib.rs:849-870`), moved
verbatim; that test calls it too. `println!("cargo:rerun-if-changed=…")` for `src/lib.rs`,
`src/command_census.rs` and the `capabilities` directory.
2. **Fail closed on an empty or malformed parse.** An empty list makes `.commands(&[])` a no-op
and silently restores today's unguarded state, so `check` reports (and `build.rs` exits 1 on)
an empty list, a name outside `[a-z0-9_]+`, or a duplicate.
3. Delete every `permissions/autogenerated/<name>.toml` whose `<name>` is not a registered
command before calling tauri-build, so a command removed from `lib.rs` cannot leave a stale
permission behind. **Not** `remove_dir_all`: tauri-build emits
`cargo:rerun-if-changed=permissions` (spike §7.I), so regenerating every file on every build
would give them fresh mtimes and re-run the build script — and recompile the crate — on
every `cargo` invocation forever. Live files are left alone; tauri-utils only rewrites them
when their content changes (`write_if_changed`, `acl/build.rs:308`), so the build settles.
Also refuse anything under `permissions/` other than `autogenerated/`: tauri-build globs
hand-written permission files into the manifest, which would be a second source of grants
that the census does not see. Add `app/src-tauri/permissions/autogenerated/` to
`.gitignore`; the reviewable artifact is `gen/schemas/acl-manifests.json`, already tracked.
4. Read every `capabilities/*.json` with `capability_file` (bare = identifiers without `:`;
scoped-object grants are read by their `identifier`), and `check` asserts:
- every registered command's `allow-<slug>` appears in **exactly one** capability file
(missing → names the command and the `windows` of the file it belongs in; more than one →
names the files);
- every bare identifier is `allow-<slug>` of a registered command (a typo is named; tauri's
own `validate_capabilities` would also fail, but its message dumps every generated
`allow-*`/`deny-*` identifier);
- no bare `deny-*` (global, §2.3) and nothing else bare that is not `allow-*` (so no bare
`default`);
- the file that grants a command has `windows` equal to `expected_windows(command)` — the
"which side does this belong to" rule decided in one place, and the reason the `viewer_*`
naming prefix is now load-bearing.
All violations are reported together, so ten forgotten grants are one build failure.
5. `tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(leaked)))`
where `leaked: &'static [&'static str]` comes from `Box::leak` (ruling: acceptable; the
process is short-lived and it is two lines), propagating the error the way
`tauri_build::build()` does (print and exit 1).
Because this runs inside the build script, it fails `cargo check`, `cargo test`, `tauri dev`
and CI's `tauri build` alike — the one place a check is guaranteed to run on every merge (§1).
`serde_json` is already a dependency and is added to `[build-dependencies]` (already in
`Cargo.lock`, so `--offline` still resolves).
Why "exactly one" rather than "at least one": today no command is shared between windows, and
a command that genuinely needs both is a design change worth a visible edit to
`expected_windows`, not a silent widening.
### 3.3 Frontend check (vitest)
`app/src/test/capabilities.test.ts`, following `icon-config.test.ts`'s pattern of reading
`src-tauri/` files with `readFileSync`. The earlier draft split invoke literals by directory;
that is wrong for the tree as built, because the viewer does not call `invoke` itself — it
imports wrappers from the shared `lib/tauri-commands.ts` (`viewerGetState`, `viewerChooseFile`
in `ViewerApp.tsx`; `viewerReadFile`, `viewerPollFile`, `viewerWriteFile` in `EditorPane.tsx`).
So the test follows imports instead, and does so by parsing every file with the TypeScript
compiler (`ts.createSourceFile`), not a regex scan, so comments, strings, template substitutions
and regex literals are the parser's problem rather than ours:
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every*
code file under `src/` — tests included, not just the files the two closures below cover,
because the viewer's closure can reach anything a relative import can. A specifier this scan
cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender
too, so an un-auditable dynamic import fails the same way a literal one to
`@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file
can invoke.
- The wrapper map is read from `tauri-commands.ts`'s AST: each exported `const NAME = …` must
call `invoke` exactly once, inside its own function body (not at module load, not inside an
IIFE, not as a default argument), with a string-literal command name matching Rust's
`[a-z0-9_]+` (115 wrappers, 115 commands today). Zero or more than one call, a non-literal
argument, `invoke` referenced other than as a direct call, or any dynamic `import()`/`require()`
in the file at all, fails the test.
- Every other module edge is resolved the way Vite 6 actually resolves it, in Vite's own order:
the exact relative path if it names a file; else, for a `.js`/`.mjs`/`.cjs`/`.jsx` path, its
TypeScript twin (`.js``.ts`, then `.tsx`); else `path + ext` over `resolve.extensions`
(`.mjs .js .mts .ts .jsx .tsx .json`) in that order; else, for a directory, `index + ext` in the
same order. A path alias, a query/fragment suffix (`?worker`, `?raw`), a directory import that
would switch Vite to package-entry resolution, or a relative import that resolves outside
`src/`, all fail the test rather than being silently skipped or misresolved — resolution is
fail-closed, not best-effort.
- A wrapper counts as used when a file names it directly (a named import, a named re-export by
name) or reaches it through a namespace import (`import * as X from ".../tauri-commands"`)
**used only as member access**, `X.name` (including `typeof X.name` in a type position). Any
other use of that namespace binding — passed to a function, spread, indexed, aliased again —
fails closed rather than being read as "no wrapper used"; `export *`/`export * as` of the
wrappers file is refused outright, since it cannot be resolved to specific names.
- The **viewer closure** is the transitive set of module targets reachable from
`src/viewer/main.tsx` under that same fail-closed resolution (static imports, `export … from`,
literal `import()`; tests excluded, non-code assets ignored). Today it reaches
`lib/tauri-commands`, `lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
`components/projects/home/filePreview`, none of which import a wrapper except the two viewer
files. `V` = commands of the wrappers those files reach; `M` = commands of the wrappers reached
by every other non-test file under `src/`.
- Assert `M ∩ V = ∅`; `allow-<slug>(V)` **equals** the bare set of `file-viewer.json` (no
over-grant to the untrusted window); `allow-<slug>(M)` is a **subset** of the bare set of
`default.json`; and every wrapper's command is in exactly one of the two files (a cheap mirror
of the Rust rule, so a developer running only vitest sees the same failure).
- `default.json`'s five plugin/core grants and `file-viewer.json`'s four core grants are
asserted as exact lists, the same census style as the Rust tests.
This is the test that turns "a forgotten command silently breaks a feature at runtime" into a
red test on the developer's machine for the *window* dimension: the Rust build check (§3.2)
proves every command is granted in the file its name says it belongs in; this one proves the
code that runs in each window only reaches wrappers that window is granted.
### 3.4 Rust test updates
- `lib.rs` `the_capability_grants_are_the_ones_that_were_reviewed`: split `listed` into prefixed
and bare. The prefixed census stays the exact five-element list. The bare set must equal
`allow_permission` over the registered commands whose `expected_windows` is `["main"]`
(110 today). Keep the `:default` and `store:` refusals.
- `file_viewer/mod.rs` `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`:
the exact list becomes the four core grants plus `allow-viewer-get-state`,
`allow-viewer-choose-file`, `allow-viewer-read-file`, `allow-viewer-poll-file`,
`allow-viewer-write-file`. `the_default_capability_is_scoped_to_the_main_window_only` is
unchanged.
- New `lib.rs` test `the_generated_app_manifest_matches_the_handler_list`: `cargo test` runs
`build.rs`, so `gen/schemas/acl-manifests.json` is fresh; assert its `__app-acl__` entry's
permission identifiers equal `allow-<slug>` `deny-<slug>` over the registered set exactly.
This is the end-to-end proof that the parse in `build.rs` produced what tauri embedded,
independent of the parser it shares with §3.2.
- `every_command_is_registered_exactly_once` is unchanged apart from calling
`command_census::registered_commands` instead of its inline copy.
- `command_census.rs` carries its own unit tests for `check` (missing grant, duplicate grant,
typo, `deny-*`, wrong file, empty list, bad name) — these are the tests that make the build
check trustworthy, and they run under `cargo test` without needing a failing build.
### 3.5 Documentation updates (same change)
- `capabilities/default.json` `description` (the threat model of record): replace the sentence
run that begins "App commands stay ungated by capability in both files" and ends "not yet
built" with the closed state: `build.rs` declares an `AppManifest`, so every app command is
ACL-gated per window; the bare `allow-*` entries are the complete list of app commands the
main window may call and are cross-checked by `build.rs` against `generate_handler!` (the
build fails on a missing, misspelled, duplicated or misfiled grant); the `file-viewer-*`
windows are confined to `capabilities/file-viewer.json`; `deny-*` is global in tauri 2.11.0
and is therefore banned by the same check; the pop-out remains capability-less; and the
Rust label gates in `file_viewer_commands.rs` stay because the ACL says *which* window and
the label says *whose* entry. Amend the opening "verified against tauri 2.11.0's `PLUGINS`
table" framing so it no longer implies app commands are outside this file.
- `capabilities/file-viewer.json` `description`: replace "app commands need no entry here and
are gated by label inside `commands/file_viewer_commands.rs`" with: the five `allow-viewer-*`
grants are the only app commands this window can reach, the label gate is still what stops
window A acting on window B's entry, and `build.rs` refuses any other bare grant here.
- `CLAUDE.md` "Key Conventions" (`CLAUDE.md:598-600`): the bullet saying
`capabilities/default.json` grants plugin commands only and app commands "do not need an
entry there" becomes false. Replace with: a new command needs (1) `#[tauri::command]`, (2) a
`generate_handler!` entry, (3) an `allow-<kebab-name>` entry in the one capability file for
the window that calls it (`viewer_*``file-viewer.json`, everything else →
`default.json`); `cargo check`/`tauri build` fail otherwise, and
`src/test/capabilities.test.ts` fails if the code that runs in a window imports a wrapper that
window is not granted. Note the `_``-` rule, that `deny-*` is off-limits, and that
`permissions/autogenerated/` is generated and ignored.
- `CLAUDE.md` Backend Structure (`CLAUDE.md:179-180`): the `file_viewer/` bullet's "residual
risk … the AppManifest lockdown spec closes it" becomes "closed by `build.rs`'s
`AppManifest`"; add one bullet for `build.rs` + `command_census.rs`.
- `file_viewer/mod.rs` module doc: one sentence that `file-viewer-*` is also the capability
glob, so labels are minted only here.
- `.gitignore`: `app/src-tauri/permissions/autogenerated/`.
### 3.6 Deliberately not done
- `build.removeUnusedCommands` stays off. It compiles out commands no capability grants
(`tauri-macros/src/command/handler.rs:92-142`), but only when the CLI sets
`REMOVE_UNUSED_COMMANDS`, so `cargo test` and `tauri build` would compile different handler
lists, and it matches on the function identifier rather than a `rename`. The §3.2 check gives
the same guarantee at build time without a second code path.
- No `permissions/*.toml` written by hand, no permission sets. Every grant is a literal
`allow-<slug>` string in a capability file, so the census tests stay a flat string compare.
- No `webviews` patterns: this app has one webview per window.
## 4. Migration risks
| Risk | Where it bites | How it is caught |
|---|---|---|
| A registered command is left out of `default.json` | Runtime: that feature's invoke rejected with `not allowed. Permissions associated with this command: allow-…` (release: `Command … not allowed by ACL`) | `build.rs` §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
| Typo in an `allow-*` string | Build | tauri's `validate_capabilities` and the §3.2 check, both at build time |
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts `acl-manifests.json` has the full set |
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 selectively prunes only the files whose `<name>` is no longer a registered command, every build (never `remove_dir_all` — Decision 9); live files are left alone so the build settles; the directory is gitignored |
| A `deny-*` added "for the viewer only" | Denies the command for main too (global) | §3.2 rejects any bare `deny-*` |
| `file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 `expected_windows` assertion and §3.3 equality check |
| Main-window code imports a `viewer*` wrapper (or the viewer imports a main one) | Runtime denial in that window | §3.3 `M ∩ V = ∅` and the per-side set checks |
| A future window label that happens to match `file-viewer-*` | Inherits the viewer grants | Labels are minted only in `file_viewer/`; note in that module's doc comment |
| Existing census tests (`lib.rs:924`, `file_viewer/mod.rs:63`) | Fail as written once bare grants appear | Updated in the same commit (§3.4) |
| Capability grants and `build.rs` land in different commits | Bare grants without a manifest fail the build; a manifest without grants builds but denies every command at runtime | One commit carries `build.rs`, both capability files and the census-test updates (plan Task 2) |
| `gen/schemas/acl-manifests.json` churn in diffs | Noise | Accepted; it is the reviewable evidence of what got embedded |
| Frontend invoked via a computed name | Un-auditable | §3.3 fails on a non-literal `invoke` argument |
Dev vs release: no behavioural difference (§2.5), so `tauri dev` is a faithful rehearsal. The
only visible difference is the error text.
## 5. Testing and manual verification
Automated (all must be green):
- `cd app/src-tauri && cargo test --offline`: §3.4 tests plus the existing census tests.
- `cd app && npx vitest run src/test/capabilities.test.ts` and the full `npm run test`.
- `npx tsc --noEmit`, `cargo clippy --offline` clean, `npm run build`.
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
(a) remove one `allow-*` from `default.json``cargo check --offline` fails naming the
command; (b) misspell one → fails naming it; (c) add `deny-check-docker` → fails; (d) move
`allow-viewer-read-file` into `default.json` → fails the `expected_windows` assertion;
(e) import `listProjects` from `lib/tauri-commands` in `src/viewer/ViewerApp.tsx` → vitest
fails; (f) temporarily make `registered_commands` return `Some(vec![])` → the build fails
on the empty-list rule rather than building an unguarded app.
Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppImage):
1. Cold start: projects list, Docker status, settings, help all render (each is an app command
on the main window).
2. Open a terminal, type, resize, paste an image, drop a file — the terminal group.
3. Files tab: list, view, rename, upload, save-to-host; Backup.
4. Browser view: enable, pop out, resize, close pop-out; in the pop-out's devtools,
`window.__TAURI_INTERNALS__?.invoke("list_projects")` must reject (remote origin, no
capability) exactly as before this change.
5. File viewer: click a path → window opens, reads, "Choose" list when ambiguous, edits, saves,
conflict banner, live reload on an external change (that is `viewer_poll_file`); then in
the viewer's devtools run
`window.__TAURI_INTERNALS__.invoke("list_projects")` and
`…invoke("read_container_file", {...})` — both must reject with
`not allowed on window "file-viewer-1"`. From the main window's devtools,
`invoke("viewer_read_file")` must reject likewise.
6. Settings export/import, gateway, STT, web terminal, scheduler tabs — one action each.
7. Watch the app log for `not allowed` during the whole pass; any occurrence is a missed grant.
## 6. Open questions — resolved
1. **CI test steps: out of scope** (controller ruling). CI runs no test suite (§1). The
build-time check (§3.2) covers the security property on every merge because it runs inside
`tauri build`; the vitest half (§3.3) and the `cargo test` half (§3.4) only run on a
developer's machine. Recommendation to the user, not part of this change: add
`cd app && npm run test` and `cd app/src-tauri && cargo test` steps to
`build-app-preview.yml` after the dependency install.
2. **`Box::leak` in `build.rs`: acceptable** (controller ruling; either was allowed). Chosen
over a generated include file because it is two lines, needs no `OUT_DIR` plumbing, and the
build script exits immediately afterwards.
3. **No `webviews` patterns** (controller ruling). One webview per window; `windows` globs
only.
## Decisions made during review
Checked against the tree at `bf22910` (the completed viewer) and the tauri-build 2.6.0 /
tauri-utils 2.9.0 / tauri 2.11.0 sources pinned by `Cargo.lock`. The user-approved goal
(allow-lists only, one file per window, the build fails rather than a feature breaking at
runtime) is unchanged. What changed:
1. **Five viewer commands, not four (§3.1).** The implemented viewer polls with
`viewer_poll_file`, so `file-viewer.json` gets five `allow-viewer-*` grants and `default.json`
gets 110, for 115 registered commands.
2. **The window rule is a function (§3.2).** `expected_windows(command)` (`viewer_*`
`file-viewer-*`, else `main`) replaces both the "no `viewer_*` in main / only `viewer_*` in
the viewer file" policy assertion and the `SHARED` list. A command that must be callable from
both windows is an edit to that function, which is the visible design change the earlier
draft wanted; the extra list was a second place to say the same thing.
3. **The check logic is a module, not inline `build.rs` (§3.2, §3.4).** A build script cannot
be unit-tested, and the empty-list, typo, `deny-*` and wrong-file rules are exactly the code
whose failure mode is "builds an unguarded app". `command_census.rs` is compiled into both
`build.rs` and the test build, so the rules get ordinary `cargo test` coverage and the
handler-list parser the census test already used becomes the one the build uses.
4. **The vitest test follows imports, not directories (§3.3).** The viewer never calls `invoke`;
it imports wrappers from the shared `lib/tauri-commands.ts`. So the test computes the
viewer's transitive import closure from `viewer/main.tsx` and takes the wrappers that closure
imports; a directory split would have put every viewer command in the "main" bucket and
proved nothing.
5. **Both census tests change, not one (§3.4).** The viewer feature added its own exact-set
test in `file_viewer/mod.rs`; the earlier draft only knew about the `lib.rs` one.
6. **`gen/schemas/*.json` is committed with the change.** It is tracked, tauri-build rewrites it
on every build, and after this change it contains the `__app-acl__` manifest and the
resolved grants — the reviewable evidence of what the binary embeds. Expect a large diff:
`desktop-schema.json` and `linux-schema.json` gain an enum entry per generated permission
(about 12 lines per command, ~2,700 lines across the two files for 115 commands).
9. **Stale-permission cleanup is selective, not `remove_dir_all` (§3.2 step 3).** Found by the
spike: wholesale regeneration would re-run the build script on every `cargo` call.
7. **Atomicity (§4).** A bare `allow-*` in a capability file with no app manifest is a build
error (`Permission allow-… not found`), and a manifest with no grants builds an app in which
every command is denied. `build.rs`, both capability files and the census-test updates are
therefore one commit; the shared module and its tests land before it, and the end-to-end
manifest test, the vitest and the docs after it.
8. **Spike (see §7).** Every claim in §2 that the design leans on was reproduced in a throwaway
worktree before the plan was written.
## 7. Spike results (2026-09-22, throwaway worktree at `bf22910`, then removed)
Environment: tauri-build 2.6.0 / tauri 2.11.0 / tauri-utils 2.9.0 from `Cargo.lock`,
`cargo check --offline` with the shared `target/` dir; each run ~3 s (only `triple-c`
recompiles). A fresh worktree needs `app/dist` to exist (`npm run build`) or
`generate_context!` panics on `frontendDist` before any ACL code runs.
| # | Experiment | Result |
|---|---|---|
| A | Untouched tree | Builds. `acl-manifests.json` has only plugin keys (`core`, `core:*`, `dialog`, `opener`); no `__app-acl__`. |
| B | `build.rs` with `AppManifest::new().commands(Box::leak(…))` for three names | Builds. `permissions/autogenerated/{check_docker,list_projects,viewer_read_file}.toml` written (`allow-check-docker` / `deny-check-docker`, `commands.allow = ["check_docker"]`, header `# Automatically generated - DO NOT EDIT!`). `acl-manifests.json` gains `__app-acl__` with the six identifiers. `desktop-schema.json` and `linux-schema.json` each gain 36 lines (enum entries). `capabilities.json` unchanged until a file references a grant. Nothing else written. |
| C | Bare `allow-check-docker`, `allow-list-projects` in `default.json`; `allow-viewer-read-file` in `file-viewer.json` | Builds. `gen/schemas/capabilities.json` shows the grants under each capability. |
| D | Bare `allow-does-not-exist` | **Fails** (exit 101): `Permission allow-does-not-exist not found, expected one of allow-check-docker, …` — one ~10 KB line listing every identifier, no fuzzy hint. Hence the project's own check with a readable message. |
| E | Typo `allow-check-dokcer` | Fails, same shape. |
| F | Bare `deny-check-docker` in `file-viewer.json` | **Builds** and appears in `capabilities.json` — tauri does not object; only §3.2's ban does. |
| G | Drop `list_projects` from the manifest list, keep its grant | **Builds**: the stale `list_projects.toml` is still globbed. After deleting `permissions/autogenerated`, fails with `Permission allow-list-projects not found`. Stale files are real. |
| H | Original `tauri_build::build()` plus one bare grant | **Fails**: `Permission allow-check-docker not found, expected one of core:default, …`. The message goes to the build script's **stdout** (`println!`). Capability grants cannot land before the manifest. |
| I | Rerun semantics | tauri-build emits `cargo:rerun-if-changed=capabilities` unconditionally (`acl.rs:427`) and `=permissions` when the dir exists (`acl.rs:300-305`). Editing a capability file alone re-runs the script and refreshes `gen/schemas`. Writing a `.toml` makes the *next* run re-run once more, then it settles because `write_if_changed` leaves unchanged files alone. |
| J | Signatures | `AppManifest::commands(mut self, commands: &'static [&'static str]) -> Self` (`acl.rs:100`); `AppManifest` is `Copy`, so no owned alternative — `Box::leak` it is. `try_build(Attributes) -> anyhow::Result<()>`; `build()` prints `{error:#}` and `exit(1)`. |
@@ -0,0 +1,404 @@
# Terminal file viewer/editor — design
Date: 2026-09-22
Status: approved in conversation (user); reviewed against the code 2026-09-22 (see "Decisions
made during review" at the end); plan at `docs/superpowers/plans/2026-09-22-terminal-file-viewer.md`
## Goal
File locations Claude prints in a terminal tab (`src/foo.ts:42`, `/workspace/x/README.md`,
`app/src/lib/urlRelay.ts:139-150`) become clickable. A click opens the file in its **own OS
window**, scrolled to and highlighting the line/range, syntax-highlighted, live-reloading while
Claude changes it, and **editable** so specs and similar files can be read, edited and saved
from inside the app.
Non-goals: tabs inside a viewer window, creating new files, diff/merge views, opening files in a
host editor, locking the viewer window out of every other app command (that is the follow-up
AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`).
## Current state (verified against the tree at `3537b23`)
- Versions: tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0, wry 0.55, Vite 6.4.1,
@xterm/xterm 5.5.0, @xterm/addon-web-links 0.12.0, React 19, bollard 0.18.1.
- `TerminalView.tsx` (props `{ sessionId, active }`; `projectId` is derived from the session in
the store): `WebLinksAddon` (http/https only, its own `registerLinkProvider` inside the addon),
OSC 8 via `linkHandler` (`createOsc8LinkHandler(getHost, readState)`; xterm's
`OscLinkProvider` drops any target that is not `http:`/`https:` **or that fails `new URL()`**
because `allowNonHttpProtocols` is unset), OSC 7777 URL relay. Nothing calls
`terminal.registerLinkProvider` directly. `opensOnClick(event, ctx, modifierPromised)` is a
module-private function in `TerminalView.tsx`; toasts are `useAppState.getState().pushToast`.
- `read_container_file(project_id, path, max_bytes)` in `commands/file_commands.rs` reads via
bollard `download_from_container` (`fetch_container_file`). **The archive endpoint does not
follow a final symlink and the helper refuses links** ("is a link — open its target instead"),
so any path the viewer reads must already be resolved to a regular file. It caps at
`MAX_READ_BYTES` = 8 MiB, returns `FileContents { contents_base64, truncated, size }`, and does
**not** call `require_running`. `validate_container_path` (absolute, no NUL, no `..` segment,
≤ 4096 bytes; it rejects rather than normalises) and `validate_container_write_path` (that plus
`is_under_root` against `CONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp`, compared by
whole segments) are private `fn`s in `file_commands.rs`.
- Execs: the container user is addressed by **name**, `"claude"` (never `uid:gid`);
`docker::exec::exec_oneshot_streams_as(container_id, "claude", cmd, env) -> (stdout, stderr,
exit_code)` runs without stdin, cwd `/workspace`, 8 MiB output cap, lossy UTF-8. There is no
helper that feeds bytes to an exec's stdin. `ExecSessionManager::write_file_to_container
(container_id, file_name, bytes)` lands bytes at `/tmp/<file_name>` owned by the container
user (`container_user_ids`) with mode 0644 via the archive API. `upload_host_file_with_ids`
is the host-file variant of the same tar path. `resolve_container_dir` runs `realpath -m` as
`claude` and re-validates the result against the write roots (allowing the literal path when
`realpath` fails).
- `FileViewerModal` + `components/projects/home/filePreview.ts` (Files tab): `previewKind`,
`imageMimeFor`, `previewLimit`, `decodeBase64`, `looksBinary`, `TEXT_PREVIEW_LIMIT` 1 MiB,
`IMAGE_PREVIEW_LIMIT` 5 MiB. Reuse the classification and limits.
- Only second window today is the browser-view pop-out (`browser_view/popout.rs`), a
remote-origin window with no capability and no IPC. It builds `WebviewWindowBuilder::new(app,
&label, WebviewUrl::External(url))` straight from an **async** command (no
`run_on_main_thread`; Tauri documents that windows must be created from async commands, not
sync ones), hooks `window.on_window_event` for `Destroyed`, and closes with `destroy()`, never
`close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`.
- `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local
window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`.
App commands need no capability entry (CLAUDE.md, "Key Conventions"). *(Historical snapshot at
`3537b23`. Closed 2026-09-22 by the AppManifest lockdown
(`2026-09-22-app-manifest-lockdown-design.md`) — see §6 below.)*
- The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project
path is bind-mounted at `/workspace/<mount_name>` (`ProjectPath { host_path, mount_name }`,
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a
mount.
- Tauri multi-window facts that the design rests on:
- `WebviewUrl::App("viewer.html".into())` is `Url::join`ed onto `build.devUrl` in dev
(`http://localhost:1420/viewer.html`) and onto `tauri://localhost/` in a bundle
(`http://tauri.localhost/` on Windows); both are `Origin::Local`, so capability files apply.
**If `viewer.html` is missing, both Vite's dev server and Tauri's asset lookup silently fall
back to `index.html`** — the main app opens in the viewer window. A test guards against this.
- Capability `windows` entries are `glob::Pattern`s, so `"file-viewer-*"` matches.
- `getCurrentWindow().onCloseRequested(cb)` listens on `tauri://close-requested` and then calls
`destroy()` itself; Rust calls `prevent_close()` whenever a JS listener exists. So the
viewer needs `core:window:allow-destroy` or **the X button stops working** the moment the
listener is registered. `listen`/`unlisten` need `core:event:allow-listen`/`allow-unlisten`.
Rust→window emits need no grant on the receiving side.
- `app.emit_to(label, …)` targets one label, but a bare `listen()` in the main window
(`EventTarget::Any`) still receives it. The viewer listens through
`getCurrentWindow().listen(...)`, and the main window never listens to viewer event names.
- The `app.security.csp` applies to every `.html` Tauri serves, `viewer.html` included. Tauri
adds a `'nonce-…'` to `style-src` only when the entry HTML contains a literal `<style>`
element, and a nonce disables `'unsafe-inline'` — which CodeMirror's `style-mod` needs for its
runtime `<style>` injection. **`viewer.html` must not contain an inline `<style>`.**
## Design
### 1. Path detection in the terminal (`app/src/lib/filePathLinks.ts`, `TerminalView.tsx`)
- A pure matcher `findFilePathLinks(lineText) -> FilePathMatch[]`, where
`FilePathMatch = { start, end, path, line?, col?, endLine? }` (`start` inclusive, `end`
exclusive, string indices into `lineText`), used by a new `ILinkProvider` registered with
`terminal.registerLinkProvider` right after `term.loadAddon(webLinksAddon)` (providers are
asked in registration order; the web-links one runs first, so an `http://` span never reaches
the file matcher as a candidate — the matcher additionally refuses any span overlapping `://`).
- Matches tokens that look like paths **with a file extension** (or a known extensionless name:
`Makefile`, `Dockerfile`, `CLAUDE.md`-style names are already covered by the extension rule),
optionally absolute, optionally `./`/`../` prefixed, followed by optional `:line`,
`:line:col`, or `:start-end`. Also accepts `#L42` / `#L40-L50` suffixes.
- Strips wrapping that Claude's markdown rendering leaves: backticks, parentheses, brackets,
quotes, trailing `.,;:` punctuation.
- Must not match inside URLs (http links are already handled; skip spans overlapping `://`),
bare version numbers (`1.2.3`), or domain names (`example.com` with no `/` — require either a
`/` in the token or an extension from an allowlist of common source/doc extensions for
slash-less tokens).
- Wrapped rows: v1 joins a buffer row with its `isWrapped` continuation rows the way
`WebLinksAddon`'s private `LinkComputer._getWindowedLineStrings` does (walk up while the row
`isWrapped`, walk down while the next row `isWrapped`, 2048-char budget, `translateToString
(true)`), then maps string indices back to `{x, y}` cells. `LinkComputer` is not exported from
the built addon, so the walk is re-implemented in `app/src/lib/xtermLineJoin.ts`
(`joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts }` plus
`stringIndexToCell`). `urlDetector.ts` works on the byte stream, not the buffer, so it has
nothing reusable here. `ILink.range` is 1-based on both axes with an **inclusive** end column;
`provideLinks(bufferLineNumber)` is 1-based and `buffer.active.getLine(y)` is 0-based.
- OSC 8 `file://` targets: set `allowNonHttpProtocols: true` on the handler and dispatch by
scheme in `createOsc8LinkHandler`. With the flag on, xterm hands **every** OSC 8 target to
`activate`/`hover`, including unparseable ones and `javascript:`; so the handler parses with
`new URL()` itself: `file:``onOpenFile(pathname decoded, no line)`, `http(s):` → the
existing `sanitizeRelayUrl``openUrlExternal` path unchanged, anything else → refused
(`console.warn`, nothing opened). A refused target (`javascript:`, any other scheme,
unparseable text) keeps the existing refusal card ("This link will not be opened — it failed
the URL safety check"), which never echoes the target into the DOM; this is the ruled
behaviour (Task 9), not a new hover card. The hover card for a `file:` target shows the path and "Open
in viewer". The `WebLinksAddon` comment that describes the old `allowNonHttpProtocols`
behaviour is updated, not left stale.
- Activation uses the same click gating as web links (`opensOnClick`: no selection drag,
single click; `modifierPromised` for OSC 8 hovers). A click invokes
`openFileViewer(projectId, rawPath, line, col, endLine)``open_file_viewer`. No
confirmation toast — nothing leaves the app and nothing is written without an explicit save.
A rejected open (cap, container not running, project gone) is a `pushToast({ kind: "error" })`.
- Hover: underline + pointer cursor (`decorations`), and the same bottom-left card the OSC 8
handler draws, reading "Open in viewer".
### 2. Path resolution (Rust, `commands/file_viewer_commands.rs` + `file_viewer/resolve.rs`)
- Pure candidate generation `candidate_paths(raw, mount_names) -> Result<Vec<String>, String>`:
- Absolute: exactly one candidate, after `validate_container_path` (which rejects `..`).
- Relative `p`: strip a leading `./`, collapse repeated `/`, reject any `..` segment or NUL or
length > 4096; candidates in order `/workspace/<p>`, then `/workspace/<mount>/<p>` for each
non-empty `mount_name`, de-duplicated. Relative paths are the common case (Claude prints
project-relative paths) and `/workspace/<p>` is first because the terminal's exec cwd is
`/workspace`.
- Existence is checked in **one** exec as `claude`: `sh -c` with the candidates as `$1..$n`,
printing `realpath -e -- "$c"` for each candidate that is a regular file (`test -f`, which
follows symlinks). Output lines are the **resolved** paths, which is what the registry stores
(`fetch_container_file` refuses a link, so the resolved path is the only one that reads). The
resolved path is re-checked with `validate_container_path`; a candidate whose resolution
escapes to an invalid path is dropped. Candidate lists are capped at 16 entries; output lines
are de-duplicated (two candidates may resolve to one file).
- 0 matches → window opens in a "not found" state listing the candidates tried.
- 1 match → open it.
- \>1 match → window opens in a "choose file" state listing matches; choosing one calls
`viewer_choose_file(index)`; the choice is from the Rust-held candidate list, never a path from
the window.
- Container must be running (`require_running`, via `project.container_id`); otherwise error
toast in the main window, no window opened.
### 3. One window per click (`src-tauri/src/file_viewer/{mod.rs,registry.rs,window.rs}`)
- Each successful `open_file_viewer` creates a `WebviewWindow` with label
`file-viewer-<counter>` (monotonic `AtomicU64`, never reused within a process), loading the
app's own bundle at a second Vite entry (`viewer.html``src/viewer/main.tsx`;
`build.rollupOptions.input = { main: "index.html", viewer: "viewer.html" }`), title
`<basename> — <project name>` (set from Rust), default 900×700, min 480×320, resizable. The
command is `async` (Tauri's requirement for window creation from a command).
- A registry `ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 }`
managed with `app.manage(...)` (separate from `AppState`, like the browser view keeps its own
state), where
`ViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates}
| NotFound{tried}, initial: Location { line, col, end_line } }`.
Entry removed on `WindowEvent::Destroyed`.
- If a window for the same `(project_id, container_path)` is already open (Resolved entries
only): `unminimize()`, `set_focus()` and `app.emit_to(label, "file-viewer-goto", Location)` to
that window instead of opening a duplicate; the viewer listens with
`getCurrentWindow().listen("file-viewer-goto", ...)`.
- Cap: 20 viewer windows (`MAX_VIEWER_WINDOWS`); the 21st click returns an error the main window
shows as a toast. The cap counts registry entries, and an entry is reserved **before** the
window is built so two concurrent clicks cannot both pass the check.
- When the project's container stops/is removed, open viewer windows stay open but their reads
fail and they show a "container not running" banner; they are not force-closed (unsaved text
must not be destroyed). The registry stores `project_id`, not `container_id`, so a recreated
container is picked up on the next poll.
- `lib.rs`'s shutdown teardown does nothing for viewers: the process exits and the OS closes
them. Unsaved edits in a viewer are lost on app quit (accepted; it is what the main window's
close does to a terminal too).
### 4. Editor (`app/src/viewer/`)
- CodeMirror 6, direct packages only (no `codemirror` meta-package, no
`@codemirror/language-data` — it hard-depends on 13 more grammars, one of which,
`legacy-modes/mode/pug`, calls `Function(...)`): `@codemirror/state`, `view`, `commands`,
`search`, `language`, `lang-markdown`, `lang-javascript`, `lang-rust`, `lang-python`,
`lang-json`, `lang-yaml`, `lang-css`, `lang-html`, `legacy-modes` (toml, shell via
`StreamLanguage.define`), plus `@lezer/highlight` for the tag-based highlight style. Language
packs are chosen by extension in `viewer/languages.ts` and loaded with dynamic `import()`
(same-origin chunks, fine under `script-src 'self'`). Verified: none of these packages uses
`eval`/`new Function`/Workers; `style-mod` injects a `<style>` element, allowed by
`style-src 'unsafe-inline'`. No CSP change.
- Theme: `EditorView.theme` + `HighlightStyle` in `viewer/viewerTheme.ts`, colours from the
app's CSS custom properties (`index.css` is imported by the viewer entry so the tokens exist).
- Line numbers, search (Ctrl/Cmd+F, `searchKeymap`), go to line (`gotoLine`), history,
`indentWithTab`, `lineWrapping` for markdown/plain text, highlight of the target line/range
(a `Decoration.line` `StateField` driven by a `setHighlight` `StateEffect`), scroll target
into view centred (`EditorView.scrollIntoView(pos, { y: "center" })`) on open and on
`file-viewer-goto`.
- Header: container path, project name, state badges (Read-only reason / Unsaved / Saved /
Changed on disk / Container not running), Save button.
- **Editable** only when: text (per `filePreview.ts` classification and `looksBinary`), not
truncated (≤ 1 MiB), and Rust says `editable: true` (the resolved path is inside
`CONTAINER_WRITE_ROOTS`). Otherwise the editor is read-only (`EditorState.readOnly` +
`EditorView.editable` both false) with a badge stating why. Images render read-only as in
`FileViewerModal` (Blob + object URL, `IMAGE_PREVIEW_LIMIT`).
### 5. Save + live reload
- Content identity: SHA-256 hex of file bytes. Two producers, one definition — the hash of the
file's full bytes: Rust computes it (`sha2`, already a dependency) over the bytes it fetched,
and `sha256sum` inside the container computes it for polls and for the save check. The two
agree exactly when the read was not truncated, which is the only case in which the hash is
used as a save base.
- `viewer_read_file(max_bytes)` → `ViewerFile { contents_base64, truncated, size, hash,
editable, readonly_reason: Option<String> }` for the caller's own target (`Resolved` only).
`hash` is over the returned bytes. `editable` is `validate_container_write_path(resolved)`
succeeding; `readonly_reason` is its message otherwise.
- `viewer_poll_file()``ViewerPoll { exists, hash: Option<String>, size: Option<u64> }` via one
exec as `claude`: `sha256sum -- "$1"` + `stat -c %s -- "$1"`. Missing file → `exists: false`.
This is one small exec per window per tick, not a 1 MiB archive download.
- Poll every 2 s while `document.visibilityState === "visible"`; pause otherwise; poll once
immediately on `visibilitychange` back to visible. The reducer tracks `diskHash` (last known
full-file hash; seeded from the read's `hash` when `truncated` is false, otherwise from the
first poll) and `baseHash` (the hash the buffer was loaded from).
- Poll hash == `diskHash` → nothing.
- Poll hash changed, editor clean → `viewer_read_file` again, replace document, preserve
scroll position and cursor (clamped), brief "Reloaded" indicator.
- Poll hash changed, editor dirty → banner "Changed on disk" with **Reload (discard mine)** and
**Overwrite on save**. No auto-merge. "Overwrite on save" sets `baseHash` to the polled hash
so the next save succeeds.
- `exists: false` → banner "File no longer exists"; content kept, editable text retained so the
user can copy it; saving is disabled (no file creation).
- Poll error (container not running) → banner "Container not running", editor keeps its state,
polling continues so the banner clears when the container is back.
- Save: Ctrl/Cmd+S or Save button → `viewer_write_file(contents_base64, base_hash)``Ok(hash)`.
- Mechanism (**all mutation of the target directory happens as the container user**; the
Docker archive API, which writes as root, only ever lands the payload in `/tmp`):
1. Rust: `Resolved` entry only; decode; refuse if `!editable`, if content > 1 MiB, or if
`base_hash` is not 64 hex chars.
2. `ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>",
bytes)` → `/tmp/triple-c-viewer-<uuid>`, owned by the container user, mode 0644.
3. One `sh` script as `claude` (`exec_oneshot_streams_as`), args `target tmp base_hash`:
`test -f target` else exit 4 (gone); `sha256sum target``base_hash` → exit 3
(conflict); if the directory is writable: `cp tmp dir/.<name>.triple-c-tmp`, `chmod
--reference=target staged`, `mv -f staged target`; else `cat tmp > target` (in-place
fallback for a writable file in a read-only directory); `rm -f tmp` in every branch
(`trap`); print `sha256sum target` on success.
4. Exit 3 → `Err("conflict: …")` (the window shows the "Changed on disk" banner); exit 4 →
`Err("gone: …")`; other non-zero → the clipped stderr.
- Ownership: a non-root user cannot `chown`, so the file ends up owned by the container user
— the same thing Claude Code's own edits produce. Mode is preserved via `chmod --reference`.
- Symlinks: the registry already holds the `realpath -e` target (§2), and `editable` was
computed on it, so a link into a non-writable root is read-only.
- Returns the new hash; editor becomes clean, `baseHash = diskHash = new hash`.
- Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by
`onCloseRequested` (`event.preventDefault()` when dirty); Discard calls `destroy()`.
### 6. Security
- New capability file `capabilities/file-viewer.json`, `windows: ["file-viewer-*"]`, granting
exactly: `core:event:allow-listen`, `core:event:allow-unlisten`, `core:window:allow-destroy`
(required for `onCloseRequested` to be able to close the window at all — see "Current state"),
and `core:webview:allow-internal-toggle-devtools` (dev-only, as in `default.json`). No
`allow-close`, no `set-title`/`set-focus`/`is-minimized` (title and focus are set from Rust;
polling pauses on `visibilityState`). `default.json` stays `windows: ["main"]`. Update the
threat-model census in `default.json`'s description to name the second file and why the viewer
gets `allow-destroy`.
- Viewer commands take `window: tauri::Window` and check the label:
- `open_file_viewer``window.label() == "main"` only.
- `viewer_get_state`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file`,
`viewer_choose_file``file-viewer-*` only, and they operate solely on the registry entry
for **the caller's own label**. No viewer command accepts a path or a label as an argument.
- Rendering: CodeMirror renders text as DOM text nodes; nothing uses `innerHTML` /
`dangerouslySetInnerHTML` on file content. CSP unchanged.
- Closed by the AppManifest follow-up (`2026-09-22-app-manifest-lockdown-design.md`, implemented):
`build.rs` now declares a Tauri `AppManifest` from `generate_handler!`, so a compromised viewer
window can invoke only the five `allow-viewer-*` app commands granted in
`capabilities/file-viewer.json`, not any other app command.
- Update CLAUDE.md (frontend/backend structure, Key Conventions note about the viewer window and
label-gated commands).
### 7. Error handling
All Rust errors are sentences suitable for display. Container-authored text is clipped via
`clip_container_text` (made `pub(crate)`). The main window shows `open_file_viewer` failures as
toasts; the viewer shows read/write failures as banners and never loses the editor buffer on
failure.
### 8. Testing
- Vitest: `filePathLinks` matcher (positive/negative table incl. markdown wrapping, URLs,
versions, `:l:c`, ranges, `#L`), `xtermLineJoin` on a fake buffer, OSC 8 scheme dispatch (in
`TerminalView.test.tsx`, which already tests `createOsc8LinkHandler`), the viewer
reload/dirty/conflict state machine as a pure reducer (`viewer/viewerState.ts`), editability
rules, language selection.
- Rust unit tests: candidate generation and normalization, label gating, registry
dedupe/cap/removal, write script argument shape, size cap, `viewer.html` presence + Vite
input assertion, and `every_command_is_registered_exactly_once` (existing) for the new
commands.
- `npx tsc --noEmit`, `npx vitest run`, `cargo test`, `cargo clippy` clean.
- Manual (`npx tauri dev`): click paths in Claude output, multiple windows, edit + save,
edit while Claude edits the same file (conflict banner), container stop with window open.
## Decisions made during review
Facts were checked against tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0 sources,
xterm 5.5.0, Vite 6.4.1 and the tree at `3537b23`. The user-approved decisions (one window per
click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, conflict banner,
20-window cap, AppManifest deferred) are unchanged. What changed:
1. **Write mechanism chosen (§5).** No exec helper feeds stdin, and whether a half-close of the
hijacked exec connection reaches the process is unverified. Instead: stage bytes at `/tmp`
with the existing `write_file_to_container` (container-user-owned), then one `sh` script as
`claude` does hash-check + `chmod --reference` + `mv -f` in the target directory. Every write
into the target directory is therefore subject to the container user's permissions; the
root-privileged archive API never touches it. `chown --reference` was dropped: a non-root
exec cannot chown, so the spec now says the saved file is owned by the container user.
2. **Polling is an exec (`sha256sum` + `stat`), not a re-download (§5).** A 2 s tick per window
that streams a 1 MiB archive was the alternative. The full-file hash from `sha256sum` equals
the Rust-side hash exactly when the read was untruncated, which is the only editable case;
the reducer keeps `diskHash` separate from `baseHash` so read-only (truncated) files still
detect change without a reload loop.
3. **Resolution stores the `realpath -e` result (§2).** `fetch_container_file` refuses a
symlink, so the raw candidate path could not be read; probing already needs an exec, so it
resolves in the same one. Candidate normalisation *rejects* `..` (matching
`validate_container_path`, which does not normalise) rather than resolving it.
4. **Exact capability set (§6).** `onCloseRequested` in @tauri-apps/api 2.11 calls
`this.destroy()`, and Rust `prevent_close()`s whenever a JS listener exists, so
`core:window:allow-destroy` is mandatory; `allow-close` is not needed and not granted.
Focus/title/unminimize are done from Rust, so no `core:window:allow-set-*` grants.
5. **OSC 8 with `allowNonHttpProtocols: true` receives unparseable and `javascript:` targets
(§1).** The handler now parses and dispatches itself, and refuses everything but `file:` and
`http(s):` before drawing a file or web hover card; a refused target gets only the existing
refusal card, which never echoes the target. The stale comment in the `WebLinksAddon` branch is
updated as part of the change.
6. **Wrapped-row joining is re-implemented (§1).** `WebLinksAddon`'s `LinkComputer` is not
exported from the built package and `urlDetector.ts` never touches the buffer.
7. **CodeMirror package list pinned (§4).** Direct packages only; `@codemirror/language-data`
and the `codemirror` meta-package are excluded (extra grammars, one with `Function(...)`).
A home-grown theme from CSS tokens rather than `theme-one-dark`.
8. **`viewer.html` fallback trap (Current state, §8).** Both Vite dev and Tauri's asset lookup
fall back to `index.html` when the entry is missing; a Rust test asserts the file and the
Vite input entry exist.
9. **Registry is its own managed state (§3)** rather than a field on `AppState`, with a
reserve-before-build cap check; label counter is a process-wide `AtomicU64`.
10. **Event delivery (§3).** `emit_to(label, …)` still reaches a bare `listen()` in the main
window, so the viewer subscribes via `getCurrentWindow().listen` and the main window has no
listener for viewer event names.
## Manual verification checklist
Not runnable inside the planning/implementation container: it needs `npx tauri dev` on a machine
with a display, plus a running project container. Run this after every task on this feature has
landed, including any review fix rounds (in particular Task 11's CRLF/BOM save fix, item 16
below).
- [ ] 1. In a Claude session, ask for a file listing; click `src/…` paths, `/workspace/...`
absolute paths, a `path:line` and a `path:start-end`. Each opens its own window titled
`<basename> — <project>`, scrolled to and highlighting the line/range.
- [ ] 2. Click the same path again → the existing window is focused and re-highlights; no
duplicate.
- [ ] 3. Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
- [ ] 4. Edit, `Ctrl+S`: file changes in the container (`cat` it in a bash tab); mode preserved
(`stat -c %a`); owner is the container user.
- [ ] 5. While a window is open, have Claude edit the file: clean window reloads with "Reloaded";
a dirty window shows "Changed on disk" with both buttons; "Overwrite on save" then Save
succeeds; "Reload (discard mine)" drops the edits.
- [ ] 6. Save while the file changed between polls → "Changed on disk" banner, no data written.
- [ ] 7. Delete the file in a bash tab → "File no longer exists", text still copyable, Save
disabled.
- [ ] 8. Stop the project with a window open → "Container not running" banner; start it → banner
clears, polling resumes.
- [ ] 9. Click a path under `/etc` or a `file:///etc/hosts` OSC 8 link → read-only badge with the
write-roots reason.
- [ ] 10. Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open;
Discard closes.
- [ ] 11. Close the *main* window with viewers open → app exits, viewers close.
- [ ] 12. Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console
shows no violations (CodeMirror styles apply).
- [ ] 13. A wrapped long path (narrow the terminal) underlines across the wrap and opens.
- [ ] 14. Image (`.png`) opens read-only; a `.bin`/binary shows "not text".
- [ ] 15. **File-path hover key-hint wording.** Hover a plain-text file-path link in the terminal
(not an OSC 8 hyperlink) while a foreground program is holding the mouse (e.g. an
interactive TUI like `vim`/`htop`/`claude`'s own REPL) and confirm the hover card's key hint
reads correctly for the platform/mode: "Shift+click to open" on non-Mac while the program
tracks the mouse, "Click to open" when nothing tracks the mouse, "Option+click to open" on
Mac with `macOptionClickForcesSelection`, or "Not clickable while a program holds the mouse"
on Mac without it (`openHintLabel` / `fillFileCard` in `TerminalView.tsx`). Then confirm the
*click itself* is held to whatever the card promised — the modifier actually required to
activate the link matches the hint shown, even if the program changes its mouse-tracking
mode between the hover and the click.
- [ ] 16. **CRLF/BOM file round-trip save.** Open a file in the container that has Windows line
endings (CRLF) and/or a UTF-8 BOM (e.g.
`printf '\xEF\xBB\xBF\r\nfoo\r\nbar\r\n' > /workspace/<project>/crlf.txt` in a bash tab),
open it in the viewer, make a small text edit, and Save. Then `cat -A` (or `xxd`) the file
in the container and confirm the CRLF line endings and the BOM are still present/unchanged
apart from the edit — i.e. the save did not silently normalize them to LF or strip the BOM.