Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 71ba5076db docs(acl): reconcile spec prose with the shipped implementation
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m49s
Build App (Preview) / build-linux (pull_request) Successful in 5m16s
Build App (Preview) / build-windows (pull_request) Successful in 10m4s
Build App (Preview) / prune-previews (pull_request) Successful in 9s
Final-wave cleanups from the whole-branch review (final-review.md Minor
1-5): spec §4 now says selective pruning, not "deletes the directory
every build"; spec §3.3 now describes the TypeScript-AST scan
(fail-closed Vite-order resolution, namespace imports as member access
only, the every-code-file boundary check) instead of the old
regex/chunk description; the viewer spec's historical "every command
is callable from every window" line gets a dated "closed by the
AppManifest lockdown" note; the lib.rs doc comment on
the_generated_app_manifest_matches_the_handler_list no longer claims
independence from the shared parser it actually reuses; and the vitest
command-name regex now allows digits, matching Rust's [a-z0-9_]+.

Also adds a cargo test backstop
(the_tauri_config_capability_check_runs_against_the_real_tree) that
runs build.rs's tauri-config capability check against the real
app/src-tauri tree on every `cargo test`, closing the gap where a new
tauri.<platform>.conf.json on an already-built tree only gets checked
by build.rs on a clean build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:25:50 -07:00
shadowdaoandClaude Opus 5.5 a479bce639 test(acl): resolve like Vite and cover every code file in capabilities.test.ts
Relative specifiers now follow Vite 6's tryCleanFsResolve order (exact
file, js->ts twin, .mjs/.js/.mts/.ts/.jsx/.tsx/.json, then index), so a
dotted name like ./evil.impl and a .mjs shadowing a .ts resolve to the
file Vite loads. The @tauri-apps/api(/core) boundary covers every code
file under src/, tests included; the main-window count includes
.js/.mjs/.mts/.jsx sources. tauri-commands.ts must call invoke inside a
wrapper's function body and may not load modules dynamically.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 f407d23bc0 test(acl): scan capabilities.test.ts imports with the TypeScript AST, fail-closed
Replace the regex/hand-rolled lexer with ts.createSourceFile + AST walking:
module edges from import/export-from/literal import(); aliases, query
suffixes, computed import(), require, import.meta.glob and any non
member-access use of a tauri-commands namespace alias throw; wrapper to
command extraction is read from the AST too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 ccff01a13a fix(acl): keep template-substitution source visible to the namespace-alias scan
Fix round 3: re-review found stripCommentsAndStrings collapsed whole
backtick template literals, including ${...} substitutions, before the
namespace-alias occurrence scan ever saw them. A tagged template hands
each substitution's value to the tag function by reference,
unstringified, so tag`${X}` smuggled the tauri-commands.ts namespace
object past the check exactly like fn(X) does, and neither threw.

Replaced the regex-based comment/string stripper with a small
hand-rolled scanner (skipQuoted/scanSubstitution/maskTemplate) that
drops literal template text but keeps a substitution's source intact,
recursively re-stripped for its own comments/strings/nested templates,
so an alias referenced only inside ${...} stays visible to (and, when
used via member access, correctly counted by) the occurrence scan.
Unterminated strings/comments/templates/substitutions now throw
(fail-closed) rather than running off the end of the text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 6d4f32e81c fix(acl): fail closed on namespace-alias value-smuggling and export-* re-export
Fix round 2: re-review found the resolve-based namespace-import fix
from round 1 unsound for non-dot-access uses. fn(X), const y = X, and
X["name"]/X[expr] all hand the whole tauri-commands.ts namespace
object somewhere the member-access regex can't follow, and none of
them failed closed. In any viewer-closure file that namespace-imports
tauri-commands, every occurrence of the alias after its import line
(comments and strings stripped, best-effort) must now be a plain
alias.identifier member access or the test throws, naming the file
and telling the author to use named imports instead. Also fails
closed on `export * as ns from ".../tauri-commands"`, which the
member-access scan can't audit either. Both checks are scoped to the
viewer side of the ACL boundary (wrapperNamesImportedBy's new strict
parameter) since that's where a missed case is a real escape; the
main-window count stays permissive as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 5688534a4a fix(acl): close the closure-walk blind spots capabilities.test.ts missed
Fixes four plan-mandated Important findings from review round 1:
namespace imports of tauri-commands.ts are resolved into the wrapper
set (not dropped), export{...}from/export*from re-exports are
followed by the closure walk and counted as wrapper usage, relative
dynamic import() is followed by the closure walk and also checked at
the @tauri-apps/api/core boundary, and an unresolvable non-relative
specifier now throws (fail-closed) instead of silently exiting the
closure. Also narrows the asset-extension filter to an allowlist of
.ts/.tsx/.js/.jsx as source, everything else treated as a non-source
asset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 8bac2a9533 test(acl): each window's code imports only the wrappers it is granted
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 23:12:19 -07:00
shadowdaoandClaude Opus 5.5 e10b16a081 docs(acl): describe the 008c8c0 layout checks and the platform-conf known limit
Task 2's fix round (008c8c0 on feat/app-manifest-lockdown) taught build.rs
to refuse any capability source command_census.rs can't see — non-top-level
capabilities/ entries, .toml/.json5, webviews/remote keys, inline
app.security.capabilities in any tauri config or TAURI_CONFIG — to name
which check failed, and to skip OS/editor junk. Documents those checks in
both capability descriptions and CLAUDE.md's Key Conventions, plus the
known gap that a new tauri.<platform>.conf.json only takes effect on a
clean or CI build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:38:32 -07:00
shadowdaoandClaude Opus 5.5 bc4980e014 docs(acl): threat model and conventions say app commands are ACL-gated
Task 2 already closed the risk (build.rs declares a Tauri AppManifest and
gates every app command per window); this task brings the docs in line so
the threat model of record no longer claims app commands are ungated or
that any local window can call any app command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:38:32 -07:00
shadowdaoandClaude Opus 5.5 1401369ffa test(acl): the embedded app manifest equals generate_handler!
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:36:11 -07:00
shadowdaoandClaude Opus 5.5 008c8c0328 fix(acl): refuse capabilities the census cannot see, and name the failed check
tauri-build loads capabilities/**/*.{json,toml,json5} plus inline
app.security.capabilities from any tauri config or TAURI_CONFIG, but the
census read only top-level capabilities/*.json, so a .toml, a
subdirectory or an inline capability could over-grant a window with a
green build. build.rs now fails on any of those, on a JSON5/TOML tauri
config it cannot read, and on a `webviews` or `remote` key in a
capability file. OS/editor junk (.DS_Store, swap files) that tauri never
loads is skipped in capabilities/ and permissions/. Each failure is
headed by the check that failed rather than always "capabilities do not
match generate_handler!".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:32:46 -07:00
shadowdaoandClaude Opus 5.5 05d991181d feat(acl): gate every app command per window via a Tauri AppManifest
build.rs now derives an AppManifest from generate_handler!, which makes
tauri 2.11 apply the ACL to app commands (it skips them entirely without
one). default.json grants the 110 main-window commands, file-viewer.json
the five viewer_* commands, and build.rs refuses to build on a missing,
misspelled, duplicated, misfiled or deny-* grant, or on a hand-written
permission file. Stale autogenerated permissions are pruned per build.

Closes the residual risk recorded by the terminal file viewer: a
compromised viewer window could invoke any app command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:26:18 -07:00
shadowdaoandClaude Opus 5.5 92a2d9de4a fix(acl): comma-split the handler parser so two commands on one line both survive
Review round 1 (Minor promoted to required fix): the parser applied
rsplit("::").next() once per line, so `a::x, b::y,` on a single line
collapsed to one item and silently dropped a::x — a denied command at
runtime with nothing flagging it. Strip `//` comments per line first (a
whole-line comment strips to nothing, a trailing one leaves the code before
it), then split the cleaned text on `,` so every grant is its own item
regardless of how many share a line.

Adds two_commands_on_one_line_are_both_kept (the regression case) and
a_fixture_shaped_like_the_real_handler_list_parses_every_command (section
comments plus 1-, 2- and 3-segment paths, mirroring lib.rs's real shape).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:20:08 -07:00
shadowdaoandClaude Opus 5.5 ed6596436e refactor(acl): shared command census module for build.rs and tests
Moves the generate_handler! parser out of the lib.rs test into
src/command_census.rs and adds the capability rules (one allow-* grant per
command, in the file its name says, no deny-*) with unit tests. No
behaviour change yet: build.rs does not use it until the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:15:25 -07:00
shadowdaoandClaude Opus 5.5 7c8ad62da4 docs(acl): implementation plan for the AppManifest lockdown
Six tasks: shared command_census module with unit tests; the atomic
build.rs + grants + census-test commit with negative proofs; the
embedded-manifest read-back test; the vitest import-closure check; the
threat-model and CLAUDE.md rewrite; full verification and hand-off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:12:25 -07:00
shadowdaoandClaude Opus 5.5 32acdf39fe docs(acl): revise lockdown spec for the implemented viewer and review rulings
Five viewer commands (viewer_poll_file was added), both existing census
tests, the wrapper-import shape of the frontend, controller rulings on
CI scope / Box::leak / webviews, and a spike (§7) that reproduced every
mechanism claim against tauri-build 2.6.0 in a throwaway worktree. One
design change from the spike: stale autogenerated permissions are pruned
selectively rather than by remove_dir_all, because tauri-build emits
rerun-if-changed=permissions and wholesale regeneration would rebuild
the crate on every cargo invocation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:12:25 -07:00
shadowdaoandClaude Opus 5.5 bf2291089a fix(viewer): final-review fixes — save base from written bytes, honest poll errors, retryable first read
- write.rs: a save's new base is sha256 of the bytes written; the script's
  post-mv hash comes back as disk_hash, and a mismatch (another writer landed
  after us) shows "Changed on disk" instead of being adopted (ledger M2).
- write.rs: conflict:/gone:/read-only strings are constants with a pure
  saved_file() mapping and tests; app/src/viewer/ipcMessages.ts is the one TS
  copy and a cargo test checks it against the Rust originals.
- write.rs: the comment now says the in-place `cat >` fallback follows a
  planted symlink, and why that is accepted (runs as claude).
- poll.rs: a file deleted between `test -f` and `sha256sum` reads as gone.
- viewerState/EditorPane: poll_failed carries its message; only the
  "Start the project before" refusal reads as Container not running, anything
  else gets its own banner and leaves Save enabled.
- EditorPane: a failed first read shows Retry and is retried by the poll.
- spec §1: refused OSC 8 targets keep the refusal card (Task 9 ruling).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:52:43 -07:00
shadowdaoandClaude Opus 5.5 398281c8b5 docs(viewer): fix round 1 — fallback trap, tracked checklist, residual-risk framing
- CLAUDE.md: document the viewer.html fallback trap (missing/broken Vite entry
  silently serves index.html into the viewer window) and point at the Rust test
  that pins it.
- Give the manual verification checklist a durable, tracked home: append it as
  a markdown task list to the terminal-file-viewer design spec, including the
  file-path hover key-hint wording check and the CRLF/BOM round-trip save check.
- default.json: state the app-command residual risk and the pending AppManifest
  lockdown directly in the capability file's own description, not only in
  CLAUDE.md, since this file is the reviewed threat model of record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:36:15 -07:00
shadowdaoandClaude Opus 5.5 90991fee32 docs: terminal file viewer structure and capability census
Add CLAUDE.md structure notes for the viewer/ frontend and file_viewer/
backend, plus a Key Conventions reminder that a new local window needs its
own capability file and lib.rs's on_window_event guard. Extend default.json's
threat-model census to name file-viewer.json and its allow-destroy grant.

Tighten the capability test in file_viewer/mod.rs from a substring check to
a parsed serde_json assertion of the exact windows list and permission set
for both capability files, per Task 12's controller ruling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:30:22 -07:00
shadowdaoandClaude Opus 5.5 16bfb3984c fix(viewer): byte-faithful saves, poll/save races, clearer errors
- Keep CRLF (or CR) line endings and a UTF-8 BOM through the editor:
  textFormat.ts records the dominant separator and the BOM on load and
  restores both on save, so a save changes only the user's edits.
- A clean document whose reload failed retries on the next poll.
- A poll that overlaps a save, or was issued before one settled, is
  ignored instead of reading the pre-save hash as a change.
- A conflict whose follow-up poll has no hash shows an error with a
  Reload button rather than an Overwrite that could only conflict again.
- Match write.rs's exact read-only message; show the read-only reason as
  visible text; error banners are role="alert".
- vite/client types move to src/vite-env.d.ts.
- Tests: CRLF and BOM saves, reload retry, poll/save race, null-hash
  conflict, Save and close success and failure, and CodeEditor.setDoc
  keeping cursor and scroll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:29:44 -07:00
shadowdaoandClaude Opus 5.5 804213a517 fix(viewer): atomic find-or-reserve so an in-flight window is never read as stale
A second click while the first window was still being built removed its
registry entry, giving a broken window, a duplicate and a cap bypass. The
registry now records when a window is built; `reserve` dedupes, prunes only
built entries whose window is gone (any state, so a leak cannot hold a cap
slot), and enforces the cap in one critical section. Choosing a file already
open elsewhere focuses that window and closes the chooser instead of
resolving a second entry. The not-running sentence names the real action.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:23:25 -07:00
shadowdaoandClaude Opus 5.5 20e60b78a1 feat(viewer): viewer window UI with live reload, save and close guard
EditorPane loads the resolved file, polls it every 2 s while visible,
reloads a clean buffer silently and shows the "Changed on disk" banner
for a dirty one, saves against the loaded hash, and intercepts closing
with unsaved edits. ViewerApp routes to the editor, the not-found list
or the choose list.

Preflight rulings carried: one reload helper that passes the truncated
flag and polled hash (P3/P14), a poll right after a save conflict so
Overwrite on save adopts the current hash (P4), a chunked base64
encoder (P5), StatusIndicator for the badge (P11), banner-only test
queries (P2), and a Range geometry stub for jsdom (P17). A save the
container user may not write is reported as read-only and keeps the
buffer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:22:52 -07:00
shadowdaoandClaude Opus 5.5 caaf70a66c fix(viewer): real errors, no leftover temp files, refuse read-only saves
Fix round 1 for Task 3, addressing task-3-review.md's I1-I3 (reproduced
under dash) plus M3 and M10 from the same review.

- I1: WRITE_SCRIPT read the target's hash through `sha256sum | cut … ||
  exit 1`. POSIX sh has no pipefail, so that `|| exit 1` tested only
  cut's exit status — an unreadable target (EACCES, EIO) left $actual
  empty, which never equals $expect, so the script silently reported
  exit 3 (conflict) instead of a real error. The user got a misleading
  "changed on disk" banner whose "Overwrite on save" could never
  succeed, since the next poll hit the same read error. Fixed by
  reading the hash from a plain command substitution
  (`actual=$(sha256sum -- "$target") || exit 1`) and splitting out the
  hash field in shell instead of piping into `cut`.

- I2 (+ M3): a failed `cp` into the staged file (ENOSPC, quota, EFBIG,
  EIO) left a partial `.<name>.triple-c-<pid>` behind in the user's
  own directory — the EXIT trap only ever removed $tmp. Fixed by
  creating the staged file with `mktemp` (M3: exclusive, unpredictable
  name, so it can't be planted or follow an existing symlink) and
  adding it to the trap as soon as it's assigned
  (`trap 'rm -f -- "$tmp" ${staged:+"$staged"}' EXIT`), so any later
  failure — cp, chmod, mv, or a signal — cleans it up too.

- I3 (controller ruling): the script only ever checked `[ -w "$dir" ]`,
  so a 0444 file (or one owned by another uid) was silently replaced
  via rename, defeating the file's own write protection even though
  spec §5 step 3 reads that way literally. Added `[ -w "$target" ]`
  before the branch; a non-writable target is refused with "The file
  is read-only for the container user." on stderr and a distinct exit
  code (5, `EXIT_READ_ONLY`) that `classify_write` maps to that same
  message rather than falling into the generic clipped-stderr arm.

- M10: added six `#[cfg(unix)]` tests that run WRITE_SCRIPT for real
  via `sh -c` against a temp directory on the host (not just needle
  matches against the script text) — clean save, stale-base conflict,
  gone target, unreadable target (I1), read-only target (I3), and a
  failed stage leaving no partial file behind (I2). The unreadable/
  read-only tests self-skip with a message if permission bits turn out
  not to block root, rather than false-failing under a root test
  runner.

Verified: `cargo test --offline file_viewer` — 19/19 passing, pristine
(up from 12; 6 new host-execution tests plus 1 for the new exit-5
classify_write arm). `cargo clippy --offline` (and `--tests`) — no
warnings in file_viewer::write; the 28 warnings clippy reports are
all pre-existing, in unrelated files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:20:11 -07:00
shadowdaoandClaude Opus 5.5 d4783b3306 feat(viewer): label-gated viewer commands and window creation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:17:51 -07:00
shadowdaoandClaude Opus 5.5 a25d9c2be3 fix(viewer): hold file-path clicks to the card's modifier promise
showFileCard now records modifierPromised like the OSC 8 hover, and the
file-path provider's gate goes through the handler's new opensFileLink,
so a "Shift+click to open" card cannot be answered by a bare click after
the container drops mouse tracking. A click before the session's project
is known now toasts instead of doing nothing. Refusal-card tests assert
the card is present; a misplaced test comment is back on its test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:17:34 -07:00
shadowdaoandClaude Opus 5.5 f970c4e895 feat(viewer): clickable file paths and file: hyperlinks in the terminal
Registers a file-path ILinkProvider (after WebLinksAddon) that opens the
file viewer at the matched line, and turns on allowNonHttpProtocols so
OSC 8 file: targets reach createOsc8LinkHandler, which now parses every
target and refuses anything but file: and http(s):. The hover card gains
an "Open in viewer" variant, exposed as showFileCard(rawPath) so relative
paths are shown as printed (preflight P6).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:13:27 -07:00
shadowdaoandClaude Opus 5.5 5b66e861df feat(viewer): CodeMirror editor, theme and target-line highlight
Implements Task 10 of the terminal file viewer plan: highlightLine.ts
(line-range StateField + setHighlight effect), viewerTheme.ts (EditorView
theme + syntax HighlightStyle), and CodeEditor.tsx (the React wrapper with
getDoc/setDoc/goTo/focus).

Also implements languages.ts + languages.test.ts, moved here from Task 7
per preflight ruling P1 (they need @codemirror/* packages, which only
Task 6 installs).

Preflight rulings applied:
- P1: languages.ts/.test.ts live here, not in Task 7.
- P12: syntax colours are `--syntax-*` custom properties in index.css,
  not hard-coded hex.
- P13: languageFor tests assert `.not.toBeNull()` for mapped extensions
  and move README to the "returns null" case, instead of the vacuous
  `.resolves.toBeDefined()`.
- P14(c): readOnly extension array factored into readOnlyExt(readOnly)
  instead of being duplicated in CodeEditor.
- P16: no custom Mod-g binding; searchKeymap's Mod-Alt-g covers "go to
  line" already.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:12:05 -07:00
shadowdaoandClaude Opus 5.5 bbcaee7797 feat(viewer): poll and save scripts run as the container user
poll.rs: one exec per tick that tests existence then hashes+stats the
file (sha256sum/stat), so the 2 s poll costs one exec instead of
re-downloading up to 1 MiB of archive per window per tick.

write.rs: sha256_hex/is_sha256_hex, MAX_WRITE_BYTES, and the save
script. Saving stages the payload in /tmp via the existing
write_file_to_container (owned by the container user, since the
Docker archive API writes as root), then an exec as `claude` checks
the base hash, swaps the file in with a same-directory rename when
the directory is writable (falling back to an in-place `cat >` when
it is not), and always cleans up the staged temp file via `trap`.
classify_write maps exit 0/3/4 to Saved/Conflict/Gone.

Applies three pre-flight rulings against the brief's literal text:
- P8: pulled the write script's argv shape and the size/hash checks
  into pure `write_command`/`check_write_input` helpers with their
  own unit tests, since both lived only inside the untested async
  `write_file` otherwise.
- P9: the brief's manual Docker smoke-test invocation
  (`sh w.sh save target tmp hash`) makes `$1` become "save" instead
  of the target, which is not what the script or the Rust caller
  expect. Verified in a throwaway container that invoking the file
  directly without the dummy "save" arg reproduces the Rust
  convention's `$1/$2/$3` correctly: exit 0 with the new hash and a
  removed payload on a clean save, exit 3 with the file untouched on
  a stale base hash, and exit 4 when the target is gone.
- P15: GNU sha256sum prefixes its output line with `\` when the
  path contains a backslash or newline. Without a fix that turns
  into a permanent false conflict (write.rs) and a blinded poll
  (poll.rs, hash: null forever). Both parsers now strip a leading
  `\`, and the script itself strips it from $actual before comparing
  to $expect. Verified against real sha256sum output in a container
  with a backslash-named file: the save no longer false-conflicts
  and the reported hash matches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:11:19 -07:00
shadowdaoandClaude Opus 5.5 a80c146b8a feat(viewer): second Vite entry, viewer capability, CodeMirror deps
Task 6 of the terminal file viewer plan: app/viewer.html plus
src/viewer/{main,ViewerApp}.tsx (placeholder ViewerApp for Task 11 to
replace), registers viewer.html as a second Rollup input in
vite.config.ts, adds the file-viewer capability restricted to
file-viewer-* windows (allow-listen/unlisten for the goto event,
allow-destroy for the close-button/prevent_close interaction,
allow-internal-toggle-devtools to match default.json's dev
convenience), installs the CodeMirror packages Task 10 builds
languages.ts on top of (P1), and pins the viewer entry/capability
with a Rust fallback-trap test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:08:01 -07:00
shadowdaoandClaude Opus 5.5 247ec764ea feat(viewer): reload/conflict reducer and editability rules
- viewerState.ts: pure reducer for the clean/dirty, same/changed/gone,
  container-down and overwrite-on-save states (spec §5), plus the
  pollEffect/canSave helpers EditorPane will drive off.
- editability.ts: classifies a fetched file as text/image/binary and
  decides whether it is editable, deferring to Rust's readonly_reason
  when it refuses.

Per preflight P1, languages.ts/.test.ts move to Task 10 (needs the
CodeMirror packages Task 6 installs; out of scope for this task's
worktree). Per P3, the "reloaded" action now carries `truncated` and
`polledHash` so a poll-driven reload of a truncated (prefix-hash-only)
file adopts the polled full-file hash instead of re-triggering a
reload on every subsequent poll -- with a reducer test covering it.
Per P13, tightened the poll_failed/canSave test to start from a dirty
doc so it actually exercises containerDown rather than passing only
because the doc was clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:07:28 -07:00
shadowdaoandClaude Opus 5.5 3177af202d feat(viewer): pure file-path matcher for terminal text
Adds findFilePathLinks, a pure matcher that finds file paths (with
optional :line, :line:col, :start-end and #L42/#L40-L50 suffixes) in
a line of terminal text, skipping anything inside a URL and stripping
markdown/quote wrapping from the matched span.

Per pre-flight ruling P7, a slash-having token still requires the
basename to carry a real extension (any extension) or be a known
extensionless basename -- a bare and/or or src/components is no
longer treated as a path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:06:52 -07:00
shadowdaoandClaude Opus 5.5 554870a50f feat(viewer): candidate paths and container probe for the file viewer
Implements resolve.rs: candidate_paths() turns a raw path Claude printed
into an ordered, capped, deduped list of container paths (absolute paths
pass through as-is; relative paths probe /workspace then each project
mount, in order), PROBE_SCRIPT + parse_probe_output() run one exec that
prints realpath -e of every candidate that is a regular file, and
probe_candidates() wires the two together over exec_oneshot_streams_as.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:06:30 -07:00
shadowdaoandClaude Opus 5.5 d32dc9b446 feat(viewer): window registry with cap, dedupe and sequential labels
Task 2 of the terminal file viewer plan: ViewerRegistry tracks which
file-viewer-<n> window is looking at which container path. reserve()
takes the cap check and label allocation atomically under one lock so
two concurrent open requests cannot both slip past the 20-window cap;
find_open() only matches windows in the Resolved state, so a window
still choosing a candidate or reporting not-found is never treated as
"open on" a path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:05:33 -07:00
shadowdaoandClaude Opus 5.5 4ada54ffc4 feat(viewer): join wrapped xterm rows for link matching
Adds joinWrappedRows/offsetToCell for reassembling a wrapped xterm
line into its full text plus offset->cell mapping, so link matching
can find paths that xterm has soft-wrapped across rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:05:06 -07:00
shadowdaoandClaude Opus 5.5 3c1d120305 feat(viewer): IPC types, wrappers and Rust module skeleton for the file viewer
Task 0 of the terminal file viewer plan: the shared interfaces that seven
later tasks build against in parallel. Adds ViewerLocation/ViewerTargetState/
ViewerState/ViewerFile/ViewerPoll to types.ts and their invoke() wrappers to
tauri-commands.ts, creates the file_viewer Rust module (mod.rs with
MAX_VIEWER_WINDOWS/VIEWER_LABEL_PREFIX/is_viewer_label, plus placeholder
registry/resolve/poll/write/window submodules), wires it into lib.rs, and
loosens visibility on the file_commands.rs helpers the viewer commands will
reuse (MAX_READ_BYTES, validate_container_path, validate_container_write_path,
FetchedFile, fetch_container_file, require_running, clip_container_text).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:56:33 -07:00
shadowdaoandClaude Fable 5.1 20befa1320 docs: terminal file viewer spec review and implementation plan
Review the approved design against tauri 2.11.0, xterm 5.5.0, Vite 6.4.1
and the tree: record the verified multi-window, capability, CSP and exec
facts, choose the save mechanism (stage in /tmp, swap in as the container
user), make polling a sha256sum exec, store realpath-resolved targets, and
pin the exact viewer capability set. Add the task-by-task plan with its
parallel groups and the verification commands that work here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:53:49 -07:00
shadowdaoandClaude Opus 5.5 508ff6d037 docs: design spec for per-window app-command lockdown via AppManifest
Follow-up to the terminal file viewer spec. Verified against tauri 2.11.0,
tauri-build 2.6.0 and tauri-utils 2.9.0 sources: with no app manifest the
ACL skips app commands entirely; with one, every command must be granted
to the calling window, unlisted commands are denied, and deny-* is global.
The spec derives the manifest from generate_handler! in build.rs and fails
the build when a command is missing from, or duplicated across, the
capability files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:39:38 -07: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
jknapp d647b56b43 Do not read an unreachable Docker daemon as an absent container (#58)
Secret Scan / scan (push) Successful in 5s
Build App / compute-version (push) Successful in 17s
Build App / build-macos (push) Successful in 2m49s
Build App / build-windows (push) Successful in 5m3s
Build App / build-linux (push) Successful in 8m4s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 9s
Closes #56.

Reviewed twice; the second round's findings on the first fix are addressed in f662ed0 and a3840f7.
2026-09-19 02:59:20 +00:00
shadowdaoandClaude Opus 5 a3840f7263 fix: say which check failed, and stop claiming an order we do not use
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / build-linux (pull_request) Successful in 4m58s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Two accuracy defects from re-review, both the same class as the bug this
branch exists to fix.

`probe_failed` rendered every failure as "This project's container could
not be inspected", but only two of the four readings are about the
container -- the others are the base image and the snapshot. A malformed
base image name in settings therefore pointed the user at the wrong object.
The sentence now names the check rather than the container.

The doc claimed "the first error wins, in call order". It does not: the
checks run container_id, base_image_id, container_running, while the daemon
is called in a different order entirely. The priority is deliberate -- it
puts the reading that stopped the probe first -- so the comment now says
that, instead of describing an order the code does not use.

The test guarding the first point asserted the message does not contain
"Docker", using a synthetic payload. The real bollard error for that case
is "Docker responded with status code 400: invalid reference format", so
the assertion passed only because the payload was invented. It now uses the
real shape and asserts what actually matters: that nothing we add claims
the daemon was unreachable or names the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:53:52 -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 f662ed04ce fix: a reading nobody consults must not destroy the report
Secret Scan / scan (push) Successful in 11s
Build App (Preview) / compute-version (pull_request) Successful in 11s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 8s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m35s
Build App (Preview) / build-linux (pull_request) Successful in 7m17s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Review of this branch found the first cut made every probe error fatal,
including one that is usually irrelevant. `snapshot_exists` is consulted
only when there is no container, or when a stopped container coincides with
a busy project -- `pick_probe_source` discards it outright for a running
one. So a daemon hiccup between the four sequential readings turned a full
report into a bare "could not be checked" with Update disabled, in a change
whose whole purpose is handling exactly that hiccup better.

It is now carried as a `Result` to the points that consult it and surfaced
only there. `stopped_probe_policy` carries its own message, because
"try again once it finishes" claims waiting is the only obstacle, which a
failed `image_exists` has not established.

`base_image_id` stays fatal, deliberately: it is the right-hand side of the
comparison, and `image_id` already distinguishes "not pulled locally"
(`Ok(None)`, a legitimate not-stale) from "could not ask". Letting an `Err`
through as `None` would report a project up to date on a reading nobody
got -- #56 one field over.

The message no longer blames the daemon. Three of the four callees can
`Err` from a daemon that answered perfectly: `image_id` maps only 404 to
`Ok(None)`, and the base image name is user-supplied, so a malformed
reference told the user to go fix a daemon that was running fine. That is
the same category of error as #56 itself.

`ContainerState` makes "running is known but no container was found"
unrepresentable rather than merely unreached, so the downstream match has
no impossible arm and the invariant is enforced where it is established.

Finally, the tests covered the new function but not the line the bug was
on: a partial revert to `.unwrap_or(None)` kept them all green. The
readings now travel as a named struct of `Result`s, so that revert is a
compile error -- verified by performing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:38:31 -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
shadowdaoandClaude Opus 5 84a5757c74 fix: do not read an unreachable Docker daemon as an absent container (#56)
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
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 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 4m59s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Successful in 6s
`get_container_staleness` collected four probes through `unwrap_or`, so a
transient daemon fault landed on the same arm as a genuine absence and the
banner said, confidently and wrongly, that the project has no container or
snapshot image to compare against.

The four readings are now taken as `Result`s and funnelled through a pure
`collect_probe_inputs`, following `pick_probe_source` and
`stopped_probe_policy` in the same file, so the rule is unit-testable
without touching Docker. The first error in call order wins and becomes
`probe_error`; the command still returns `Ok`, because the hook's `catch`
sets `staleness` to null and the banner returns early on null -- an `Err`
here would hide the fault instead of reporting it.

One of the issue's premises did not hold. `is_container_running` does not
distinguish absent from unreachable: its body flattens every
`inspect_container` failure to `Ok(false)`, so only a `get_docker` failure
can surface as `Err`. Its `Result` is threaded through anyway, since that
one case is a real daemon-unreachable signal and this layer no longer adds
a second swallow on top, and the remaining gap is documented where the
decision is made rather than patched in `docker/container.rs`, which the
issue puts out of scope and whose doc comment says the swallow is
deliberate. In practice `find_existing_container` runs immediately before
and would already have errored if the daemon were down.

No frontend change: `probeUnavailable` in ContainerMigrationBanner already
routes a set `probe_error` to "Some checks did not complete".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 06:04:41 -07:00
jknapp 73a6e3d8b4 Merge pull request 'Make in-container OAuth logins actually complete' (#57) from fix/auth-callback-and-opener into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 2m44s
Build App / build-linux (push) Successful in 5m48s
Build App / build-windows (push) Successful in 5m55s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 1m12s
Reviewed-on: #57
2026-09-18 04:32:07 +00:00
shadowdaoandClaude Opus 5 943c83b9e3 fix: stop a stale payload re-enabling a bridge the user turned off
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 8m39s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Review of this branch found that `update_project` restored
`browser_view_enabled` from the store but took `auth_bridge_enabled` from
the IPC payload, on a comment claiming the Config tab edits it through that
save. The comment was wrong. `AuthBridgeRow` is the only writer, it calls
`set_auth_bridge_enabled` out of band precisely so the switch works while a
login is hanging, and it never writes the value back into frontend state --
so a payload's copy of that flag is always a stale snapshot.

The consequence was not cosmetic: turn the bridge off, then close a renamed
terminal tab, and `useTerminal` round-trips the stale `true` and the
reconcile block restarts a bridge whose own UI warns that a bridged port is
unauthenticated and reachable by any local process. Defaulting the flag to
true earlier in this branch made it worse, since the stale value is now
true for every pre-existing project.

Both flags are now restored from the store by `restore_store_owned_fields`,
and the reconcile block is gone rather than corrected: with the value
always restored it could only re-assert what was already true, and every
writer already owns its own side effect -- the setter starts and stops
synchronously, container start arms the bridge, launch reconcile re-arms
it, and the poller re-reads the flag each tick and self-terminates.
Re-adding a start path to the one function that no longer owns the flag is
what caused this.

Turning the browser view off also stopped tearing the session down when the
project record had vanished, because the persist used `?` and returned
early -- the supervisor's own `store.get()` check exists because records do
vanish mid-session. Teardown is now unconditional and the write error still
surfaces afterwards, since the stored flag saying "enabled" means the view
returns on next launch and that is worth reporting.

Finally, the opener no longer falls through to `gio` on any non-zero exit.
xdg-open's 1, 2 and 3 assert no handler ran; 4 also covers a handler that
was launched and then failed, which would have opened the link twice --
two authorize requests for one click in an OAuth flow. Reasoned from
documented exit codes rather than an observed double-open, and the cost is
stated: a genuine code-4 failure no longer reaches gio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:13:57 -07:00
shadowdaoandClaude Opus 5 60188610ee fix: do not let an in-flight open blank a newer prompt, or promise a bridge that is off
Two findings from review of this branch.

Awaiting the open instead of dismissing up front bought a window: on Linux
it is at least OPENER_GRACE, doubled when xdg-open fails and gio is tried.
If the container relays a second URL inside that window, the first open's
resolution blanked the second prompt -- losing a link that exists only in
the container's transcript, which is the failure "dismiss on success only"
was made to prevent. The slot already carried a `seq` for exactly this
reason; dismissal is now conditional on it.

`urlPromptRef` is written eagerly by the two functions that change the slot
rather than synced by an effect. That is load-bearing: an effect-synced
mirror lags state by a commit, and a promise microtask can resolve between
`setUrlPrompt` and React flushing passive effects -- so it answers "did a
newer prompt land?" wrong in precisely the window the guard exists for.
Dropping the functional updater also fixes `promptSeqRef.current += 1`
being mutated inside a state updater React is free to invoke twice.

The guard is a sibling function rather than an optional argument on
`dismissUrlPrompt`, because that function is passed by reference as
UrlToast's `onDismiss` and React would hand it a MouseEvent as its first
argument -- the seq check would fail and the close button would silently
stop working, with the types still assignable.

Separately, the sign-in hint was binary on which button leads, but "host
leads" covers both a live bridge and a fallback where nothing is set up to
catch the callback at all. In the second case the toast promised the bridge
would carry it and the login hung to its timeout. The target is now
three-state, the hint tells the truth in the fallback case and names the
control that fixes it, and the hook starts at `host-fallback` rather than
assuming a bridge it has not confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:12:22 -07:00
shadowdaoandClaude Opus 5 db648230ee chore: regenerate capabilities schema after dropping the opener grant
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
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 2m57s
Build App (Preview) / build-linux (pull_request) Successful in 5m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m54s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Tracked build output; regenerated by the Tauri build from
capabilities/default.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:11:03 -07:00
shadowdaoandClaude Opus 5 5a452e7a2a security: drop opener:allow-open-url now that nothing calls it
default.json carried this grant with an explicit accepted residual risk:
a compromised webview could make the OS open an attacker-chosen http(s)
URL. It was accepted because it could not be narrowed -- WebLinksAddon
opens links Claude printed inside the container, which are arbitrary by
construction, so a host allowlist would have deleted the feature.

Now that every host-browser open routes through `open_url_external`, the
webview has no reason to reach the plugin directly, and the risk closes
rather than stays recorded. The plugin remains a dependency: macOS and
Windows still use it, through `OpenerExt::open_url`, whose desktop
implementation calls `crate::open::open` directly and is not gated by
capabilities at all (tauri-plugin-opener-2.5.3/src/lib.rs:60) -- verified
rather than assumed, since the whole point is that the Rust path keeps
working. What is removed is the webview's ability to reach the opener
without passing the Rust-side validation.

The census note in default.json is rewritten to match, and lib.rs's
grant-list test is updated deliberately, as its own assertion message
demands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:10:45 -07:00
shadowdaoandClaude Opus 5 5a09254538 fix: route every host-browser open through open_url_external
The Rust command existed but nothing called it. All four frontend call
sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the
environment fix was inert and the three dialogs carried the same Linux bug
as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in
link and UpdateDialog's release link would all have reported success while
launching nothing.

`openUrlExternal` in tauri-commands.ts is now the single sink. There is no
platform branch: Linux gets the sanitized spawn, macOS and Windows reach
the same plugin as before but from Rust, and every platform picks up the
Rust-side re-validation, which matters because these URLs originate in an
untrusted container.

Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink
they guard are updated to match, and the two test files that mocked
`@tauri-apps/plugin-opener` now mock the command instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:09:57 -07:00
shadowdaoandClaude Opus 5 9297020688 fix: open host URLs with a clean environment (triple-c#34)
On Linux the app ships as a single AppImage, and the AppImage environment
leaks into everything it spawns. linuxdeploy's AppRun, linuxdeploy-plugin-gtk
and our own wayland fallback hook all export LD_LIBRARY_PATH, GTK_PATH,
GIO_MODULE_DIR and friends pointing inside $APPDIR, and main.rs sets
WEBKIT_DISABLE_DMABUF_RENDERER process-wide for the webview. A browser that
is already running shrugs this off, because xdg-open just hands the URL to
the existing process. A cold-launched one inherits the lot and dies before
painting -- with xdg-open still exiting 0, which is why this looked like the
button doing nothing at all.

`url_open` captures a pristine snapshot of the environment in main() before
any mutation runs, then hands children a repaired copy: a saved original is
restored where one exists, otherwise the process-start value is restored
where we changed it, otherwise only the colon-separated entries that live
under $APPDIR are dropped and the user's own are kept. Outside an AppImage
it is a no-op.

The command re-validates the URL in Rust rather than trusting the frontend,
because the URL originates in an untrusted container: http/https only, no
embedded credentials, no control characters or whitespace, length capped,
ASCII asserted before it reaches execvp, and error messages never echo the
input. Spawning is Command with explicit args and never a shell, trying
xdg-open then gio open.

No portal. org.freedesktop.portal.OpenURI would pull in a D-Bus client stack
for one call on the one platform where we ship self-contained, and it only
helps where a portal is running -- the same case where xdg-open already
works once the environment is clean. `gio open` as a second candidate
recovers most of the missing-MIME-association case for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:08:20 -07:00
shadowdaoandClaude Opus 5 bf8094dbc4 fix: route sign-in links by what can actually catch the callback
`isAnthropicSignInUrl` made the container the default action for every
Anthropic sign-in link, justified by "the host has nothing to catch it
with". That was wrong in both directions. The host does have something --
the auth bridge -- and the container side is not a general browser at all
but Playwright's dashboard, whose packages and chromium are deliberately
not baked into the image. So the default pointed at the one path that is
uninstalled on a fresh project, on every platform, while the path that
works sat behind a switch.

The decision now lives in `useSignInOpenTarget`: a live auth bridge picks
the host, otherwise a container that can actually launch a browser picks
the container, otherwise the host. It resolves at mount rather than when a
URL arrives, so the buttons do not swap under a moving mouse, and it
re-decides on `auth-bridge-changed` so flipping the switch during a
hanging login takes effect. A bridge with port conflicts reads as not
live; an empty `active_ports` does not, since there is nothing to bridge
until the CLI binds its listener and that races the URL.

Both buttons still render either way -- this changes which one leads.
`sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in
web_terminal/terminal.html needs no matching edit.

The host "Open" path also failed silently: `dismissUrlPrompt()` ran before
`openUrl`, so the toast vanished and a rejected promise reached only the
devtools console. Dismissal now happens on success only, leaving "In
container" one click away after a failure, and the error surfaces through
the same toast the container path already used. On Linux this catch will
not fire for the common case -- `xdg-open` routinely exits 0 having done
nothing -- so it complements the AppImage environment fix rather than
replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:07:50 -07:00
shadowdaoandClaude Opus 5 90b7e4ccb2 fix: default the auth bridge on, and make the browser-view flag durable
A CLI running `claude login` inside the container binds a random ephemeral
loopback port and hands the provider a redirect pointing at it. The browser
is on the host, so the callback lands on a closed host port and the login
hangs with no diagnostic. The auth bridge is the thing that fixes this --
it mirrors container loopback listeners onto the same host port -- so
having it default to off made a hang the out-of-the-box experience.

`auth_bridge_enabled` now defaults to true through a
`default_auth_bridge_enabled()` serde helper, matching the shape already
used by `use_shared_auth_token`. Because the default is applied at
deserialisation, projects stored before the bridge existed pick it up too;
`migrate_from_value` writes neither flag, so nothing defeats it, and a
regression test pins that.

Separately, `BrowserViewManager.enabled` was in-memory only and the durable
`browser_view_enabled` field on the project record was never implemented.
Rather than sync the two, the cache is removed and the record becomes the
single home for the flag, mirroring how `AuthBridgeManager` already works.
`stop()` deliberately does not clear it, since container teardown and
migration reach that path and neither is the user changing their mind.
Durable does not mean auto-started: a restarted app reports enabled with
the viewer off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:06:09 -07:00
shadowdaoandClaude Opus 5 afe9d5cdb2 docs: correct Linux packaging in BUILDING.md
BUILDING.md listed AppImage, .deb and .rpm as build artifacts, but Linux
ships as AppImage only -- CI passes `--bundles appimage`, and the .deb and
.rpm were dropped because neither could self-update. A bare `npx tauri
build` still emits all three, since tauri.conf.json keeps "targets": "all"
to leave macOS and Windows untouched, so the table now marks which are
actually released rather than pretending the others do not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:29 -07:00
jknapp b59c6148ff Merge pull request 'Read a stopped container instead of claiming there is nothing to read' (#55) from fix/staleness-probe-stopped-container into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m29s
Build App / build-windows (push) Successful in 5m1s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 1m36s
2026-09-11 03:54:40 +00:00
shadowdaoandClaude Opus 5 95a78fe9a3 Take the review: cache the stopped probe, and never let it cost an answer
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
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 2m58s
Build App (Preview) / build-linux (pull_request) Successful in 4m43s
Build App (Preview) / build-windows (pull_request) Successful in 5m9s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Six findings, all real. The one that mattered: `getContainerStaleness` is
called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview now committed its whole writable
layer — 44 s on a real project, against ~3 s for the snapshot probe it
replaced. Shipping that would have traded one bad banner for a bad page.

A stopped container's writable layer cannot change, so the probe is exactly
cacheable: `STOPPED_MANIFEST_CACHE` keys on the container's `FinishedAt`,
which moves on every stop. Cold 2967 ms, warm 1 ms, measured. A live test
asserts the restart case as well as the hit, because a cache that failed to
invalidate would plan a migration against a filesystem the project no longer
has — verified by breaking the token and watching that assertion fail.

Skipping the probe for projects that are not stale looked like the cheaper
fix and is unsafe: the deltas would be empty while `probeSettled` stayed
true, and the migrate action in the project menu is not gated on the banner,
so the pre-flight would report nothing to copy while the backend was told to
copy nothing. That is the hazard `canMigrate`'s comment already warns about.
Not done, and written down so it is not tried again.

Also from the review:

- A failed commit no longer costs an answer the snapshot could have given.
  Before this feature a stopped project read its snapshot directly, so
  surfacing this error would have made the banner worse than it was — and
  the failure modes are where the fallback earns its keep: a full disk (the
  commit allocates the whole layer, the snapshot probe allocates nothing)
  and a 409 from a concurrent claim.
- The probe no longer commits while the project is claimed. The collision is
  not symmetric: the probe losing is a retryable `probe_error`, but
  `start_project_container` removes the old container with a hard `?`, so a
  remove that raced a commit would fail the user's Start with an opaque
  error. `stopped_probe_policy` reads `project_lock::held` and probes the
  snapshot instead, or defers with a message that says so.
- The cleanup-failure warning claimed the next probe of the same container
  would reclaim the leftover. Unique names made that false the moment they
  landed; it is `reap_probe_images` that collects it.
- The TS binding still called the command read-only, which is how the
  auto-refresh got added in the first place.
- CLAUDE.md still documented the stable `triple-c-probe-{cid}:latest` name
  this PR removed as unsafe.

548 unit tests, 752 frontend tests, 4 live-Docker tests. Clippy unchanged at
44 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 20:48:10 -07:00
shadowdaoandClaude Opus 5 307ea07409 Read a stopped container instead of claiming there is nothing to read
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m55s
Build App (Preview) / build-linux (pull_request) Successful in 4m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m53s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
A project that was merely stopped reported "This project has no container
or snapshot image yet, so there is nothing to compare against the base
image" — with its container sitting right there — and Update stayed
disabled. Start it and the checks passed, which is the tell: the staleness
probe had only two sources, a *running* container via `docker exec` or the
project's snapshot image.

The snapshot is not a checkpoint. `commit_container_snapshot` runs only
before a container is destroyed (a config-change recreate) or inside a
migration, never on stop, so a project in daily use for a year can have no
snapshot at all — and five of the six projects on the box that reported
this had none. Absence of a snapshot was being read as absence of anything
to inspect.

So probe the stopped container directly: commit its writable layer to a
throwaway image, probe that, drop it. A stopped container now also outranks
the snapshot, for the same reason a running one already did — the snapshot
lags it by everything installed since the last commit. `pick_probe_source`
is the whole decision and is unit-tested; the message it used to emit now
describes only the case it is true of, no container and no snapshot.

Two things found on the way, both documented in CLAUDE.md:

`bollard` never hands back the image id from a commit — its `Commit` model
deserialises "ID" while the daemon sends "Id" — so the probe image has to be
tagged, and a tagged image is dangling-proof and therefore invisible to
`sweep_orphaned_snapshots`, `reap_stale_migration_pins` and
`scrub_secrets_from_snapshots` alike. Without a reaper of its own a crashed
probe would leak a multi-gigabyte image that nothing could ever reclaim, so
`reap_probe_images` runs at startup beside `reap_probe_containers`, age-gated
for the same reason that one is: `reference=` is daemon-wide and a second
instance's live probe matches the glob.

It removes by tag, never by image id: a force removal by id untags an image
everywhere, which is how a first draft of the reaper test deleted an
unrelated `alpine:latest`. Names are unique per call rather than stable per
container, because container ids do not survive a recreate and two
overlapping probes would otherwise fight over one tag.

Verified against the container that reported the bug: 13,365 paths and an
apt delta of cmake, ffmpeg, libobs-dev, qt6-base-dev and nine more — the
migration payload the Update flow could not see. 546 unit tests plus three
live-Docker tests pass; no new clippy warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 19:24:05 -07:00
jknapp 37bbf181c9 Merge pull request 'Give the mouse back, retire the follow controls, update Claude per session' (#54) from feat/mouse-release-retire-follow-update into main
Build App / compute-version (push) Successful in 12s
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 4m54s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 9s
Build Container / build-container (push) Successful in 17m57s
2026-09-08 23:43:34 +00:00
shadowdaoandClaude Opus 5 5d16b5713d Give BuildKit the host's network, so it can reach the runner's cache
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 4m45s
Build App (Preview) / build-linux (pull_request) Successful in 7m38s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 14m48s
The multi-arch build needs the `docker-container` driver — the plain `docker`
driver cannot do linux/amd64+linux/arm64 — and that driver runs BuildKit in
its own container on Docker's default bridge. act_runner advertises
ACTIONS_CACHE_URL as an address the *job* container can reach, and nothing
teaches the BuildKit container about it. So the job could reach
192.168.1.126:40649 while the container actually making the cache request
could not.

That is also why no other workflow here hit this: it is the only one using
buildx. The rest make their cache calls from the job container act_runner set
up.

`no route to host` is EHOSTUNREACH — a firewall rejecting, not a missing route
— which is what a default firewalld zone does to traffic from the docker
bridge, and the runner registers under the stock RHEL/Fedora hostname.
Sharing the host's namespace sidesteps it: the cache address becomes local to
BuildKit. No effect on runners where this already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 15:30:27 -07:00
shadowdaoandClaude Opus 5 c02c02cbfc Never fail a container build because the cache was unreachable
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 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 8m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 10m21s
Every layer of both architectures built. The job then died exporting to
act_runner's emulated GitHub Actions cache service, which it could not route
to: `GetCacheEntryDownloadURL ... dial tcp 192.168.1.126:40649: no route to
host`.

On a pull_request `push:` is false, so this job pushes nothing and the cache
is its only output — which means a network problem between the buildx
`docker-container` builder and the runner host threw away a complete,
successful validation of the Dockerfile on linux/amd64 and linux/arm64. A
cache is an optimisation; it must degrade to "slow", never to "red".

Only the exporter needs the flag. The import is already non-fatal — the build
ran all 37 layers after warning it could not read the cache.

This does not fix the routing itself, so builds stay uncached until that is
sorted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 12:48:41 -07:00
shadowdaoandClaude Opus 5 c0e4c87cec Give the mouse back, retire the follow controls, update Claude per session
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 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Failing after 14m49s
Three things the terminal was getting wrong.

**A program that grabs the mouse and dies used to freeze the tab.** A TUI sets
DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps
routing clicks, drags and — under ?1003 — every pointer *move* to the PTY.
Text selection dies and escape bytes flood the prompt. The only exit was
closing the tab. `TerminalView` now reconciles a flag against
`term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only
changes because the container printed a sequence, so one check per write
catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar
button writes the resets back through `term.write`, never `sendInput`: the
reset belongs to xterm's parser, and a still-live TUI told about it would just
re-grab on its next repaint.

The control is in the status bar deliberately. Mouse tracking is the *normal*
state of htop, vim, lazygit and Claude Code, so a badge over the terminal
would be on screen for the whole life of those programs and would swallow
clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is
also on now: xterm's force-select is Shift everywhere except macOS, where it
is Option and is gated behind that option, which defaults to false — so until
now Mac users had no way to select text while a program held the mouse.

**"Following" and "Jump to Current" are gone.** Claude Code draws on the
alternate screen, which has no scrollback, so `viewportY` always equalled
`baseY` and neither control could do anything. They did still work in bash
tabs; xterm's native follow covers that, and the per-write `scrollToBottom()`
went with them because it fought exactly that. What remains, on activate and
after a refit, now samples `viewportY >= baseY` *before* the fit, so opening
the Notes dock no longer yanks a reader to the tail.

**`claude update` runs before every Claude session, not just at container
start.** Containers here stop/start and often just keep running, so a
long-lived one never re-checked. Both copies take the same flock: the
entrypoint prints "container ready" only after its own update finishes, so
opening a tab immediately would otherwise run two updaters against the same
~/.claude/bin, with `|| echo` hiding a half-written install one line before
`exec claude` ran it.

This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so
flags and session names are shell-interpolated now and must go through
`shell_quote_arg`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 11:02:33 -07:00
jknapp 3aec2998d8 Merge pull request 'Anchor the update channel tag, and stop shipping a duplicate AppImage' (#52) from fix/update-channel-durability into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m44s
Build App / build-linux (push) Successful in 4m48s
Build App / build-windows (push) Successful in 4m52s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 7s
2026-09-03 16:52:47 +00:00
shadowdao 019fb403d5 Merge remote-tracking branch 'origin/main' into fix/update-channel-durability
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
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 2m42s
Build App (Preview) / build-linux (pull_request) Successful in 4m51s
Build App (Preview) / build-windows (pull_request) Successful in 4m54s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
2026-09-03 09:41:22 -07:00
jknapp b21a568bf5 Merge pull request 'Install from the lockfile, so CI cannot be broken by someone else's release' (#53) from fix/ci-npm-lockfile into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m42s
Build App / build-windows (push) Successful in 4m53s
Build App / build-linux (push) Successful in 5m0s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
2026-09-03 16:41:15 +00:00
shadowdaoandClaude Opus 5 f41b1d9054 Install from the lockfile, so CI cannot be broken by someone else's release
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
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 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
`build-linux` fails before `tauri build` runs, on every workflow, at "Install
frontend dependencies":

    npm error Cannot read properties of null (reading 'edgesOut')

Reproduced exactly on the first attempt by running the step's own commands
locally on the same Node 22.23.2 the runner installs. The debug log gives the
frame the CI output omits:

    at #loadPeerSet (.../@npmcli/arborist/lib/arborist/build-ideal-tree.js:1289:38)

It is a null dereference in npm 10.9.8's peer-set resolver, reached through
vite → @vitejs/devtools → @vitejs/devtools-vitest → vitest@* →
@vitest/browser-playwright → vitest@4.1.11 → jsdom@* → canvas.

**Nothing in this repo changed to cause it.** The step deleted
`package-lock.json` before installing, so every build re-resolved the entire
tree against the registry against ranges like `vitest@*`. A dependency
published a version that produces a peer graph npm cannot resolve, and our CI
broke — the same command succeeded fifteen hours earlier for 0.4.21. That is
the real defect: the build was never reproducible, and the crash is only how we
found out.

So Linux installs with `npm ci`, from the committed lockfile, like Windows
already did. macOS moves too — it kept the lockfile but still ran `npm
install`, which is free to re-resolve; all three platforms now install
identically and none can re-resolve mid-release.

**The reason the lockfile was being deleted is obsolete, not ignored.** 2d4fce9
removed it "to ensure correct platform-specific bindings", which was a real
problem once. The committed lockfile now records 25 rollup platform variants,
and `npm ci` on Linux installs precisely rollup-linux-x64-{gnu,musl} and
@esbuild/linux-x64 — checked directly. A comment on the step says so, and says
not to reach for deleting the lockfile again: if `npm ci` refuses, package.json
and the lockfile have genuinely diverged and the fix is to commit an updated
lockfile.

Verified from the resulting tree: `tsc --noEmit` clean, `npm run build`
successful, 752 tests across 62 files passing. The `npx tauri --version ||
npm install @tauri-apps/cli` fallback in the next step cannot reintroduce a
fresh resolution — the CLI is a pinned devDependency that `npm ci` installs, so
the fallback is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:28:09 -07:00
shadowdaoandClaude Opus 5 d38736007f Take the re-review: distinguish "absent" from "unreachable"
Secret Scan / scan (push) Successful in 3s
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 2m3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Skipped
Second review of this branch. Two blockers and one real defect I had papered
over with a true-but-misleading claim.

**`make_latest` was missing from the republish path.** The create path sends
`"make_latest": "false"` so the channel cannot displace the versioned release
on the releases page. The reuse path — taken on every run after the first —
omitted it, and the API's documented default for a publish transition is
`true`. So the second release would have quietly promoted `linux-latest` to
the repository's Latest release: a release whose own body says "for a specific
version, use the versioned releases instead". Now sent on both paths.
`tag_name` is re-sent deliberately and now says so in a comment — the API
removes the tag when a PATCH omits it, and this branch exists because a tag
disappeared.

**A transient Gitea error would have cost the whole release.** `curl -sf`
fails identically for "404, the tag is genuinely absent" and "503, Gitea is
briefly unreachable", and both landed in the create branch. Creating a tag that
already exists returns 409, which aborted the last step of `build-linux` — and
`create-tag` and `sync-to-github` both depend on it, so no version tag and no
GitHub sync at all. The failure message also read "the tag does not exist" when
Gitea had merely been unreachable. Now a `case` on the HTTP code — 200 leave
alone, 404 create, anything else fail loudly with the real code — the same
idiom `Upload to Gitea release` already uses two steps above. `422
already_exists` on the release POST is likewise a recoverable answer, not a
reason to lose a release.

**The empty `Categories=` was still shipping, and my claim hid it.** I wrote
that the guard "asserts the absence of an empty value rather than the presence
of any filled one" — true of the regex, false of the artifact. The AppDir root
`.desktop` is a *symlink* into usr/share/applications, so `sed -i` replaced the
link with a regular file and left the real entry empty; the guard globbed the
root only, so it saw the copy it had just written and passed. Verified on the
real artifact: two divergent entries, and the one that shipped was empty. Fixed
with `--follow-symlinks`, both locations globbed, and the guard turned into a
positive assertion over every entry — which also closes its missing-key and
unmatched-glob holes. Both entries now read `Categories=Development;Utility;`.

Also taken: the duplicate-AppImage check moves to a precondition, since as a
post-mortem it let the script repack and overwrite the versioned artifact
before failing, and it silently selected by glob order, i.e. the older version
— it now refuses in under a second; assets are deleted and re-uploaded one at
a time, because deleting both up front left a fresh AppImage with no .zsync if
the second upload failed, which silently stops every client; and the success
line no longer claims a fallback was kept when there was nothing to demote.

Left as informational, with the reasoning recorded rather than acted on:
`--retry-all-errors` retries permanent 4xx (fail-closed, matches the repo's
other upload steps); the release list is unpaginated (a GraphQL lookup by
pending tag name is the durable fix, but 7 releases is decades from the cliff,
and the 422 handling above covers the failure mode); process-substitution
failure is invisible to `mapfile` (fail-closed downstream).

Verified against the real 0.4.19 artifact — happy path, no AppImage, two
AppImages, and an AppDir rebuilt with the bundled library removed. shellcheck
clean at warning level on both scripts. appimagetool now reports the AppStream
metadata found.

Nothing here is CI-proven, and that is worth stating plainly: `build-linux`
fails on this branch before `tauri build` even runs, at "Install frontend
dependencies" with `npm error Cannot read properties of null (reading
'edgesOut')` — confirmed in the logs of jobs 5644 and 5636. Unrelated to this
change and tracked separately, but it means the finalizer has never executed
in CI on either commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:17:34 -07:00
shadowdaoandClaude Opus 5 63f282bef6 Fix the review findings: never destroy a working anchor
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 1m49s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / prune-previews (pull_request) Skipped
An adversarial review of the previous commit found six real problems and
corrected one of my claims. Taking all of it.

**The anchoring could kill the channel it exists to protect.** It did
DELETE-then-POST so the tag would name the current build. If the POST failed
for any transient reason the script aborted having already deleted the anchor a
previous run put there, and the next mirror run pruned GitHub's copy — a
transient Gitea error converting a healthy channel into a dead one, which is
strictly worse than the step not existing. There was also a real window
between the two calls with no tag at all.

The DELETE bought nothing. The update string resolves the tag by *name* and the
assets hang off the release object, so nothing about the channel depends on
which commit the tag points at; moving it changes only the source-zip link.
It existed solely to get past a 409, since Gitea's POST /tags has no force
semantics. Now the tag is created if absent and otherwise left alone, which
removes the window too.

**My "no window where the two disagree" claim was wrong, and it is the third
time in this area I have asserted something I had not established.** The
release POST sets no `target_commitish`, so GitHub creates its tag at its own
default-branch HEAD, not at `GITEA_SHA`; the two agree only because
`sync_on_commit` pushes main minutes earlier. And the DELETE actively created
the window. What the ordering genuinely buys is narrower: if anchoring fails,
the script aborts before creating a GitHub release that would be orphaned.

**Orphaned drafts were invisible to the release lookup.** GitHub demotes a
release to a draft when its tag is deleted, and `/releases/tags/` never returns
drafts — precisely the state every mirror run left behind. The by-tag lookup
reported "absent" while 86 MB drafts accumulated, one per release. The lookup
now reads the authenticated list, republishes the newest, and deletes the rest.

**A guard that could not catch what it named.** The update-info assertion was
a substring match on the tag, so it passed for a wrong host, path, filename or
transport — verified: an `evil.example.com/.../linux-latest/...` string passes
the old check and fails the new one. Now a fixed full-string match.

Also from the review: an absent bundled library no longer exits early, because
that skipped the metadata *and* left `update-channel/` uncreated, killing the
publish step on a missing directory and taking the tag and mirror jobs with it;
the Categories guard asserts the absence of an empty value rather than the
presence of any filled one; the channel directory is cleared before use so a
stale zsync cannot satisfy an existence check while describing the previous
build; the AppImage count uses a glob array, since `ls | wc -l` aborted under
pipefail before the message it promised could print; uploads carry the
retry/http1.1 hardening this repo's other upload steps already learned to
need; verification compares served size against built size, because a status
code only proves something is served; and the release workflow now fails on
empty artifacts instead of publishing a release with no AppImage.

The metainfo file is installed as `Triple-C.appdata.xml`. appimagetool derives
the name it looks for from the .desktop basename, so under the id-based name it
warned the metadata was missing on every build while this script reported it
present. Now it prints "AppStream upstream metadata found in
usr/share/metainfo/Triple-C.appdata.xml" — the AppStream id inside the file is
unchanged and is what identifies the component.

Two review hypotheses did not hold and nothing was changed for them: `set -e`
does not abort on a failing `&&` list mid-script, and my claim of a `trap`
reassignment was wrong — there is one trap, installed once.

Verified against the real 0.4.19 artifact: exit 0, one AppImage beside the
release, channel pair in its own directory, appimagetool reporting the metadata
found, and the wayland fallback intact. Guards exercised individually — the
duplicate one bites, the exact-match one rejects an impostor carrying the tag,
the empty directory reports cleanly, and all four publisher preconditions
refuse rather than half-publishing. Header parsing for the size check was
tested against a real redirecting GitHub asset URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:53:13 -07:00
shadowdaoandClaude Opus 5 d561ce03d5 Anchor the update channel tag, and stop shipping a duplicate AppImage
Secret Scan / scan (push) Successful in 6s
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 1m49s
Build App (Preview) / build-macos (pull_request) Successful in 2m57s
Build App (Preview) / build-windows (pull_request) Successful in 16m16s
Build App (Preview) / prune-previews (pull_request) Skipped
Two defects in the update channel, both visible in 0.4.20 and 0.4.21.

**The channel tag does not survive.** `publish-update-channel.sh` created the
GitHub release, uploaded both assets and verified each URL returned 200 — the
job log shows it succeeding at 00:38. By 13:04 the tag was gone and every
installed copy was checking a 404.

Gitea push-mirrors this repo to GitHub every four hours, and a mirror push
deletes remote refs with no local counterpart. `linux-latest` was created by
GitHub's release API and never existed as a Gitea tag, so the mirror removed
it. Versioned tags were never affected because `create-tag` creates them in
Gitea first.

So the tag is now anchored in Gitea, and before the GitHub release rather than
after, so there is no window where the two disagree. Its absence fails the
step instead of warning, because it is the only thing keeping the channel
alive. Worth stating plainly: publishing correctly is not evidence the channel
still works, and the verification that passed at 00:38 could not have caught a
failure that arrives twelve hours later.

**Every release carried the AppImage twice.** The channel's stable-named copy
sat beside the versioned one, where the release job's `*.AppImage` glob picked
it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and
`Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and
`sync-to-github` copied both to the mirror. 80 MB of duplicate per release,
under a name that reads like a different build. That is how it was noticed.

The channel pair now lives in `bundle/appimage/update-channel/`, out of the
glob's reach, and a guard fails the build if more than one AppImage is left
beside the release. Verified by planting a second one: it fails.

One appimagetool quirk found while moving it — zsyncmake writes the .zsync
into the working directory, not beside the image it describes, so it has to be
collected rather than assumed in place. The existing guard caught that too.

Verified against the real 0.4.19 artifact: exactly one AppImage at top level,
the channel pair in its own directory, update string still resolving to the
fixed tag, and the wayland fallback intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:29:58 -07:00
105 changed files with 20899 additions and 528 deletions
+32 -5
View File
@@ -299,8 +299,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI
working-directory: ./app
@@ -335,7 +361,6 @@ jobs:
run: |
mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file.
@@ -427,8 +452,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI
working-directory: ./app
+51 -6
View File
@@ -172,8 +172,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI
working-directory: ./app
@@ -200,10 +226,23 @@ jobs:
- name: Collect artifacts
run: |
mkdir -p artifacts
# The versioned AppImage only. The update channel's copy lives in
# bundle/appimage/update-channel/ precisely so this glob cannot pick
# it up and publish an 80 MB duplicate under a second name.
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
ls -la artifacts/
# A green job that published nothing is the worst outcome available:
# the release exists, carries no AppImage, and nobody is told. The
# `|| true` above is there so a missing bundle does not mask the real
# error, which makes this check the thing that catches it.
shopt -s nullglob
collected=(artifacts/*)
if [ ${#collected[@]} -eq 0 ]; then
echo "No artifacts collected — the bundler produced nothing." >&2
exit 1
fi
- name: Upload to Gitea release
if: gitea.event_name == 'push'
env:
@@ -286,7 +325,11 @@ jobs:
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: bash scripts/publish-update-channel.sh artifacts
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_SHA: ${{ gitea.sha }}
run: |
bash scripts/publish-update-channel.sh \
app/src-tauri/target/release/bundle/appimage/update-channel
build-macos:
runs-on: macos-latest
@@ -343,8 +386,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI
working-directory: ./app
+38 -1
View File
@@ -28,6 +28,27 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# Put BuildKit in the host's network namespace so it can reach
# act_runner's cache service.
#
# The `docker-container` driver — which the multi-arch build below
# requires, since the plain `docker` driver cannot do
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
# an address the *job* container can reach, and nothing teaches the
# BuildKit container about it: the job could reach
# 192.168.1.126:40649 while the container actually making the request
# could not, and the build died with `no route to host`.
#
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
# missing route (a wrong address times out instead) — which is what a
# default firewalld zone does to traffic arriving from the docker
# bridge. Sharing the host's namespace sidesteps the question
# entirely: the cache address becomes local to BuildKit.
#
# No effect on runners where this already worked.
driver-opts: network=host
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
@@ -55,5 +76,21 @@ jobs:
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
# `ignore-error` is what stops a cache failure failing a build that
# already succeeded. act_runner emulates the GitHub Actions cache
# service on the runner host's LAN address, and the `docker-container`
# builder `setup-buildx-action` creates could not route to it —
# every layer of both arches built, then the job died on
# `GetCacheEntryDownloadURL: no route to host` while exporting.
#
# On a pull_request `push:` above is false, so this job pushes
# nothing and the cache is its only output: failing it discarded a
# complete, successful validation of the Dockerfile for both
# architectures. A cache is an optimisation and must degrade to
# "slow", never to "red".
#
# The import is already non-fatal — the build ran all 37 layers after
# warning that it could not read the cache — so only the exporter
# needs the flag.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,ignore-error=true
+3
View File
@@ -1,6 +1,9 @@
node_modules/
app/dist/
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
code-review.md
+21 -5
View File
@@ -71,13 +71,29 @@ npm ci
npx tauri build
```
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
explicitly:
```bash
npx tauri build --bundles appimage
```
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
publish for an audience the AppImage already serves, and neither could
self-update. A bare `npx tauri build` still emits them, because
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
untouched; they are not released and not tested.
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
| Format | Path |
|------------|-------------------------------|
| AppImage | `appimage/*.AppImage` |
| Debian pkg | `deb/*.deb` |
| RPM pkg | `rpm/*.rpm` |
| Format | Path | Released |
|------------|-------------------------------|----------|
| AppImage | `appimage/*.AppImage` | yes |
| Debian pkg | `deb/*.deb` | no |
| RPM pkg | `rpm/*.rpm` | no |
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
section of `CLAUDE.md` for why both of its steps are load-bearing.
## macOS
+124 -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`)
- **`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
- **`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/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
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
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_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
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
@@ -413,6 +436,26 @@ container is created once by a very long function where a dropped capability is
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
so a project already at `true` gets no recreation at all on upgrade.
### Keeping Claude Code current
`claude update` runs in **two** places, and both are needed:
- `container/entrypoint.sh` runs it once per container start, before any session exists.
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
command every Claude session launches with, because containers use a stop/start model and a
long-lived one would otherwise never re-check.
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
deliberately untouched.
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
@@ -436,6 +479,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
bump: churn on the old base, and it would consume the "you should migrate" signal without
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
- **A missing lineage label means "unknown, probe instead", never "stale".**
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
for the same reason a running container already outranked it. Assuming a snapshot existed is what
made a stopped, never-recreated project report "no container or snapshot image yet" with its
container sitting right there, and left Update disabled on the projects furthest behind.
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
layer acceptable. Two rules it earned the hard way:
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
daemon-wide, so a second copy of the app has live probe images matching the glob.
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
two concurrent probes fought over one tag — whichever finished first force-removed the image the
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
uniqueness is what makes overlapping probes safe.
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
failed to invalidate would plan a migration against a filesystem the project no longer has.
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
which leaves the migrate action in the project menu enabled — that action is not gated on the
banner — so the pre-flight would report nothing to copy while the backend was told to copy
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
- **`get_container_staleness` never commits while the project is claimed.** It takes no
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
reads `project_lock::held` instead and probes the snapshot rather than the container. The
collision is not symmetric: the probe losing is a retryable `probe_error`, but
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
commit would fail the user's Start with an opaque error.
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
stamps *now* — which is what real probe images do, and what any fixture for them must do.
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
crash before that point self-heal — `start_project_container` just recreates from the old
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
@@ -501,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/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
need an entry there — adding one is not required and none exists for any app command.
- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
`lib.rs`, and a bare `allow-<name-with-dashes>` entry in the one capability file for the
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`.
- **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
@@ -524,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
`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
- 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
+27 -5
View File
@@ -243,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
│ │ │ │ │
│ │ └──────────────────────────────────────────────────┘ │
├─────────────┴────────────────────────────────────────────────────────┤
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓
│ 2 project(s) · 1 running · 2 terminal(s) Notes
└──────────────────────────────────────────────────────────────────────┘
```
@@ -268,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
open you get a welcome screen with Docker/image/project readiness checks.
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
speech-to-text is enabled.
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
**Notes** toggle; and the microphone button when speech-to-text is enabled.
---
@@ -1224,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current
### Scrolling
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
for them to act on.
### When the mouse stops working
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
you cannot select text, and stray characters can appear as you move the pointer.
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
container — only the terminal's own state is reset.
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
the button is showing most of the time you are in one. It is there for when a program exits
without handing the mouse back and the terminal is left stuck; releasing while a program is still
running just takes the mouse away from that program.
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
macOS.
### Files
+1 -1
View File
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
+1 -1
View File
@@ -58,7 +58,7 @@ choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup.
---
+6 -3
View File
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
### bollard (Docker API)
+348
View File
@@ -8,6 +8,21 @@
"name": "triple-c",
"version": "0.4.0",
"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/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
@@ -413,6 +428,204 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1055,6 +1268,123 @@
"@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": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2523,6 +2853,12 @@
"dev": true,
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -3599,6 +3935,12 @@
"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": {
"version": "3.2.4",
"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": {
"version": "5.0.0",
"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\""
},
"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/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
+1
View File
@@ -5306,6 +5306,7 @@ dependencies = [
"tauri-plugin-opener",
"tokio",
"tower-http",
"url",
"uuid",
"zeroize",
]
+6
View File
@@ -39,6 +39,10 @@ local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
# container. Already in the tree transitively (reqwest), and the point of
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
url = "2"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
@@ -47,6 +51,8 @@ tokio = { version = "1", features = ["full", "test-util"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
serde_json = "1"
[features]
default = ["custom-protocol"]
+207 -2
View File
@@ -1,3 +1,208 @@
fn main() {
tauri_build::build()
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
//! 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
+118 -8
View File
@@ -15,6 +15,12 @@ use crate::AppState;
/// non-`Running` status carrying an explanation rather than an error, so the
/// pane always has something specific to say. This is host-side only — no
/// container recreation is involved either way.
///
/// Either way the choice is persisted, so it survives an app restart. This is
/// the only caller allowed to write `false`: every other path to
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
/// is a teardown rather than the user changing their mind. Enabling persists
/// inside `start`, which is the single funnel for it.
#[tauri::command]
pub async fn set_browser_view_enabled(
project_id: String,
@@ -23,9 +29,32 @@ pub async fn set_browser_view_enabled(
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
if !enabled {
// Persist first, then tear down: the supervisor's own teardown emit
// reads this flag back out of the store, and reading it mid-stop would
// announce a view that is going away as still enabled.
//
// But the write's outcome is a *value*, not a branch. A `?` here meant
// that a store with no such project record returned early and
// `manager().stop()` never ran, leaving the supervisor, the proxy and
// the host port up for a project that, as far as the user is concerned,
// just had its view switched off. That state is not hypothetical while
// a session is live — the supervisor's own `store.get()` check in
// [`crate::browser_view`] exists because a record can go away
// underneath it — and before the flag was persisted at all, turning the
// view off always tore the session down.
let persisted = state
.projects_store
.set_browser_view_enabled(&project_id, false);
// Awaits the supervisor, so the host port is released before we return.
manager().stop(&project_id).await;
return Ok(manager().status(&project_id).await);
//
// A failed write is still reported rather than logged and swallowed.
// The resources are gone either way by this point, so surfacing it
// costs nothing that matters, and the failure it describes is one the
// user needs: the stored flag still says *enabled*, so the view comes
// back by itself on the next launch. Returning `Ok` would be a claim
// about persistence that isn't true.
tear_down_then_report(persisted, manager().stop(&project_id)).await?;
return Ok(manager().status(&project_id, false).await);
}
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
@@ -40,10 +69,31 @@ pub async fn set_browser_view_enabled(
.await
}
/// Current status. Cheap: reads in-process state only, never the container.
/// Await `teardown`, then report `persisted`.
///
/// Trivial on purpose, and split out for one reason: it is the whole rule the
/// disable path of [`set_browser_view_enabled`] has to obey — the teardown is
/// unconditional, and a failed persist surfaces only after it has run — and as
/// a free function that rule can be tested without a live `AppState`.
async fn tear_down_then_report(
persisted: Result<(), String>,
teardown: impl std::future::Future<Output = ()>,
) -> Result<(), String> {
teardown.await;
persisted
}
/// Current status. Cheap: the session map in this process plus the stored flag,
/// never the container.
///
/// The two are independent on purpose — this is what the pane reads on mount,
/// and after an app restart the honest answer is "enabled, nothing running".
#[tauri::command]
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id).await)
pub async fn get_browser_view_status(
project_id: String,
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
}
/// Probe the container for Playwright without starting anything.
@@ -110,7 +160,9 @@ pub async fn open_browser_view_popout(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
return Err(
"The browser view isn't running. Start it before opening it in its own window."
@@ -209,7 +261,9 @@ pub async fn open_page_in_container_browser(
// the user to go and press Start in the Browser tab themselves — and from
// the terminal's URL prompt, with no indication that was even needed.
// Asking for a page *is* asking to watch it, so the viewer comes up too.
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if status.state != BrowserViewState::Running {
crate::commands::project_commands::emit_progress(
&app_handle,
@@ -229,7 +283,9 @@ pub async fn open_page_in_container_browser(
// From the terminal there is no pane on screen to fill, so the page needs a
// window of its own or it lands somewhere the user isn't looking.
if show_window {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if let Some(url) = status.url.as_deref() {
let name = state
.projects_store
@@ -311,6 +367,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
Ok(popout::match_window(&project_id))
}
/// The project's stored browser-view opt-in.
///
/// The manager holds no copy of this — see
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
/// status call reads it here, the way `get_auth_bridge_status` does. A project
/// that has gone away reads as off, which is the only answer that can be given
/// about a record that no longer exists.
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
state
.projects_store
.get(project_id)
.is_some_and(|p| p.browser_view_enabled)
}
/// The project's container, or a sentence saying why there isn't one.
///
/// Every command here needs a *running* container, and every one of them used
@@ -344,3 +414,43 @@ async fn running_container(
}
Ok(container_id)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
/// The regression: turning the view off must not leave the supervisor, the
/// proxy and the host port running just because the project record could
/// not be written — which is exactly what a missing record did.
#[tokio::test]
async fn a_failed_persist_does_not_skip_the_teardown() {
let torn_down = AtomicBool::new(false);
let result = tear_down_then_report(Err("Project x not found".to_string()), async {
torn_down.store(true, Ordering::SeqCst);
})
.await;
assert!(
torn_down.load(Ordering::SeqCst),
"the session must be torn down even when the store write failed"
);
assert_eq!(
result.err().as_deref(),
Some("Project x not found"),
"and the write failure must still reach the caller, not be swallowed"
);
}
#[tokio::test]
async fn a_successful_persist_reports_success_after_the_teardown() {
let torn_down = AtomicBool::new(false);
let result = tear_down_then_report(Ok(()), async {
torn_down.store(true, Ordering::SeqCst);
})
.await;
assert!(torn_down.load(Ordering::SeqCst));
assert!(result.is_ok());
}
}
+81 -32
View File
@@ -34,14 +34,22 @@
//!
//! ## Lifecycle
//!
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
//! Off by default and per-project opt-in. The opt-in itself is
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
//! `auth_bridge_enabled` and read from the store on demand rather than cached
//! here — so the pane comes back the way it was left. What does *not* persist
//! is the session: nothing starts a viewer on app start, so a project left
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
//! for one. That is deliberate, and the reason the flag and the session are
//! separate ideas — see [`BrowserViewManager::status`].
//!
//! One supervisor task per session owns the proxy and the viewer process, and it
//! is the only thing that tears them down, so every way a session can end funnels
//! through one code path:
//!
//! | Trigger | Path |
//! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
//! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
@@ -59,7 +67,10 @@
//! orphan is reachable on container loopback only: the host-side port dies with
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
//! precisely so the bridge will not mirror an orphan the next time the app
//! starts. The next [`BrowserViewManager::start`] reclaims it.
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
//! which is exactly the state that invites the user to press the button that
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
pub mod commands;
pub mod detect;
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
#[derive(Debug, Clone, Serialize)]
pub struct BrowserViewStatus {
/// The per-project opt-in. Off by default.
/// The per-project opt-in, read from the persisted project record. Off by
/// default, and true without a `Running` state whenever the view is turned
/// on but has nothing up — a stopped container, or an app that has just
/// restarted and does not auto-start viewers.
pub enabled: bool,
pub state: BrowserViewState,
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
@@ -201,17 +215,20 @@ struct Session {
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
/// Live sessions, and nothing else.
///
/// The per-project opt-in deliberately is **not** a field here. It lives on
/// the project record as
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
/// is read from [`ProjectsStore`] at each use, exactly as
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
/// one copy, durable across a restart, and impossible to get out of step with
/// what the Config tab shows. A cached copy here was the previous design and
/// its only observable behaviour was forgetting the user's choice on every
/// app start.
#[derive(Default)]
pub struct BrowserViewManager {
sessions: SessionMap,
/// The per-project opt-in.
///
/// NOTE: in memory only, so it does not survive an app restart. The durable
/// home for this is a `browser_view_enabled: bool` field on
/// `models::Project` (see the report) — `models/project.rs` is out of scope
/// for this change, so the flag lives here and the wiring is otherwise
/// identical to `auth_bridge_enabled`.
enabled: Mutex<std::collections::HashSet<String>>,
next_epoch: AtomicU64,
}
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
}
impl BrowserViewManager {
pub async fn is_enabled(&self, project_id: &str) -> bool {
self.enabled.lock().await.contains(project_id)
}
async fn set_enabled(&self, project_id: &str, enabled: bool) {
let mut set = self.enabled.lock().await;
if enabled {
set.insert(project_id.to_string());
} else {
set.remove(project_id);
}
}
/// Current status without touching the container.
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
let enabled = self.is_enabled(project_id).await;
///
/// `enabled` is passed in rather than looked up, the way
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
/// the caller's to read from the store, and keeping it out of here is what
/// stops a second copy of it appearing. A project whose view is enabled but
/// whose container is stopped — or whose app has just restarted — reports
/// `enabled: true` with a state of `Off`, which is the honest answer.
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
match self.sessions.lock().await.get(project_id) {
Some(session) => BrowserViewStatus {
enabled,
@@ -261,6 +271,14 @@ impl BrowserViewManager {
///
/// Idempotent: a call while a live session exists returns that session's
/// status untouched, so re-opening the tab does not restart the dashboard.
///
/// This is the single funnel for turning the view **on**, so it is also
/// where the durable flag is written — both call sites (the toggle and
/// `open_page_in_container_browser`, which opens a page and then shows it)
/// mean "on", and neither can forget. The **off** direction is not
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
/// that are not the user changing their mind, so the command owns that
/// write. See [`Self::stop`].
pub async fn start(
&self,
project_id: String,
@@ -268,7 +286,7 @@ impl BrowserViewManager {
app: AppHandle,
store: Arc<ProjectsStore>,
) -> Result<BrowserViewStatus, String> {
self.set_enabled(&project_id, true).await;
store.set_browser_view_enabled(&project_id, true)?;
// Bind the answer before acting on it: `status()` takes the same lock,
// and this mutex is not reentrant.
@@ -279,7 +297,7 @@ impl BrowserViewManager {
.get(&project_id)
.is_some_and(|s| !s.supervisor.is_finished());
if already_live {
return Ok(self.status(&project_id).await);
return Ok(self.status(&project_id, true).await);
}
let detection = detect::detect(&container_id).await?;
@@ -364,14 +382,21 @@ impl BrowserViewManager {
},
);
let status = self.status(&project_id).await;
let status = self.status(&project_id, true).await;
emit(&app, &project_id, &status);
Ok(status)
}
/// Stop one project's view and wait until its host port has been released.
///
/// Tears the *session* down and deliberately leaves the durable flag alone.
/// Most callers are not the user turning the feature off — a migration
/// removes the container out from under a running view
/// (`migration_commands`), and the container can stop for any other reason
/// — and persisting `false` for those would quietly opt the project out of
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
/// the one caller that means it, and it writes the flag itself first.
pub async fn stop(&self, project_id: &str) {
self.set_enabled(project_id, false).await;
// Remove under the lock, then release it before awaiting: the
// supervisor takes the same lock to deregister itself on exit.
let session = self.sessions.lock().await.remove(project_id);
@@ -483,7 +508,12 @@ async fn supervise(
// longer exists. The session owns it, and this is where the session ends.
let _ = popout::close(&app, &project_id);
let enabled = manager().is_enabled(&project_id).await;
// Straight from the store, like the auth bridge's own teardown emit: the
// session is over, but the project may well still be opted in — a stopped
// container is not a changed mind, and the pane has to show the difference.
let enabled = store
.get(&project_id)
.is_some_and(|p| p.browser_view_enabled);
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
}
@@ -915,6 +945,25 @@ mod tests {
assert!(s.url.is_none());
}
#[tokio::test]
async fn the_opt_in_and_the_live_session_are_separate_answers() {
let manager = BrowserViewManager::default();
// Exactly what the pane reads on mount after an app restart of a
// project that was left enabled: the durable flag says on, and nothing
// auto-starts, so the state is honestly `Off`. The old in-memory flag
// could not express this — it came back `false` and the pane silently
// showed the feature as never having been turned on.
let status = manager.status("p1", true).await;
assert!(status.enabled);
assert_eq!(status.state, BrowserViewState::Off);
assert!(status.url.is_none());
// The flag belongs to the caller, read from the store. The manager
// keeps no copy, so it has nothing to contradict it with.
assert!(!manager.status("p1", false).await.enabled);
}
#[test]
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
let mut d = PlaywrightDetection::default();
+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
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// 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]
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
/// 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() {
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
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
/// 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)?;
if CONTAINER_WRITE_ROOTS
.iter()
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
}
/// One regular file's bytes, pulled out of a container.
struct FetchedFile {
bytes: Vec<u8>,
pub(crate) struct FetchedFile {
pub(crate) bytes: Vec<u8>,
/// 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.
size: u64,
truncated: bool,
pub(crate) size: u64,
pub(crate) truncated: bool,
}
/// 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
/// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling.
async fn fetch_container_file(
pub(crate) async fn fetch_container_file(
container_id: &str,
container_path: &str,
max_bytes: u64,
@@ -1448,6 +1448,14 @@ pub async fn create_container_directory(
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.
///
/// 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`
/// is the first thing to touch the container, so a stopped project fails inside
/// 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 running = docker
.inspect_container(container_id, None)
@@ -1468,10 +1476,7 @@ async fn require_running(container_id: &str, action: &str) -> Result<(), String>
if running {
return Ok(());
}
Err(format!(
"Start the project before {} — it runs inside the running container.",
action
))
Err(not_running_message(action, "it runs inside the running container"))
}
/// 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
/// this text ends up inside a toast that renders above every modal, and its
/// author is the container.
fn clip_container_text(text: &str) -> String {
pub(crate) fn clip_container_text(text: &str) -> String {
const MAX: usize = 200;
let flattened: String = text
.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());
}
}
+743 -14
View File
@@ -92,8 +92,336 @@ fn pick_recorded_lineage(
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
}
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
/// be called on demand, not polled.
/// Reported as `probe_error` when there is genuinely nothing to read: no
/// container, stopped or otherwise, and no snapshot image.
///
/// It used to be reported for a *stopped* container too, which was simply
/// untrue — the container was sitting right there — and it disabled Update on
/// exactly the long-lived projects that had never been recreated and so had no
/// snapshot to fall back on.
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
/// The project's container, as the daemon reported it.
///
/// `running` lives *inside* `Present` because it is only ever read about a
/// container that was found: `is_container_running` needs an id. Keeping the
/// two in one variant makes "running, but no container" unrepresentable rather
/// than merely unreached, which is what [`pick_probe_source`] relies on when it
/// hands a container id to the container probe arms.
#[derive(Debug, PartialEq, Eq)]
enum ContainerState {
/// The project genuinely has no container — an answer, not a failure to
/// look.
Absent,
Present {
id: String,
running: bool,
},
}
impl ContainerState {
fn id(&self) -> Option<&str> {
match self {
ContainerState::Absent => None,
ContainerState::Present { id, .. } => Some(id),
}
}
}
/// Where [`get_container_staleness`] reads the project's *current* filesystem
/// from, in descending order of how current the answer is.
///
/// The container variants carry the id they will be probed with, so that
/// "there is a container to read" and "here is which one" cannot come apart
/// downstream.
#[derive(Debug, PartialEq, Eq)]
enum ProbeSource<'a> {
/// `docker exec` into the live container. The only source that includes
/// everything installed since the last commit *in this session*.
RunningContainer(&'a str),
/// Commit the stopped container's writable layer to a throwaway image and
/// probe that. Exactly as current as the container, which is what makes it
/// preferable to the snapshot — see below.
StoppedContainer(&'a str),
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
Snapshot,
/// Nothing to read: no container, no snapshot.
Nothing,
}
/// Pick the probe source, or report the one reading this decision needed and
/// did not get.
///
/// **A stopped container outranks the snapshot.** The snapshot image is not a
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
/// config-change recreate) or inside a migration, so a project that has never
/// hit either has *no snapshot at all*, however long it has been in use, and
/// one that has is stale by everything installed since. The container's
/// writable layer is the truth in both cases. This is the same argument
/// [`mig::manifest_from_container`] already makes for the running case; it does
/// not stop applying when the container is stopped.
///
/// Getting this wrong is what made a stopped, never-recreated project report
/// "no container or snapshot image yet" — with its container sitting right
/// there — and left Update disabled on the projects that most needed it.
///
/// **`snapshot_exists` is consulted only where it decides something.** When a
/// container answered, the snapshot is not part of this decision at all, so a
/// failed `image_exists` is passed over rather than surfaced: destroying a
/// report the running container could have supplied in full would be the same
/// mistake, in the other direction, as reading an unreachable daemon as an
/// absent container. It is load-bearing only with no container at all, and
/// there its failure *is* the answer this function cannot give.
fn pick_probe_source<'a>(
container: &'a ContainerState,
snapshot_exists: &Result<bool, String>,
) -> Result<ProbeSource<'a>, String> {
match container {
ContainerState::Present { id, running: true } => Ok(ProbeSource::RunningContainer(id)),
// The stopped path may still want the snapshot, but only as a fallback
// it can do without — see `stopped_probe_policy` and the commit-failure
// arm in `get_container_staleness`, which each handle an unreadable
// snapshot themselves.
ContainerState::Present { id, running: false } => Ok(ProbeSource::StoppedContainer(id)),
ContainerState::Absent => match snapshot_exists {
Ok(true) => Ok(ProbeSource::Snapshot),
Ok(false) => Ok(ProbeSource::Nothing),
Err(e) => Err(probe_failed(e)),
},
}
}
/// Reported as `probe_error` when another operation owns the project and there
/// is no snapshot image to read instead. Deliberately not a claim about the
/// container: nothing is wrong with it, the answer is simply not safe to take
/// right now. See [`stopped_probe_policy`].
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
/// What to do about a stopped container, whose probe is the expensive one: it
/// commits the writable layer before it can read anything.
#[derive(Debug, PartialEq, Eq)]
enum StoppedProbe {
/// Commit and probe. The current answer, and the default.
Commit,
/// Probe the snapshot image instead. Less current — it lags the container by
/// everything installed since the last commit — but it allocates nothing and
/// touches nothing, which is what makes it the right answer while another
/// operation owns the container.
SnapshotInstead,
/// Report rather than guess, with the message to report.
Defer(String),
}
/// Pick what to do about a stopped container.
///
/// **Never commits while the project is claimed.** `get_container_staleness`
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
/// *recreate* losing is not, because `start_project_container` removes the old
/// container with a hard `?`, so a non-404 from a remove that raced this commit
/// fails the whole Start with an opaque "Failed to remove container". Reading
/// the claim costs nothing and takes that failure off the table.
///
/// `snapshot_exists` matters only once the project is busy, because that is the
/// only state in which the snapshot is the alternative to committing. An
/// unreadable snapshot there leaves nothing to fall back *to*, so its error is
/// what gets reported: "try again once it finishes" alone would be a claim that
/// waiting is all that stands in the way, which a failed `image_exists` has not
/// established.
fn stopped_probe_policy(
project_is_busy: bool,
snapshot_exists: &Result<bool, String>,
) -> StoppedProbe {
match (project_is_busy, snapshot_exists) {
(false, _) => StoppedProbe::Commit,
(true, Ok(true)) => StoppedProbe::SnapshotInstead,
(true, Ok(false)) => StoppedProbe::Defer(PROJECT_BUSY.to_string()),
(true, Err(e)) => StoppedProbe::Defer(probe_failed(e)),
}
}
/// Reported as `probe_error` when a probe input could not be read at all.
///
/// Deliberately distinct from [`NOTHING_TO_PROBE`]: a failed reading is not
/// evidence that the project has no container, and saying "no container or
/// snapshot image yet" on a transient fault was confidently wrong about a
/// project that may well have both.
///
/// Deliberately *neutral about the cause*, too. Only one of the four readings
/// implies an unreachable daemon: `mig::image_id` maps a 404 to `Ok(None)` and
/// returns `Err` for any other status, and `find_existing_container` /
/// `image_exists` wrap every list failure the same way — all of which a daemon
/// that answered perfectly well can produce. The base image name comes from
/// user settings, so a malformed reference alone reaches here, and telling that
/// user to go fix a running daemon would be the same unestablished claim about
/// a cause that this whole probe path exists to stop making.
///
/// The underlying error is carried through verbatim, because "Docker is not
/// running" and "permission denied on /var/run/docker.sock" call for different
/// fixes from the user.
///
/// The sentence names *the check*, not the container, because only two of the
/// four readings are about the container at all — the other two are the base
/// image and the snapshot image. Saying "this project's container could not be
/// inspected" for a malformed base image name in settings would point the user
/// at the wrong object, which is the same mistake one size down.
fn probe_failed(e: &str) -> String {
format!("This project could not be checked against its base image: {}", e)
}
/// The four daemon readings [`get_container_staleness`] takes before it can
/// choose a probe source, each still carrying whether it is an answer.
///
/// **Absence and unreachability are different answers, and only one of them is
/// an answer.** All four callees already draw that line — `image_id` maps a 404
/// to `Ok(None)`, `find_existing_container` and `image_exists` return `Ok` with
/// an empty filtered list — so a call site that writes `.unwrap_or(None)` /
/// `.unwrap_or(false)` is not defaulting, it is *discarding a distinction the
/// callee went to the trouble of making*. That is what let an unreachable
/// daemon reach [`pick_probe_source`] as "no container, no snapshot" and report
/// [`NOTHING_TO_PROBE`] — a confident claim about a project nothing had
/// actually looked at.
///
/// The `Result` fields are the guard against that returning: the call site
/// hands over what the daemon said, unmodified, and an `.unwrap_or` there no
/// longer type-checks.
#[derive(Debug)]
struct ProbeReadings {
/// `docker::find_existing_container`.
container_id: Result<Option<String>, String>,
/// `docker::is_container_running`, and `None` when there was no container
/// to ask about — not a swallowed error.
container_running: Option<Result<bool, String>>,
/// `mig::image_id` for the configured base image.
base_image_id: Result<Option<String>, String>,
/// `docker::image_exists` for the project's snapshot image.
snapshot_exists: Result<bool, String>,
}
/// The readings [`get_container_staleness`] carries past the point where a
/// missing one would have stopped it.
#[derive(Debug)]
struct ProbeInputs {
/// The current base image's ID, or `None` when it is not pulled locally —
/// which [`mig::image_id`] reports as `Ok(None)`, not an error.
current_base_image_id: Option<String>,
container: ContainerState,
/// Still a `Result`, because whether it is load-bearing depends on the
/// container: see [`pick_probe_source`].
snapshot_exists: Result<bool, String>,
}
/// What [`get_container_staleness`] does next, once the readings are in.
#[derive(Debug)]
enum ProbeStart {
/// Go ahead, with these inputs.
Inputs(Box<ProbeInputs>),
/// Stop, and hand the user this report.
///
/// **Reported, not returned.** The hook's `catch` sets `staleness` to
/// `null`, and `ContainerMigrationBanner` renders nothing at all for a null
/// staleness — so an `Err` out of the command would make the banner vanish
/// at exactly the moment it has something to say. A `probe_error` on an
/// otherwise-default report keeps it on screen, reading "Container base
/// could not be checked". Carrying a `ContainerStaleness` rather than an
/// error string is what keeps that decision here, where it is tested,
/// instead of in the `?` someone adds at the call site later.
Report(Box<ContainerStaleness>),
}
/// Decide whether the collected readings are enough to probe with.
///
/// Only the readings this decision actually rests on can stop it:
///
/// * `container_id` selects the probe source outright, so a failure to read it
/// leaves nothing to choose between. Fatal.
/// * `base_image_id` is fatal too, and deliberately so: it is the right-hand
/// side of the staleness comparison, where `None` ("not pulled locally", an
/// answer) and `Err` ("could not ask") both otherwise collapse into
/// `stale: false`. Reporting a project as up to date because the base image
/// could not be read is exactly the #56 mistake, one field over.
/// * `container_running` is asked only about a container that was found, and
/// decides between two live probe sources. Fatal when present.
/// * `snapshot_exists` is *not* fatal here, because it is load-bearing in only
/// two of the downstream states — no container at all, and a stopped
/// container on a busy project. It travels as a `Result` so each of those can
/// surface it, and the states that never consult it are not punished for it.
///
/// The first error wins, because when the daemon is unreachable they fail
/// together and the user needs the reason once, not three times. The order is
/// `container_id`, then `base_image_id`, then `container_running` — chosen
/// priority, deliberately *not* the order the daemon was called in, so that the
/// reported error is most often the one that stopped the probe rather than
/// whichever reading happened to run first. (It is at most three, not four:
/// `container_running` is only attempted when `container_id` answered with a
/// container.)
///
/// **A caveat this cannot fix here.** `docker::is_container_running` swallows
/// `inspect_container` failures into `Ok(false)` itself and errors only when the
/// client cannot be built, so a daemon that dies between the list and the
/// inspect still reads as "stopped" rather than as an error. That is a fix
/// inside that function, not at this call site; threading its `Result` through
/// at least stops *this* layer from adding a second swallow on top.
fn start_probe(readings: ProbeReadings) -> ProbeStart {
match collect_probe_inputs(readings) {
Ok(inputs) => ProbeStart::Inputs(Box::new(inputs)),
Err(e) => ProbeStart::Report(Box::new(ContainerStaleness {
probe_error: Some(e),
..Default::default()
})),
}
}
fn collect_probe_inputs(readings: ProbeReadings) -> Result<ProbeInputs, String> {
let ProbeReadings {
container_id,
container_running,
base_image_id,
snapshot_exists,
} = readings;
let container_id = container_id.map_err(|e| probe_failed(&e))?;
let current_base_image_id = base_image_id.map_err(|e| probe_failed(&e))?;
let container_running = container_running
.transpose()
.map_err(|e| probe_failed(&e))?;
let container = match (container_id, container_running) {
(Some(id), Some(running)) => ContainerState::Present { id, running },
// No container: whatever `container_running` says is about nothing, and
// the caller only produces `None` here anyway.
(None, _) => ContainerState::Absent,
// A container was found but nobody asked whether it was running. The
// caller cannot produce this, and guessing "stopped" would cost a
// running project the only probe source that sees this session's
// installs — so say what happened instead.
(Some(_), None) => return Err(probe_failed("the container's state was not read")),
};
Ok(ProbeInputs {
current_base_image_id,
container,
snapshot_exists,
})
}
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
/// on demand, not polled.
///
/// **Not read-only, despite only reporting.** The stopped-container path commits
/// a throwaway image and force-removes it, which makes this a writer of a
/// `triple-c-probe-*` image and puts it in the class of thing
/// [`crate::project_lock`] exists for — and it takes no claim. That is
/// deliberate: this is what the migration banner calls to decide whether to
/// offer an update, including while a migration is in flight, so refusing it
/// under a claim would blank the banner exactly when it has the most to say.
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
/// migration can remove the container out from under the commit, and the result
/// is a `probe_error` the user can retry, never a damaged container or a
/// mislabelled image. Two overlapping probes cannot collide either, because
/// probe image names are unique per call; see
/// [`crate::docker::container::get_probe_image_name`].
#[tauri::command]
pub async fn get_container_staleness(
project_id: String,
@@ -111,7 +439,35 @@ pub async fn get_container_staleness(
let snapshot_image = docker::get_snapshot_image_name(&project);
let mut out = ContainerStaleness::default();
out.current_base_image_id = mig::image_id(&base_image).await.unwrap_or(None);
// Every reading the daemon owes us, taken up front and handed on exactly as
// it came back, so that "could not ask" stays distinguishable from "asked,
// and the answer is no". [`start_probe`] is where that distinction is acted
// on; nothing between here and there may collapse one into the other, and
// the `Result` fields of [`ProbeReadings`] are what stop it being possible.
let container_id_result = docker::find_existing_container(&project).await;
let container_running_result = match &container_id_result {
Ok(Some(id)) => Some(docker::is_container_running(id).await),
// No container, or no usable reading of one: nothing to inspect, and
// the container lookup's own error is what gets reported.
_ => None,
};
let readings = ProbeReadings {
container_id: container_id_result,
container_running: container_running_result,
base_image_id: mig::image_id(&base_image).await,
snapshot_exists: docker::image_exists(&snapshot_image).await,
};
let inputs = match start_probe(readings) {
ProbeStart::Inputs(inputs) => *inputs,
// Reported, not returned — see [`ProbeStart::Report`].
ProbeStart::Report(report) => return Ok(*report),
};
let container = inputs.container;
let container_id = container.id();
out.current_base_image_id = inputs.current_base_image_id;
out.snapshot_created_at = mig::image_created(&snapshot_image).await;
// Lineage, most authoritative source first: the live container's label,
@@ -125,8 +481,7 @@ pub async fn get_container_staleness(
// as an answer and skip the snapshot entirely, so a snapshot that *did*
// record a lineage was never consulted and the project reported "unknown"
// with the information sitting one lookup away.
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
let from_container = match &container_id {
let from_container = match container_id {
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
None => None,
};
@@ -145,16 +500,70 @@ pub async fn get_container_staleness(
};
// ── Probes ───────────────────────────────────────────────────────────
let running = match &container_id {
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
None => false,
let snapshot_exists = &inputs.snapshot_exists;
let source = match pick_probe_source(&container, snapshot_exists) {
Ok(source) => source,
// The only reading this decision needed and did not get — see
// [`ProbeStart::Report`] for why this is a report and not an `Err`.
Err(e) => {
out.probe_error = Some(e);
return Ok(out);
}
};
let from_manifest = if running {
mig::manifest_from_container(container_id.as_ref().unwrap()).await
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
mig::manifest_from_image(&snapshot_image).await
} else {
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
let from_manifest = match source {
ProbeSource::RunningContainer(id) => mig::manifest_from_container(id).await,
ProbeSource::StoppedContainer(id) => {
let busy = crate::project_lock::held(&project_id).is_some();
match stopped_probe_policy(busy, snapshot_exists) {
StoppedProbe::Commit => {
match mig::manifest_from_stopped_container_cached(id).await {
Ok(m) => Ok(m),
// **Never let a failed commit cost an answer the
// snapshot could have given.** Before stopped
// containers were readable at all, a stopped project
// fell straight through to its snapshot, so surfacing
// this error where the snapshot exists would make the
// banner *worse* than it was — and the ways this fails
// are the ones where the fallback matters most: a full
// disk (the commit has to allocate the whole writable
// layer; the snapshot probe allocates nothing) and a
// 409 from an operation that claimed the project after
// the check above.
// `Ok(true)` specifically: an `image_exists` that
// failed has not established that there is anything to
// fall back to, and probing a snapshot that may not
// exist would replace the commit's real error with a
// confusing one.
Err(e) if matches!(snapshot_exists, Ok(true)) => {
log::warn!(
"Probing the stopped container for project {} failed ({}) — \
falling back to its snapshot image, which may lag it",
project_id,
e
);
mig::manifest_from_image(&snapshot_image).await
}
Err(e) => Err(e),
}
}
StoppedProbe::SnapshotInstead => {
log::info!(
"Project {} is claimed by another operation — probing its snapshot image \
rather than committing the container",
project_id
);
mig::manifest_from_image(&snapshot_image).await
}
StoppedProbe::Defer(message) => Err(message),
}
}
ProbeSource::Snapshot => mig::manifest_from_image(&snapshot_image).await,
// Reached only when there is genuinely neither a container nor a
// snapshot: `ProbeSource` carries the container id in its container
// variants, so a container that exists can no longer fall through to
// here — which is the bug this arm used to hide, swallowing every
// stopped container.
ProbeSource::Nothing => Err(NOTHING_TO_PROBE.to_string()),
};
let (from_manifest, base_manifest) = match from_manifest {
@@ -1964,6 +2373,326 @@ mod tests {
assert_eq!(pick_recorded_lineage(some(""), None), None);
}
/// The readings as the daemon answered them, all four healthy: no
/// container, nothing pulled, no snapshot. Tests override the one reading
/// they are about, which keeps it obvious which reading each case is
/// actually exercising.
fn readings() -> ProbeReadings {
ProbeReadings {
container_id: Ok(None),
container_running: None,
base_image_id: Ok(None),
snapshot_exists: Ok(false),
}
}
fn present(running: bool) -> ContainerState {
ContainerState::Present {
id: "c1".to_string(),
running,
}
}
/// What every one of the four readings looks like when the socket is gone:
/// generic over what it was going to return.
fn daemon<T>() -> Result<T, String> {
Err("Failed to list containers: connection refused".to_string())
}
#[test]
fn a_stopped_container_is_probed_rather_than_reported_missing() {
// The regression: a container that exists but is stopped, with no
// snapshot ever taken, read as "nothing to compare against".
assert_eq!(
pick_probe_source(&present(false), &Ok(false)),
Ok(ProbeSource::StoppedContainer("c1"))
);
}
#[test]
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
// The snapshot lags the container by everything installed since the
// last commit, in both states.
assert_eq!(
pick_probe_source(&present(true), &Ok(true)),
Ok(ProbeSource::RunningContainer("c1"))
);
assert_eq!(
pick_probe_source(&present(false), &Ok(true)),
Ok(ProbeSource::StoppedContainer("c1"))
);
}
#[test]
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
assert_eq!(
pick_probe_source(&ContainerState::Absent, &Ok(true)),
Ok(ProbeSource::Snapshot)
);
}
#[test]
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
// The one case the "no container or snapshot image yet" message may
// still describe.
assert_eq!(
pick_probe_source(&ContainerState::Absent, &Ok(false)),
Ok(ProbeSource::Nothing)
);
}
#[test]
fn an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer() {
// A container answered, so `image_exists` decides nothing: its failure
// must not cost a report the container can supply in full. Treating it
// as fatal turned "running container, one flaky `image_exists`" into a
// bare probe_error with Update disabled.
assert_eq!(
pick_probe_source(&present(true), &daemon()),
Ok(ProbeSource::RunningContainer("c1"))
);
assert_eq!(
pick_probe_source(&present(false), &daemon()),
Ok(ProbeSource::StoppedContainer("c1"))
);
// With no container, the snapshot is the whole decision, so its failure
// is reported — and never as "no container or snapshot image yet",
// which nothing has established.
let e = pick_probe_source(&ContainerState::Absent, &daemon()).unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
assert_ne!(e, NOTHING_TO_PROBE);
}
#[test]
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
assert_eq!(
stopped_probe_policy(false, &Ok(false)),
StoppedProbe::Commit
);
assert_eq!(stopped_probe_policy(false, &Ok(true)), StoppedProbe::Commit);
// Not the snapshot's business either way when the project is free: an
// unreadable `image_exists` does not stop the commit that would not
// have consulted it.
assert_eq!(stopped_probe_policy(false, &daemon()), StoppedProbe::Commit);
}
#[test]
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
// The snapshot lags, but a stale answer beats failing someone's Start.
assert_eq!(
stopped_probe_policy(true, &Ok(true)),
StoppedProbe::SnapshotInstead
);
// Nothing to fall back to: say so instead of committing anyway.
assert_eq!(
stopped_probe_policy(true, &Ok(false)),
StoppedProbe::Defer(PROJECT_BUSY.to_string())
);
// Busy *and* the fallback could not be read: "try again once it
// finishes" would promise that waiting is all that stands in the way,
// which the failed reading has not established. Report what happened.
match stopped_probe_policy(true, &daemon()) {
StoppedProbe::Defer(message) => {
assert!(message.contains("connection refused"), "{}", message);
assert_ne!(message, PROJECT_BUSY);
}
other => panic!("expected Defer, got {:?}", other),
}
}
#[test]
fn an_unreachable_daemon_is_never_read_as_an_absent_container() {
// The bug: every one of these used to be flattened to "no" by an
// `unwrap_or`, which reached `pick_probe_source` as "no container, no
// snapshot" and reported "no container or snapshot image yet" about a
// project nobody had managed to look at.
let e = collect_probe_inputs(ProbeReadings {
container_id: daemon(),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
assert_ne!(e, NOTHING_TO_PROBE);
let e = collect_probe_inputs(ProbeReadings {
base_image_id: daemon(),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(daemon()),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
// The fourth reading is not fatal here — see
// `an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer`
// — but it must still arrive as an error rather than as "no snapshot".
let inputs = collect_probe_inputs(ProbeReadings {
snapshot_exists: daemon(),
..readings()
})
.unwrap();
assert!(inputs.snapshot_exists.is_err());
let e = pick_probe_source(&inputs.container, &inputs.snapshot_exists).unwrap_err();
assert_ne!(e, NOTHING_TO_PROBE);
}
#[test]
fn a_base_image_that_could_not_be_read_is_never_reported_as_up_to_date() {
// `image_id` answers `Ok(None)` for "not pulled locally", which is a
// legitimate `stale: false`. An `Err` is not: it is the right-hand side
// of the comparison missing, and letting it through as `None` would
// report the project up to date on the strength of a reading nobody
// got. This is #56 one field over, so it is fatal on purpose.
let e = collect_probe_inputs(ProbeReadings {
base_image_id: Err("invalid reference format".into()),
container_id: Ok(Some("c1".into())),
container_running: Some(Ok(true)),
snapshot_exists: Ok(true),
})
.unwrap_err();
assert!(e.contains("invalid reference format"), "{}", e);
}
#[test]
fn a_failed_reading_is_not_blamed_on_a_daemon_that_answered() {
// Three of the four readings return `Err` from a daemon that replied
// perfectly well: `image_id` maps only a 404 to `Ok(None)`, and the two
// list-based readings wrap any failure. The base image name is
// user-supplied, so a typo in settings lands here — and used to be
// reported as "Docker could not be reached", sending the user to fix a
// daemon that was running.
// The payload is the shape bollard really produces for this case, and
// it contains the word "Docker" itself — so asserting the *message*
// lacks that word would pass here only because a synthetic payload was
// chosen. What must be true is that nothing *we* add claims the daemon
// was unreachable, or names the container when the reading was about
// the base image.
let raw = "Docker responded with status code 400: invalid reference format";
let e = collect_probe_inputs(ProbeReadings {
base_image_id: Err(raw.into()),
..readings()
})
.unwrap_err();
assert!(!e.contains("could not be reached"), "{}", e);
assert!(!e.contains("container"), "{}", e);
// The cause still comes through verbatim: "Docker isn't running" and
// "permission denied on the socket" need different fixes and must stay
// distinguishable.
assert!(e.contains(raw), "{}", e);
}
#[test]
fn the_first_daemon_error_is_the_one_reported() {
// When the daemon is down these fail together, and the user needs the
// reason once rather than three times. Call order wins, and
// `container_id` leads because it is what selects the probe source.
//
// At most three fail, not four: `container_running` is only attempted
// when `container_id` answered with a container, so the caller cannot
// produce an `Err` container id alongside a `Some(..)` running reading.
let e = collect_probe_inputs(ProbeReadings {
container_id: Err("first".into()),
container_running: None,
base_image_id: Err("second".into()),
snapshot_exists: Err("third".into()),
})
.unwrap_err();
assert!(e.ends_with("first"), "{}", e);
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(Err("third".into())),
base_image_id: Err("second".into()),
snapshot_exists: Err("fourth".into()),
})
.unwrap_err();
assert!(e.ends_with("second"), "{}", e);
}
#[test]
fn a_container_id_cannot_arrive_without_a_reading_of_its_state() {
// `ContainerState` makes "running, but no container" unrepresentable;
// this is the other half — a container found, but never asked about.
// The caller cannot produce it, and guessing "stopped" would cost a
// running project the only probe source that sees this session's
// installs.
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: None,
..readings()
})
.unwrap_err();
assert!(e.contains("state was not read"), "{}", e);
}
#[test]
fn a_daemon_that_answers_no_is_an_answer_and_passes_through() {
// No container, no snapshot, base image not pulled: all the readings
// are `Ok`, and the "nothing to probe" path downstream is then
// genuinely earned.
let inputs = collect_probe_inputs(readings()).unwrap();
assert_eq!(inputs.current_base_image_id, None);
assert_eq!(inputs.container, ContainerState::Absent);
assert_eq!(inputs.snapshot_exists, Ok(false));
assert_eq!(
pick_probe_source(&inputs.container, &inputs.snapshot_exists),
Ok(ProbeSource::Nothing)
);
// And the fully populated reading survives intact.
let inputs = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(Ok(true)),
base_image_id: Ok(Some("sha256:base".into())),
snapshot_exists: Ok(true),
})
.unwrap();
assert_eq!(inputs.current_base_image_id.as_deref(), Some("sha256:base"));
assert_eq!(inputs.container, present(true));
assert_eq!(inputs.snapshot_exists, Ok(true));
}
#[test]
fn a_failed_reading_keeps_the_banner_on_screen_instead_of_erroring() {
// The load-bearing design decision of this path: a failed reading is a
// report with `probe_error` set, never an `Err` out of the command. An
// `Err` reaches the hook's `catch`, which nulls `staleness`, and
// `ContainerMigrationBanner` renders nothing at all for a null one — so
// the banner would vanish at exactly the moment it has something to say.
match start_probe(ProbeReadings {
container_id: daemon(),
..readings()
}) {
ProbeStart::Report(report) => {
let message = report.probe_error.clone().expect("probe_error");
assert!(message.contains("connection refused"), "{}", message);
// Everything else at its default: a field being empty means
// "nothing found", and nothing was found because nothing was
// read. `stale: false` here is the absence of a claim, which is
// only honest because `probe_error` is carrying the reason.
assert_eq!(
*report,
ContainerStaleness {
probe_error: Some(message),
..Default::default()
}
);
}
ProbeStart::Inputs(_) => panic!("a failed reading must not be probed on"),
}
// And a healthy set of readings still goes on to probe.
assert!(matches!(start_probe(readings()), ProbeStart::Inputs(_)));
}
#[test]
fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B");
+1
View File
@@ -3,6 +3,7 @@ pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod file_viewer_commands;
pub mod gateway_commands;
pub mod help_commands;
pub mod inspect_commands;
+131 -27
View File
@@ -1036,7 +1036,6 @@ fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc
#[tauri::command]
pub async fn update_project(
project: serde_json::Value,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
// Taken as raw JSON, then deserialised, for one reason: a secret field that
@@ -1098,37 +1097,57 @@ pub async fn update_project(
// [`crate::models::validate_env_vars_update`].
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
project.container_id = stored.container_id;
project.status = stored.status;
project.created_at = stored.created_at;
restore_store_owned_fields(&mut project, &stored);
project.updated_at = chrono::Utc::now().to_rfc3339();
store_secrets_for_project(&project, &explicitly_cleared)?;
let updated = state.projects_store.update(project)?;
// `auth_bridge_enabled` can arrive through this generic save as well as
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
// when nothing is running, so this is safe on every project save.
if updated.auth_bridge_enabled {
if let Some(ref container_id) = updated.container_id {
if docker::is_container_running(container_id).await.unwrap_or(false) {
state
.auth_bridge
.start(
updated.id.clone(),
container_id.clone(),
app_handle,
state.projects_store.clone(),
)
.await;
}
}
} else {
state.auth_bridge.stop(&updated.id).await;
}
// Nothing reconciles the *running* auth bridge here any more, and there is
// nothing left for such a step to do. This command can no longer change
// `auth_bridge_enabled` at all (see [`restore_store_owned_fields`]), so a
// reconcile could only ever re-assert what was already true. The paths that
// do change it each own their own side effect: `set_auth_bridge_enabled`
// starts or stops the bridge itself, [`start_project_container`] arms it
// when the container comes up, and `reconcile_project_statuses` re-arms it
// for every already-running container at launch. The version of this that
// re-asserted on every save is what turned a stale flag in a payload into a
// restarted bridge.
state.projects_store.update(project)
}
Ok(updated)
/// Restore onto `project` the fields whose value belongs to the store rather
/// than to whoever is saving the project. See the comment above `stored` in
/// [`update_project`] for `container_id`, `status` and `created_at`.
///
/// **Both feature flags are in here, for one reason that covers them equally:
/// neither ever arrives through this command as an edit.** Each has a
/// dedicated setter — [`crate::browser_view::commands::set_browser_view_enabled`]
/// and [`crate::commands::auth_bridge_commands::set_auth_bridge_enabled`] —
/// and that setter is the only control the UI offers for it. Neither is wired
/// into the Config tab's `save`: the browser view's toggle lives in the Browser
/// tab, and `AuthBridgeRow`'s switch calls `set_auth_bridge_enabled` directly
/// even though it is rendered *in* the Config tab, because that tab's editors
/// are disabled while the container runs and the bridge is precisely the thing
/// a user needs to flip while a login is hanging.
///
/// So the flags in an incoming payload are never a choice — they are whatever
/// the frontend was told when it loaded the project, and the setters do not
/// write their new value back into frontend app state. Every unrelated save
/// (a renamed session, an env var, a mount name) carries that snapshot back.
/// Taking it would silently undo a toggle made since.
///
/// This restored only `browser_view_enabled` before, on the stated belief that
/// the Config tab edited `auth_bridge_enabled` through this save. It does not.
/// The consequence was specific: a user turns the bridge off — having been told
/// a bridged port is unauthenticated and reachable by any local process — then
/// closes a renamed terminal tab, and the stale `true` in that save re-persisted
/// and restarted the bridge.
fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
project.container_id = stored.container_id.clone();
project.status = stored.status.clone();
project.browser_view_enabled = stored.browser_view_enabled;
project.auth_bridge_enabled = stored.auth_bridge_enabled;
project.created_at = stored.created_at.clone();
}
#[tauri::command]
@@ -2186,4 +2205,89 @@ mod tests {
// Changing it to a different root is a change, and refused.
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
}
// ── Fields a generic save does not get to write ───────────────────────
/// A project as the store holds it, plus the copy the frontend is about to
/// save back: same record, one unrelated edit, and the flags as they were
/// when the frontend last loaded it.
fn stored_and_stale_payload() -> (Project, Project) {
let mut stored = Project::new("demo".to_string(), Vec::new());
stored.container_id = Some("abc123".to_string());
stored.status = ProjectStatus::Running;
let mut payload = stored.clone();
payload.container_id = None;
payload.status = ProjectStatus::Stopped;
payload
.renamed_session_names
.insert("s1".to_string(), "build".to_string());
(stored, payload)
}
/// The regression. The user turns the auth bridge off — the switch calls
/// `set_auth_bridge_enabled`, which persists `false` and stops the bridge,
/// and writes nothing back into the frontend's copy of the project. Every
/// holder of that copy still has `auth_bridge_enabled: true`, and the next
/// unrelated save (closing a renamed terminal tab) posts it back. That save
/// must not re-enable the bridge.
#[test]
fn a_stale_auth_bridge_flag_in_a_save_cannot_re_enable_a_disabled_bridge() {
let (mut stored, mut payload) = stored_and_stale_payload();
stored.auth_bridge_enabled = false;
payload.auth_bridge_enabled = true;
restore_store_owned_fields(&mut payload, &stored);
assert!(
!payload.auth_bridge_enabled,
"a save must not be able to turn the bridge back on: the stored value is the user's"
);
// The edit the save was actually for still goes through.
assert_eq!(
payload.renamed_session_names.get("s1").map(String::as_str),
Some("build")
);
}
/// The mirror image, and the reason the serde default going to `true`
/// made this worse: a pre-existing record with no `auth_bridge_enabled`
/// key reads as enabled, so the stale payload is `true` for every project
/// that predates the field. A user who has *not* turned the bridge off is
/// equally entitled to have the store's answer win.
#[test]
fn an_enabled_bridge_is_left_enabled_by_the_same_rule() {
let (mut stored, mut payload) = stored_and_stale_payload();
stored.auth_bridge_enabled = true;
payload.auth_bridge_enabled = false;
restore_store_owned_fields(&mut payload, &stored);
assert!(payload.auth_bridge_enabled);
}
/// The flag that was already restored, kept under test beside the one that
/// was not — the two are owned by their setters for the same reason and
/// must not drift apart again.
#[test]
fn a_stale_browser_view_flag_cannot_undo_the_panes_toggle_either() {
let (mut stored, mut payload) = stored_and_stale_payload();
stored.browser_view_enabled = true;
payload.browser_view_enabled = false;
restore_store_owned_fields(&mut payload, &stored);
assert!(payload.browser_view_enabled);
}
#[test]
fn the_container_handle_status_and_creation_time_still_come_from_the_store() {
let (stored, mut payload) = stored_and_stale_payload();
restore_store_owned_fields(&mut payload, &stored);
assert_eq!(payload.container_id.as_deref(), Some("abc123"));
assert_eq!(payload.status, ProjectStatus::Running);
assert_eq!(payload.created_at, stored.created_at);
}
}
+187 -27
View File
@@ -6,10 +6,58 @@ use crate::AppState;
/// Build the command to run in the container terminal.
///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
/// before `exec claude`. For Bedrock Profile projects the script additionally
/// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
)
}
/// Shell line run immediately before `exec claude` in every Claude terminal
/// session.
///
/// `container/entrypoint.sh` already runs `claude update` when the container
/// starts, but containers here use a stop/start (and often just keep running)
/// model, so a long-lived container's CLI goes stale between restarts. Running
/// it per session is what keeps a week-old container current.
///
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
/// network, npm registry down) so a session always opens, and `timeout 60`
/// bounds how long a user waits for a terminal.
///
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
/// the entrypoint's own `claude update`, and the entrypoint prints "container
/// ready" only *after* its copy finishes — so "start the project, open a tab"
/// races two updaters against the same `~/.claude/bin` install, as does
/// opening two tabs at once. `|| echo` would then hide a half-written install
/// behind a friendly message and the very next line (`exec claude`) would run
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
/// than failing the wait, and `-E 0` makes losing the race a success: the
/// other holder just updated, so there is nothing left to do.
pub(crate) const UPDATE_PRELUDE: &str = concat!(
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
);
/// Single-quote one argument for interpolation into a shell script string.
fn shell_quote_arg(arg: &str) -> String {
format!(" '{}'", arg.replace('\'', "'\\''"))
}
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
let permission_args = project.effective_permission_mode().cli_args();
// The args are interpolated into a shell script string, so single-quote
// each one.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default();
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
@@ -75,9 +114,11 @@ else
echo ""
fi
fi
{update_prelude}
{claude_cmd}
"#,
profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd
);
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
use crate::models::Project;
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
@@ -338,6 +382,122 @@ mod tests {
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
/// A `Project` with only the fields these tests care about set; the rest
/// come through serde so the test does not have to track every field.
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
serde_json::from_value(serde_json::json!({
"id": "p1",
"name": "Test",
"paths": [],
"container_id": null,
"status": "running",
"backend": backend,
"bedrock_config": bedrock_config,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"full_permissions": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"created_at": "now",
"updated_at": "now"
}))
.expect("test project deserializes")
}
/// Every Claude session updates the CLI before launching it.
///
/// `container/entrypoint.sh` only updates at container *start*, and these
/// containers are long-lived, so a stale CLI is the normal case without
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
assert!(
cmd[2].contains(UPDATE_PRELUDE),
"plain path must run the update prelude: {}",
cmd[2]
);
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
// The update has to happen *before* the exec, which never returns.
assert!(
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
"prelude must precede the exec: {}",
cmd[2]
);
assert!(
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
"the update must stay time-bounded and non-fatal"
);
}
/// The session name is interpolated into a shell script, so a quote in it
/// must not break out of its single-quoted argument.
#[test]
fn build_terminal_cmd_escapes_a_quoted_session_name() {
let cmd = build_claude_terminal_cmd(
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
);
assert!(
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
"session name must be single-quote escaped: {}",
cmd[2]
);
}
/// Permission flags travel the same escaped path, and an empty name adds
/// no `-n` at all.
#[test]
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
let cmd = build_claude_terminal_cmd(
&project("bedrock", serde_json::json!({
"auth_method": "profile",
"aws_region": "us-east-1",
"aws_profile": "acme",
"model_id": null,
"disable_prompt_caching": false
})),
None,
Some("it's fine"),
);
assert_eq!(cmd[0], "bash");
let script = &cmd[2];
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
assert!(
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
"prelude must precede the exec: {}",
script
);
}
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name;
+140 -4
View File
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
.collect()
}
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
/// container makes. The reaper's only handle on a leftover — see
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
/// The throwaway image a staleness probe of a **stopped** container commits to.
///
/// **Unique per call**, and both halves of the name earn their place: the
/// container id prefix makes a leftover traceable in `docker images`, and the
/// counter makes two overlapping probes independent.
///
/// An earlier version of this was deliberately *stable* per container, on the
/// theory that the next probe would move the tag off an abandoned image and
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
/// over. A container id does not survive a recreate, so for most leftovers
/// there is no "next probe of the same container" and the image was stranded
/// permanently; and a stable name made two concurrent probes fight over one
/// tag, where whichever finished first force-removed the image the other was
/// still reading and turned a healthy project into a bogus `probe_error`.
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
/// is what collects the leftovers instead.
pub fn get_probe_image_name(container_id: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let short: String = container_id.chars().take(12).collect();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!(
"{}{}-{}-{}:latest",
PROBE_IMAGE_PREFIX,
short,
nanos,
SEQ.fetch_add(1, Ordering::Relaxed)
)
}
/// Commit a **stopped** container's filesystem to a throwaway image, returning
/// its name. The caller owns the image and must remove it.
///
/// This exists so a stopped project can be read at all. `docker exec` needs a
/// running container and the snapshot image is not a checkpoint — see
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
/// this there is no way to see inside a project that is merely stopped.
///
/// ## Why it is tagged at all
///
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
/// that `docker commit` copies off the container is exactly the pair
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
/// `commit_container` hands back `id: None` every time and there is no
/// reference left to probe. Neither existing commit site notices, because both
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
///
/// So the image needs a name, a tagged image is not dangling, and the sweep
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
/// is, and [`get_probe_image_name`] carries the rest of that argument.
///
/// ## What is in the image, and what is not
///
/// `pause: false` because nothing is running — pausing a stopped container is
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
/// passes `false`.
///
/// Secrets are blanked from the env for the same reason
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
/// full ENV into the image, and "it only lives a few seconds" is not a property
/// this function can promise after a crash.
///
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
/// because a commit stacks a layer and never rewrites one — but that scrub is a
/// `docker exec`, which is exactly what a stopped container cannot serve, and
/// scrubbing is not wanted anyway: the probe's whole job is to report the
/// filesystem as it actually is. What makes it acceptable is that this copies
/// bytes that are *already on this disk* in the container's own writable layer,
/// into an image that is never pushed, never created from, and reaped — so it
/// duplicates data inside one trust domain rather than widening it. That
/// argument depends on the reaping actually happening; treat
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
let docker = get_docker()?;
let image_name = get_probe_image_name(container_id);
let (repo, tag) = image_name
.rsplit_once(':')
.map(|(r, t)| (r.to_string(), t.to_string()))
.expect("get_probe_image_name always emits a tag");
docker
.commit_container(
CommitContainerOptions {
container: container_id.to_string(),
repo,
tag,
pause: false,
..Default::default()
},
Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
Ok(image_name)
}
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
/// name in [`SECRET_ENV_KEYS`].
fn env_holds_a_secret(env: &[String]) -> bool {
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
remove_image_by_name(&get_snapshot_image_name(project)).await
}
/// Remove a Docker image by name/tag, treating "does not exist" as success.
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
/// only has the image name (the project record is already gone by then).
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
/// (which only has the image name the project record is already gone by
/// then), and the staleness probe's throwaway commit, which has only an id.
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
let docker = get_docker()?;
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
.await
{
Ok(_) => {
log::info!("Removed snapshot image {}", image_name);
log::info!("Removed image {}", image_name);
Ok(())
}
Err(bollard::errors::Error::DockerResponseServerError {
@@ -4464,6 +4577,29 @@ mod tests {
assert!(env_holds_a_secret(&env));
}
/// The probe image's name must be **unique per call**. A stable name was
/// tried and is wrong twice over: a container id does not survive a
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
/// next probe of the same container"; and two concurrent probes sharing one
/// tag means whichever finishes first force-removes the image the other is
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
#[test]
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
let id = "75993e6d5e1ab473b029a408c5ff0339";
let a = get_probe_image_name(id);
let b = get_probe_image_name(id);
assert_ne!(a, b, "two probes of one container must not share a tag");
// The prefix is the reaper's only handle on a leftover, so every name
// has to carry it — and it must not be the snapshot namespace, which is
// what a project is rebuilt from.
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
// Traceable back to its container, which is the point of the prefix.
assert!(a.contains("75993e6d5e1a"), "{}", a);
assert!(a.ends_with(":latest"), "{}", a);
}
#[test]
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
let clean = SnapshotScrubReport {
+461
View File
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
}
}
/// Remove throwaway images left behind by a staleness probe of a stopped
/// container — [`super::container::commit_container_for_probe`]'s commits.
///
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
/// gives no image id back from a commit and there has to be something to probe.
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
/// — which collects every other kind of orphan this app can leave — will never
/// see one. Without this, a probe that dies between its commit and its own
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
/// UI to find it either. That is the one leak in this app with no floor on it,
/// so this runs at startup beside [`reap_probe_containers`].
///
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
/// filter, so a second copy of the app probing a project on the same daemon has
/// images matching this glob, and removing one mid-capture fails that probe with
/// "No such image" — the bogus `probe_error` the staleness work exists to get
/// rid of. In-process state cannot see the other instance, so age is the only
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
/// `find` over a root filesystem, not a multi-minute job.
///
/// Never fails the caller. Housekeeping, like every other sweep here.
pub async fn reap_probe_images() {
use bollard::image::{ListImagesOptions, RemoveImageOptions};
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
log::warn!("Could not reap leftover probe images: {}", e);
return;
}
};
let filters = HashMap::from([(
"reference".to_string(),
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
log::warn!("Could not list leftover probe images: {}", e);
return;
}
};
let now = chrono::Utc::now().timestamp();
for image in images {
// Unlike a container summary, an image summary always carries a
// `Created`, so there is no unknown-age case to defend against here.
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
log::info!(
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
to another Triple-C instance's live probe",
image.repo_tags,
PROBE_REAP_MIN_AGE_SECS / 60
);
continue;
}
// By **tag**, never by image id. A `force` removal by id untags an
// image everywhere, so an id that happens to carry another name loses
// that name too — which is how a test fixture that tagged
// `alpine:latest` into this namespace deleted the user's alpine. A real
// leftover has exactly the one probe tag, so removing the tag removes
// the image; anything else keeps whatever other names it has.
for tag in image
.repo_tags
.iter()
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
{
log::info!("Removing leftover probe image {}", tag);
if let Err(e) = docker
.remove_image(
tag,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
{
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
}
}
}
}
/// How old a `triple-c.probe=migration` container must be before
/// [`reap_probe_containers`] will force-remove it, in seconds.
///
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
Ok(parse_manifest(&out))
}
/// Cached stopped-container manifests, keyed by container id, each paired with
/// the container's `FinishedAt` at the time it was captured.
///
/// **Sound because a stopped container's writable layer cannot change.** Nothing
/// can write to it while it is not running, so a manifest captured after it
/// stopped stays true until it is started again — and `FinishedAt` moves on
/// every stop, which is what makes the key exact rather than merely plausible.
///
/// This exists because `get_container_staleness` is called from a `useEffect`
/// that fires whenever the container settles, so simply opening a stopped
/// project's Overview probes it. Uncached that meant a `docker commit` of the
/// whole writable layer per visit — measured at 44 s on a real project — where
/// before this feature the same visit cost one throwaway container or nothing at
/// all. A regression like that is not worth the answer it buys.
///
/// Capped, because a `Manifest` of a real container is a few MB: this only has
/// to serve "the project whose page is open", so a handful of entries is the
/// whole working set and the oldest is dropped past that.
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
Option<Vec<(String, String, Manifest)>>,
> = std::sync::Mutex::new(None);
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
/// `FinishedAt` for a container, the cache's validity token. `None` when it
/// cannot be read, which is never treated as a hit.
async fn container_finished_at(container_id: &str) -> Option<String> {
let docker = get_docker().ok()?;
docker
.inspect_container(container_id, None)
.await
.ok()?
.state?
.finished_at
.filter(|s| !s.is_empty())
}
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
/// when the container has not been started since it was taken.
///
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
pub async fn manifest_from_stopped_container_cached(
container_id: &str,
) -> Result<Manifest, String> {
let finished_at = container_finished_at(container_id).await;
if let Some(token) = &finished_at {
let guard = STOPPED_MANIFEST_CACHE.lock();
if let Ok(cache) = guard {
if let Some(entries) = cache.as_ref() {
if let Some((_, _, manifest)) = entries
.iter()
.find(|(id, tok, _)| id == container_id && tok == token)
{
log::debug!(
"Reusing the cached manifest for stopped container {}",
container_id
);
return Ok(manifest.clone());
}
}
}
}
let manifest = manifest_from_stopped_container(container_id).await?;
// Only cacheable if the container's state could be read at all; an unknown
// `FinishedAt` means there is no token that could later be compared.
if let Some(token) = finished_at {
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
let entries = cache.get_or_insert_with(Vec::new);
entries.retain(|(id, _, _)| id != container_id);
entries.push((container_id.to_string(), token, manifest.clone()));
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
entries.remove(0);
}
}
}
Ok(manifest)
}
/// Capture a [`Manifest`] from a **stopped** container.
///
/// Commits the container's writable layer to a throwaway image, probes that,
/// and removes it. This is as current as [`manifest_from_container`] — it reads
/// the same filesystem — and it is why a stopped project no longer has to fall
/// back to its snapshot image, which may not exist at all and lags the
/// container by everything installed since the last commit when it does.
///
/// The image is removed on every path, including a failed probe. See
/// [`super::container::commit_container_for_probe`] for what a crash in the
/// window between the two costs, and why it is bounded.
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
let image = super::container::commit_container_for_probe(container_id).await?;
let manifest = manifest_from_image(&image)
.await
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
if let Err(e) = super::container::remove_image_by_name(&image).await {
log::warn!(
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
collects it at the next app start; the orphan sweep never will, because it is tagged",
image,
e
);
}
manifest
}
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
///
/// Deliberately the **ID**, not a repo digest: locally built images and custom
@@ -2146,4 +2353,258 @@ mod tests {
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
assert!(!pin_is_reapable("latest", false, ancient, &now));
}
// ── Live Docker ─────────────────────────────────────────────────────────
/// The cache serves a second read of an unchanged stopped container, and —
/// the half that matters — stops serving it the moment the container is
/// started and stopped again. If invalidation were wrong this would report a
/// filesystem the project no longer has, and a migration would be planned
/// against it.
///
/// ```text
/// cargo test -- --ignored --nocapture stopped_manifest_cache
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
fn docker_cli(args: &[&str]) -> String {
let out = std::process::Command::new("docker")
.args(args)
.output()
.expect("docker CLI");
assert!(
out.status.success(),
"docker {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
let first = format!("/opt/cache-marker-a-{}", std::process::id());
let second = format!("/opt/cache-marker-b-{}", std::process::id());
let id = docker_cli(&[
"run", "-d", "--label", "triple-c.managed=true",
"--entrypoint", "/bin/sh",
&image, "-c", "sleep 600",
]);
let cleanup = || {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", &id])
.output();
};
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
docker_cli(&["stop", "-t", "1", &id]);
let t0 = std::time::Instant::now();
let cold = manifest_from_stopped_container_cached(&id).await;
let cold_ms = t0.elapsed().as_millis();
let t1 = std::time::Instant::now();
let warm = manifest_from_stopped_container_cached(&id).await;
let warm_ms = t1.elapsed().as_millis();
// Restart, change the filesystem, stop again — `FinishedAt` moves.
docker_cli(&["start", &id]);
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
docker_cli(&["stop", "-t", "1", &id]);
let after_restart = manifest_from_stopped_container_cached(&id).await;
cleanup();
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
let cold = cold.expect("cold read");
let warm = warm.expect("warm read");
let after_restart = after_restart.expect("read after restart");
assert!(has(&cold, &first), "cold read missed {}", first);
assert!(has(&warm, &first), "warm read missed {}", first);
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
assert!(
warm_ms * 5 < cold_ms.max(5),
"the second read cost {} ms against a cold {} ms — it re-committed \
instead of using the cache",
warm_ms,
cold_ms
);
// The restart must have invalidated it: the new directory has to show up.
assert!(
has(&after_restart, &second),
"a restart did not invalidate the cache — {} is missing, so this is \
a stale manifest of a filesystem the container no longer has",
second
);
assert!(has(&after_restart, &first), "the restart lost {}", first);
}
/// The reaper finds a leftover probe image by prefix and — crucially —
/// refuses to remove a young one, because that image may be another
/// Triple-C instance's live probe. Only a real daemon can say whether the
/// `reference=` glob matches the names `get_probe_image_name` produces.
///
/// The fixture is **committed**, not tagged and not built. An image's
/// `Created` is its own, not its tag's, so tagging something already on disk
/// into this namespace yields a fixture the reaper is right to call ancient
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
/// one looks ancient too. A commit stamps *now*, verified against Engine
/// 29.6, which is also how real probe images get their age.
///
/// Both of those mistakes were made here first, and one of them deleted an
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
/// tag rather than by image id.
///
/// ```text
/// cargo test -- --ignored --nocapture reaper_spares
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
use std::process::Command;
fn docker_out(args: &[&str]) -> std::process::Output {
Command::new("docker").args(args).output().expect("docker CLI")
}
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "alpine:latest".to_string());
let name = crate::docker::container::get_probe_image_name("reapertest01234");
// A never-started container is enough to commit from, and leaves the
// daemon's run state alone entirely.
let created = docker_out(&["create", &base, "true"]);
assert!(
created.status.success(),
"could not create the fixture container from {}: {}",
base,
String::from_utf8_lossy(&created.stderr)
);
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
let _ = docker_out(&["rm", "-f", &cid]);
assert!(
committed.status.success(),
"could not commit the fixture image: {}",
String::from_utf8_lossy(&committed.stderr)
);
reap_probe_images().await;
let still_there = Command::new("docker")
.args(["image", "inspect", &name])
.output()
.expect("docker image inspect")
.status
.success();
let _ = Command::new("docker").args(["rmi", &name]).output();
assert!(
still_there,
"a probe image committed seconds ago was reaped — that is another \
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
);
}
/// A *stopped* container is readable, and what comes back is its writable
/// layer rather than the image it was created from. This is the whole point
/// of the function: the base image cannot answer it, and the project may
/// well have no snapshot image at all.
///
/// Also asserts the throwaway commit leaves nothing behind, which no unit
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
/// the probe image is *tagged*, so a leak never shows up as a dangling
/// image and a dangling-set assertion here would pass either way.
///
/// Ignored because it needs Docker and commits a container; run it with
///
/// ```text
/// cargo test -- --ignored --nocapture stopped_container
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
async fn a_stopped_container_is_read_from_its_writable_layer() {
fn docker_cli(args: &[&str]) -> String {
let out = std::process::Command::new("docker")
.args(args)
.output()
.expect("docker CLI");
assert!(
out.status.success(),
"docker {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn probe_images() -> Vec<String> {
let mut ids: Vec<String> = docker_cli(&[
"images", "-q",
"--filter",
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
])
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
ids.sort();
ids
}
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
let marker = format!("/opt/probe-marker-{}", std::process::id());
// Another instance's live probe images are allowed to exist; what must
// hold is that this probe adds none of its own.
let before = probe_images();
let id = docker_cli(&[
"run", "-d", "--label", "triple-c.managed=true",
"--entrypoint", "/bin/sh",
&image, "-c", "sleep 300",
]);
let cleanup = |id: &str| {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", id])
.output();
};
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
docker_cli(&["stop", "-t", "1", &id]);
let result = manifest_from_stopped_container(&id).await;
cleanup(&id);
let manifest = result.expect("a stopped container must be probeable");
assert!(
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
"the probe read the image, not the container's writable layer: {} missing",
marker
);
// Non-empty package sets prove the probe script really ran, rather than
// parsing an empty transcript into an empty-but-Ok manifest.
assert!(
!manifest.apt_manual.is_empty(),
"apt-mark showmanual came back empty, so the probe did not run"
);
assert_eq!(
probe_images(),
before,
"the throwaway probe image was not cleaned up"
);
}
}
+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);
}
}
+154 -42
View File
@@ -1,12 +1,16 @@
mod auth_bridge;
mod browser_view;
#[cfg(test)]
mod command_census;
mod commands;
mod docker;
pub mod file_viewer;
mod install_helper;
mod logging;
mod models;
mod project_lock;
mod storage;
pub mod url_open;
pub mod web_terminal;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -239,6 +243,7 @@ pub fn run() {
lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
})
.manage(file_viewer::registry::ViewerRegistry::default())
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
@@ -263,12 +268,20 @@ pub fn run() {
// logged warning rather than a failed start.
//
// Ordering matters. Probes are removed first because a probe holds
// an image open and the sweep will not force; pins are untagged
// an image open and the sweep will not force — both the probe
// containers and the probe images, the latter being the one orphan
// the sweep can never reach on its own; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
let projects_store_for_cleanup = projects_store_setup.clone();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
// Probe *images* too, and for a sharper reason: a probe
// container merely pins an image the sweep then refuses to
// touch, whereas a leftover probe image is tagged and so
// nothing else in this app can ever collect it. See
// `reap_probe_images`.
crate::docker::reap_probe_images().await;
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
@@ -536,6 +549,13 @@ pub fn run() {
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
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
commands::aws_commands::aws_sso_refresh,
// Updates
@@ -544,6 +564,9 @@ pub fn run() {
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
// Opening a link in the host browser (see `url_open` for why this
// is not `@tauri-apps/plugin-opener` on Linux)
url_open::open_url_external,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
@@ -823,30 +846,11 @@ mod tests {
&mut defined,
);
// The registration list, read from this file rather than from a macro
// expansion so the test does not depend on `generate_handler!`'s shape.
let this = include_str!("lib.rs");
let handler = this
.split_once("generate_handler![")
.and_then(|(_, rest)| rest.split_once("])"))
.map(|(inside, _)| inside)
// The registration list, read from this file by the same parser `build.rs` uses to
// declare the AppManifest — so if this test can see a command, the ACL can too.
let ordered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
// Line-based, not `split(',')`: the list is grouped under `// Docker`
// 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();
let registered: BTreeSet<String> = ordered.iter().cloned().collect();
assert!(
!defined.is_empty() && !registered.is_empty(),
@@ -875,21 +879,11 @@ mod tests {
// passed here.
let mut seen: Vec<&str> = Vec::new();
let mut duplicated: Vec<&str> = Vec::new();
for line in handler
.lines()
.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);
} else {
seen.push(name);
}
for name in &ordered {
if seen.contains(&name.as_str()) {
duplicated.push(name);
} else {
seen.push(name);
}
}
assert!(
@@ -918,7 +912,10 @@ mod tests {
})
.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();
let mut expected = vec![
"core:event:allow-listen",
@@ -926,16 +923,35 @@ mod tests {
"core:webview:allow-internal-toggle-devtools",
"dialog:allow-open",
"dialog:allow-save",
"opener:allow-open-url",
];
expected.sort();
assert_eq!(
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 \
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,
// because they expand to a set the file never spells out. `store:*` in
// particular was an arbitrary host-file read/write primitive.
@@ -953,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:?}"
);
}
}
+12
View File
@@ -63,6 +63,12 @@
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause.
///
/// That leak is now plugged rather than merely documented: `url_open` hands
/// the opener a child environment with this variable (and the AppImage's own
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
/// children no longer inherit it.
#[cfg(target_os = "linux")]
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
@@ -138,6 +144,12 @@ mod tests {
}
fn main() {
// Before *any* `std::env::set_var` — `url_open` hands a child process the
// environment this app was started with, and the workaround below is one
// of the things that must not leak into it (see triple-c#34). Anything
// added here that mutates the environment belongs after this line.
triple_c_lib::url_open::capture_pristine_environment();
#[cfg(target_os = "linux")]
apply_webkit_wayland_workaround();
+103 -5
View File
@@ -132,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
true
}
/// `auth_bridge_enabled` defaults to **on**, and the default is what makes
/// `claude login` work at all.
///
/// The login flow binds a *random* ephemeral loopback port inside the
/// container and then sends the host's browser to `127.0.0.1:<that port>`.
/// On the host nothing is listening there, so the callback lands on a closed
/// port and the CLI waits for a redirect that can never arrive. The bridge
/// mirrors the container's loopback listeners onto the same host port, which
/// is the only thing that closes that loop — so off-by-default made a hang the
/// out-of-the-box experience.
///
/// Returning `true` from a `#[serde(default)]` helper (rather than flipping the
/// constructor alone) is deliberate: existing `projects.json` records were
/// written before this field existed, or while it was off, and an absent key is
/// what the default is read for. A project that wants the old behaviour turns
/// the toggle off, which persists an explicit `false`.
fn default_auth_bridge_enabled() -> bool {
true
}
/// How much autonomy Claude Code is granted inside the container.
///
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
@@ -336,17 +356,30 @@ pub struct Project {
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool,
/// Opt in to the auth bridge: while the container runs, its loopback
/// listeners are mirrored onto the host's loopback so browser OAuth
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
/// The auth bridge: while the container runs, its loopback listeners are
/// mirrored onto the host's loopback so browser OAuth callbacks
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
/// Purely host-side — it deliberately has no container-recreation label,
/// because toggling it changes nothing about the container itself.
#[serde(default)]
///
/// **On by default**, and opt-*out* rather than opt-in — see
/// [`default_auth_bridge_enabled`] for why the default is the feature.
#[serde(default = "default_auth_bridge_enabled")]
pub auth_bridge_enabled: bool,
/// Opt in to the browser-view pane, which watches and takes over the
/// browser Claude drives with Playwright inside the container. Purely
/// host-side like `auth_bridge_enabled`, so it likewise has no
/// container-recreation label.
///
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
/// rather than keeping its own copy, so the pane comes back the way it was
/// left. Off by default, and unlike the auth bridge it stays that way — a
/// view costs a container exec, a Node daemon and a host port, and a
/// container without Playwright cannot serve one at all.
///
/// Durable does **not** mean auto-started: nothing brings a viewer up on
/// app start, so a project left enabled reports `enabled` with a state of
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
#[serde(default)]
pub browser_view_enabled: bool,
/// Grant the container what a VPN client needs to build a tunnel:
@@ -639,7 +672,7 @@ impl Project {
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
auth_bridge_enabled: false,
auth_bridge_enabled: default_auth_bridge_enabled(),
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(),
@@ -885,4 +918,69 @@ mod tests {
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped, partial);
}
// ── The host-side per-project toggles ─────────────────────────────────
#[test]
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
// record written by a shipped binary and has no `auth_bridge_enabled`
// key at all. Without this, every existing project keeps hanging on
// `claude login` until its owner finds the toggle.
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
assert!(project.auth_bridge_enabled);
// The browser view is the other way round and must stay so: it costs a
// Node daemon, a container exec loop and a host port, and most
// containers have no Playwright to serve it with.
assert!(!project.browser_view_enabled);
}
#[test]
fn turning_the_auth_bridge_off_survives_the_default() {
// Opt-out has to be expressible, or the toggle does nothing across a
// restart. An explicit `false` in the file beats the default.
let json = r#"{ "auth_bridge_enabled": false }"#;
#[derive(Deserialize)]
struct JustTheFlag {
#[serde(default = "default_auth_bridge_enabled")]
auth_bridge_enabled: bool,
}
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
assert!(!parsed.auth_bridge_enabled);
// And a saved project always writes the key, so the choice is pinned
// rather than re-defaulted on the next load.
let mut p = Project::new("demo".to_string(), Vec::new());
p.auth_bridge_enabled = false;
let round_tripped: Project =
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert!(!round_tripped.auth_bridge_enabled);
}
#[test]
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
let p = Project::new("demo".to_string(), Vec::new());
assert!(p.auth_bridge_enabled);
assert!(!p.browser_view_enabled);
}
#[test]
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
// `ProjectsStore::new` runs every record through this before
// deserialising. If it inserted either key — even as `false` — the
// serde default above would never be consulted for an existing project
// and this change would be a no-op on exactly the projects it is for.
let legacy = serde_json::json!({
"id": "p1",
"name": "demo",
"path": "/home/u/demo",
});
let migrated = Project::migrate_from_value(legacy);
let obj = migrated.as_object().unwrap();
assert!(obj.contains_key("paths"), "the migration should still do its own job");
assert!(!obj.contains_key("auth_bridge_enabled"));
assert!(!obj.contains_key("browser_view_enabled"));
}
}
@@ -241,6 +241,21 @@ impl ProjectsStore {
}
}
/// Granular setter for the browser view's opt-in, for the same reason
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
/// the Config tab may be holding an older copy of the whole record.
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.browser_view_enabled = enabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
@@ -338,4 +353,61 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
/// is the real user's; the fields are right here, so the granular setters
/// can be exercised against a directory the test owns.
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
ProjectsStore {
projects: Mutex::new(projects),
file_path: dir.join("projects.json"),
}
}
#[test]
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
// The point of the whole exercise: before this the flag lived in a
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
let dir = temp_dir("browser-view");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
assert!(!store.get(&id).unwrap().browser_view_enabled);
store.set_browser_view_enabled(&id, true).unwrap();
assert!(store.get(&id).unwrap().browser_view_enabled);
// Durable, not merely in memory — this is what a restart reads.
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert!(on_disk[0].browser_view_enabled);
store.set_browser_view_enabled(&id, false).unwrap();
assert!(!store.get(&id).unwrap().browser_view_enabled);
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_granular_toggle_leaves_every_other_field_alone() {
// Why these setters exist at all: the Config tab can be holding an
// older copy of the whole record while the pane flips one flag.
let dir = temp_dir("granular");
let mut project = Project::new("demo".to_string(), Vec::new());
project.claude_instructions = Some("keep me".to_string());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
store.set_browser_view_enabled(&id, true).unwrap();
store.set_auth_bridge_enabled(&id, false).unwrap();
let saved = store.get(&id).unwrap();
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
assert!(saved.browser_view_enabled);
assert!(!saved.auth_bridge_enabled);
fs::remove_dir_all(&dir).ok();
}
}
+791
View File
@@ -0,0 +1,791 @@
//! Opening a URL in the *host's* browser — the half of triple-c#34 where
//! "Open" appeared to do nothing on Linux.
//!
//! # Why this module exists rather than `openUrl` from `@tauri-apps/plugin-opener`
//!
//! The plugin's Linux path shells out to `xdg-open`, and the child inherits
//! this process's environment verbatim. Inside an AppImage that environment is
//! not the user's — it is the AppImage's, and it is actively hostile to any
//! program that is not the one the bundle was built for:
//!
//! - linuxdeploy's `AppRun`/`AppRun.wrapped` prepends the bundle's own
//! directories to `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `PYTHONPATH`,
//! `PERLLIB`, `QT_PLUGIN_PATH` and `GSETTINGS_SCHEMA_DIR`.
//! - `linuxdeploy-plugin-gtk`'s hook adds `GTK_PATH`, `GTK_EXE_PREFIX`,
//! `GTK_DATA_PREFIX`, `GTK_IM_MODULE_FILE`, `GIO_MODULE_DIR` and
//! `GDK_PIXBUF_MODULE_FILE`.
//! - `scripts/finalize-appimage.sh` installs one more hook of our own
//! (`triple-c-wayland-fallback.sh`) that can prepend
//! `$APPDIR/usr/lib/wayland-fallback` to `LD_LIBRARY_PATH`.
//! - `main.rs` sets `WEBKIT_DISABLE_DMABUF_RENDERER` process-wide, and the
//! comment there has flagged this leak for a while: it reaches whatever the
//! app spawns afterwards.
//!
//! A browser that is *already running* is unaffected — `xdg-open` just hands
//! the URL to the existing instance over D-Bus/IPC and the new process exits.
//! A **cold-launched** browser loads our bundled GTK/glib/pixbuf stack against
//! the host's, aborts before it ever paints, and `xdg-open` has already
//! returned 0. From the app's point of view the click did nothing. That is the
//! reported symptom, and it is why the bug only reproduces for some people.
//!
//! # What this does instead
//!
//! `open_url_external` re-validates the URL (see below) and spawns the opener
//! with a **sanitized child environment**. Sanitizing is
//! [`sanitize_child_env`], a pure function over two maps so it can be tested
//! without touching process-wide state:
//!
//! 1. If the AppImage saved the pre-launch value under a `*_ORIG` /
//! `APPIMAGE_ORIGINAL_*` name, restore that. Restoring a saved original is
//! strictly better than unsetting, because the user may genuinely have had
//! an `LD_LIBRARY_PATH` of their own.
//! 2. Otherwise, if the variable differs from the value this process started
//! with, restore the start-up value. That is what undoes *our own*
//! `std::env::set_var` — `main.rs` snapshots the environment via
//! [`capture_pristine_environment`] before any mutation runs.
//! 3. Otherwise, drop only the entries that point inside `$APPDIR`, keeping
//! the rest of the list intact. Blanket-unsetting would also discard
//! whatever the user's session had set; this removes exactly the
//! bundle's own contribution.
//!
//! Nothing is invented: a variable the pristine environment did not have and
//! that does not point into `$APPDIR` is left alone, so outside an AppImage
//! (`cargo tauri dev`, a distro build) this is very close to a no-op.
//!
//! # Portal vs. `xdg-open`
//!
//! `org.freedesktop.portal.OpenURI` would sidestep both the environment leak
//! *and* a missing `x-scheme-handler/https` association, but reaching it means
//! a D-Bus client — `zbus` and its async stack — as a new dependency for one
//! call, on the only platform where we ship a single self-contained binary.
//! It also only helps where a portal is running, which is precisely the
//! desktop-environment case in which `xdg-open` already works once the
//! environment is clean. The environment *is* the bug here, so the cheap fix
//! is the complete one. `gio open` is kept as a second candidate because it
//! goes through GIO's own handler lookup rather than `xdg-open`'s shell
//! heuristics, which covers most of what the portal would have covered.
//!
//! # Security
//!
//! The URL reaching this command originates in an **untrusted container** (see
//! `app/src/lib/urlRelay.ts`). The frontend validates with `sanitizeRelayUrl`,
//! but a compromised webview can call this command directly, so the rules are
//! mirrored here and enforced again: `http`/`https` only, a non-empty host, no
//! embedded credentials, no control characters or whitespace, and a length
//! cap. The URL is never passed through a shell — `std::process::Command` with
//! explicit arguments, so there is no word-splitting, no globbing and no
//! metacharacter to escape.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use url::Url;
/// Hard cap on a URL we will hand to the OS. Mirrors `MAX_RELAY_URL_LENGTH`
/// in `app/src/lib/urlRelay.ts`.
const MAX_URL_LEN: usize = 8192;
/// The environment this process was started with, captured before anything
/// mutates it. See [`capture_pristine_environment`].
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
static PRISTINE_ENV: OnceLock<BTreeMap<String, String>> = OnceLock::new();
/// Record the environment as it was at process start.
///
/// Must be called from `main()` **before** any `std::env::set_var` — today
/// that means before `apply_webkit_wayland_workaround()`, which is the only
/// mutation in the tree. Calling it twice is harmless; the first call wins.
///
/// This is the only reliable source of truth for "what did the user actually
/// have?" for variables *we* set. It cannot recover what `AppRun` overwrote
/// before `main()` ran — that is what the `*_ORIG` and `$APPDIR` rules in
/// [`sanitize_child_env`] are for.
pub fn capture_pristine_environment() {
let _ = PRISTINE_ENV.set(std::env::vars().collect());
}
/// Variables an AppImage launcher is known to override, and that break a
/// cold-launched child that is not this app.
///
/// `PATH` is in the list for the same reason as the rest: `AppRun` prepends
/// `$APPDIR/usr/bin`, and resolving `xdg-open` (or anything the browser's own
/// wrapper script calls) out of the bundle is its own failure mode.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const SANITIZED_VARS: &[&str] = &[
"GDK_PIXBUF_MODULEDIR",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GSETTINGS_SCHEMA_DIR",
"GTK_DATA_PREFIX",
"GTK_EXE_PREFIX",
"GTK_IM_MODULE_FILE",
"GTK_PATH",
"LD_LIBRARY_PATH",
"PATH",
"PERLLIB",
"PYTHONPATH",
"QT_PLUGIN_PATH",
"XDG_DATA_DIRS",
// Set by `main.rs`, not by AppRun — rule 2 (the pristine snapshot) is what
// removes it, since the pristine environment almost never has it.
"WEBKIT_DISABLE_DMABUF_RENDERER",
];
/// What to do to one variable in the child: `Some(value)` sets it, `None`
/// removes it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
type EnvChange = (String, Option<String>);
/// True when `entry` is `appdir` itself or a path inside it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_inside(entry: &str, appdir: &str) -> bool {
let appdir = appdir.trim_end_matches('/');
if appdir.is_empty() {
return false;
}
entry == appdir || entry.strip_prefix(appdir).is_some_and(|r| r.starts_with('/'))
}
/// Drop the `$APPDIR` entries from a colon-separated list, keeping order and
/// keeping everything else.
///
/// Single-valued variables (`GDK_PIXBUF_MODULE_FILE`, say) are just lists of
/// one, so they need no separate case: a value inside `$APPDIR` filters down
/// to nothing and the variable is removed.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
let kept: Vec<&str> = value
.split(':')
.filter(|entry| !entry.is_empty() && !is_inside(entry, appdir))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(":"))
}
}
/// Compute the changes that turn `current` into an environment safe to hand a
/// cold-launched host program.
///
/// Pure on purpose — `current` and `pristine` are passed in rather than read
/// from the process, so the rules can be tested without a global mutex around
/// the environment. Returns changes sorted by variable name so assertions are
/// deterministic.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn sanitize_child_env(
current: &BTreeMap<String, String>,
pristine: &BTreeMap<String, String>,
appdir: Option<&str>,
) -> Vec<EnvChange> {
let mut changes: Vec<EnvChange> = Vec::new();
for var in SANITIZED_VARS {
let now = current.get(*var);
// 1. A saved original always wins. Both spellings are checked because
// which one exists depends on the launcher: linuxdeploy's AppRun
// and the various `AppRun.wrapped` generations have used each.
// An empty saved value means "it was unset", not "set it to empty".
let saved = current
.get(&format!("{var}_ORIG"))
.or_else(|| current.get(&format!("APPIMAGE_ORIGINAL_{var}")));
if let Some(saved) = saved {
let restored = if saved.is_empty() {
None
} else {
Some(saved.clone())
};
if restored.as_ref() != now {
changes.push((var.to_string(), restored));
}
continue;
}
// 2. We changed it ourselves after start-up — put back what was there.
let at_start = pristine.get(*var);
if at_start != now {
changes.push((var.to_string(), at_start.cloned()));
continue;
}
// 3. Polluted before `main()` ran, with nothing saved. Remove the
// bundle's own entries and keep the user's.
let (Some(now), Some(appdir)) = (now, appdir) else {
continue;
};
let stripped = strip_appdir_entries(now, appdir);
if stripped.as_deref() != Some(now.as_str()) {
changes.push((var.to_string(), stripped));
}
}
changes.sort_by(|a, b| a.0.cmp(&b.0));
changes
}
/// Whether `candidate` holds a character that disqualifies it before parsing.
///
/// Mirrors `hasForbiddenChar` in `app/src/lib/urlRelay.ts`, and for the same
/// reasons: C0/C1 controls and whitespace are invisible in the UI and are
/// stripped rather than rejected by some URL parsers, and quote characters are
/// illegal in a URL per RFC 3986 while being exactly what an argument-splitting
/// opener downstream would act on. Written as a scan over code points rather
/// than a regex so the control ranges cannot be mangled by an editing tool.
fn has_forbidden_char(candidate: &str) -> bool {
candidate.chars().any(|ch| {
let code = ch as u32;
code <= 0x20
|| code == 0x7f
|| (0x80..=0x9f).contains(&code)
|| ch == '"'
|| ch == '\''
|| ch == '`'
|| ch.is_whitespace()
})
}
/// Validate a URL an untrusted source asked the host to open.
///
/// Returns the normalized URL, or a message safe to show the user. The message
/// never echoes the input: it is the input that is untrusted, and this error
/// is rendered in a toast.
fn validate_external_url(raw: &str) -> Result<String, String> {
// Rust's `trim` strips slightly more than JavaScript's (NEL, U+0085, for
// one), so a string the frontend would have rejected can reach the parser
// here with its edges shaved. That only ever removes outer whitespace —
// everything that survives still has to pass every check below — so the
// divergence cannot widen what gets opened.
let candidate = raw.trim();
if candidate.is_empty() {
return Err("Refused to open an empty URL.".to_string());
}
if candidate.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
if has_forbidden_char(candidate) {
return Err(
"Refused to open a URL containing whitespace, quotes or control characters."
.to_string(),
);
}
let parsed = Url::parse(candidate).map_err(|_| "Refused to open a malformed URL.".to_string())?;
// Scheme allowlist. Nothing else, ever — `file:`, `javascript:`, `data:`
// and every registered protocol handler stay out of reach of the
// container. The scheme is safe to interpolate: the parser restricts it to
// ASCII alphanumerics, `+`, `-` and `.`.
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(format!(
"Refused to open a {}: URL — only http and https are allowed.",
parsed.scheme()
));
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err("Refused to open a URL with no host.".to_string());
}
// `https://claude.ai@evil.tld/x` reads as claude.ai anywhere the string is
// truncated, and navigates to evil.tld.
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Refused to open a URL containing embedded credentials.".to_string());
}
let normalized = parsed.to_string();
if normalized.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
// A normalized http(s) URL is ASCII by construction — the host is
// punycoded and everything after it is percent-encoded. Asserting it means
// nothing non-ASCII can reach an `execvp` argument, whatever the parser
// decides to do in a future version.
if !normalized.is_ascii() {
return Err("Refused to open a URL with non-ASCII characters.".to_string());
}
Ok(normalized)
}
/// Openers to try, in order, each as (program, leading arguments).
///
/// `xdg-open` first because it is what the desktop expects to be asked and
/// honours the user's `mimeapps.list`. `gio open` second: it is present
/// wherever glib is (which, for a GTK app's host, is everywhere) and resolves
/// the handler through GIO rather than `xdg-open`'s shell heuristics, so it
/// still works when the `x-scheme-handler/https` association `xdg-open` looks
/// for is missing or points at something broken.
#[cfg(target_os = "linux")]
const OPENERS: &[(&str, &[&str])] = &[("xdg-open", &[]), ("gio", &["open"])];
/// How long a candidate opener is given to fail before it is assumed to have
/// worked.
///
/// `xdg-open` usually returns immediately (it hands the URL to a running
/// browser and exits), but in its generic fallback mode it *is* the browser's
/// parent and stays alive for the session. So "still running" cannot be read
/// as failure, and "exited non-zero quickly" is the only negative signal there
/// is — though not, on its own, a trustworthy one. See
/// [`exit_code_means_nothing_was_launched`].
#[cfg(target_os = "linux")]
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
/// Whether a non-zero exit says the opener certainly launched nothing, and so
/// that the next candidate can be tried without risking a second tab.
///
/// The loop used to treat every quick non-zero exit as "it did nothing" and
/// fall through. That is safe for most of `xdg-open`'s documented codes — 1
/// (syntax), 2 (file not found) and 3 (a required tool could not be found) are
/// all statements that it never got as far as launching a handler, and 3 is the
/// missing-association case `gio open` is in [`OPENERS`] for. 127 is the same
/// statement made by a shell, which is how a `$BROWSER` or `x-www-browser`
/// wrapper naming a program that does not exist comes back.
///
/// Code 4 is the one that cannot be read that way, and it is the catch-all:
/// "the action failed" also covers a handler that *was* launched and then
/// returned non-zero. A browser that takes the URL, opens the tab in an already
/// running instance and exits non-zero for its own reasons ends up here, as
/// does a wrapper script that does its job and then returns the exit status of
/// something else. Falling through on that hands the same URL to a second
/// opener: two tabs for one click, and for an OAuth link two authorize
/// requests.
///
/// So anything not recognised below — 4, an unfamiliar code, or a death by
/// signal (`code()` is `None`) — ends the loop rather than continuing it. The
/// caller is told the opener failed, which is the honest report of an
/// ambiguous outcome, and no second request is made on the user's behalf. Note
/// what this costs: an opener that genuinely failed with code 4 no longer falls
/// through to `gio`, so a user whose `xdg-open` fails that way sees an error
/// where they previously might have got a tab.
///
/// This is reasoning from `xdg-open`'s documented exit codes, not from an
/// observed double-open in this app.
#[cfg(target_os = "linux")]
fn exit_code_means_nothing_was_launched(code: Option<i32>) -> bool {
matches!(code, Some(1 | 2 | 3 | 127))
}
/// Spawn `url` with an opener, under a sanitized environment.
#[cfg(target_os = "linux")]
fn spawn_with_clean_env(url: &str) -> Result<(), String> {
let current: BTreeMap<String, String> = std::env::vars().collect();
let pristine = PRISTINE_ENV.get().cloned().unwrap_or_else(|| current.clone());
let appdir = current.get("APPDIR").cloned();
let changes = sanitize_child_env(&current, &pristine, appdir.as_deref());
let mut failures: Vec<String> = Vec::new();
for (program, leading) in OPENERS {
let mut command = std::process::Command::new(program);
command.args(*leading).arg(url);
// The bundle's own identity is not the child's business either, and a
// browser that re-execs itself through a wrapper script can pick these
// up.
for var in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] {
command.env_remove(var);
}
for (key, value) in &changes {
match value {
Some(value) => command.env(key, value),
None => command.env_remove(key),
};
}
// Detached: the opener must not inherit our stdio, or a browser
// writing to stderr keeps a pipe to us open for the session.
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// A spawn failure — `ErrorKind::NotFound` for an opener that is not
// installed, `PermissionDenied` for one that cannot be executed — is
// the unambiguous case: nothing ran, so nothing was opened, and the
// next candidate is free to try.
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
failures.push(format!("{program}: {err}"));
continue;
}
};
std::thread::sleep(OPENER_GRACE);
match child.try_wait() {
Ok(Some(status)) if !status.success() => {
failures.push(format!("{program} exited with {status}"));
// A program that *ran* is not a program that did nothing.
if !exit_code_means_nothing_was_launched(status.code()) {
return Err(format!(
"Could not confirm the link opened. Tried: {}. It may have opened anyway \
check your browser before trying again.",
failures.join("; ")
));
}
continue;
}
Ok(_) => {}
Err(err) => {
failures.push(format!("{program}: could not be waited on: {err}"));
continue;
}
}
// Still running (it is the browser's parent) — reap it off-thread so it
// does not become a zombie for the life of the app.
std::thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
Err(format!(
"Could not open the link. Tried: {}. Check that xdg-utils is installed and that a default browser is set.",
failures.join("; ")
))
}
/// Open `url` in the user's browser.
///
/// On Linux this goes through [`spawn_with_clean_env`] rather than
/// `@tauri-apps/plugin-opener`, for the AppImage reasons in this module's
/// documentation (triple-c#34). macOS and Windows keep the plugin's path —
/// neither has the environment problem, and `open`/`ShellExecute` are the
/// right calls there — but they are reached through this same command so the
/// frontend has one call site with one set of validation rules.
///
/// Errors are returned rather than logged-and-swallowed: "Open" silently doing
/// nothing is the bug being fixed, so the failure has to be something the UI
/// can show.
#[tauri::command]
pub async fn open_url_external(app: tauri::AppHandle, url: String) -> Result<(), String> {
let validated = validate_external_url(&url)?;
#[cfg(target_os = "linux")]
{
let _ = &app;
tauri::async_runtime::spawn_blocking(move || spawn_with_clean_env(&validated))
.await
.map_err(|err| format!("Could not open the link: {err}"))?
}
#[cfg(not(target_os = "linux"))]
{
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(validated, None::<&str>)
.map_err(|err| format!("Could not open the link: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
// ── URL re-validation ────────────────────────────────────────────────
#[test]
fn plain_http_and_https_urls_are_accepted() {
for url in [
"https://claude.ai/",
"http://localhost:1420/callback?code=abc",
"https://example.com/path#frag",
] {
assert!(validate_external_url(url).is_ok(), "{url} should be allowed");
}
}
#[test]
fn urls_are_returned_normalized() {
assert_eq!(
validate_external_url("https://Example.COM").unwrap(),
"https://example.com/"
);
}
#[test]
fn only_http_and_https_survive() {
for url in [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,<script>",
"ftp://example.com/x",
"vscode://foo/bar",
"mailto:someone@example.com",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn embedded_credentials_are_refused() {
for url in [
"https://claude.ai@evil.tld/x",
"https://user:pass@example.com/",
"https://:pass@example.com/",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn control_characters_and_whitespace_are_refused() {
// `\n` in particular: parsers that strip it would turn the first of
// these into a `javascript:` URL.
for url in [
"java\nscript:alert(1)",
"https://example.com/\u{7f}",
"https://example.com/\u{85}x",
"https://example.com/a b",
"https://example.com/\u{00a0}x",
"https://example.com/\"",
"https://example.com/'",
"https://example.com/`",
] {
assert!(
validate_external_url(url).is_err(),
"{url:?} must not be openable"
);
}
}
#[test]
fn empty_and_oversized_are_refused() {
assert!(validate_external_url("").is_err());
assert!(validate_external_url(" ").is_err());
let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
assert!(validate_external_url(&long).is_err());
}
#[test]
fn a_host_is_required() {
assert!(validate_external_url("https://").is_err());
assert!(validate_external_url("http://:8080/").is_err());
// Not a missing host: WHATWG's "special authority ignore slashes"
// state eats the third slash, so this is the host `path` in both
// `new URL()` and here. Asserted so the parity is on the record.
assert_eq!(
validate_external_url("http:///path").unwrap(),
"http://path/"
);
}
#[test]
fn error_messages_never_echo_the_input() {
// The input is attacker-controlled and the message goes into a toast.
let err = validate_external_url("file:///home/someone/.ssh/id_rsa").unwrap_err();
assert!(!err.contains("id_rsa"), "message leaked the input: {err}");
}
// ── Environment sanitization ─────────────────────────────────────────
#[test]
fn appdir_entries_are_stripped_and_the_users_own_are_kept() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib:/opt/mine/lib"),
("XDG_DATA_DIRS", "/tmp/.mount_abc/usr/share:/usr/share"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
(
"LD_LIBRARY_PATH".to_string(),
Some("/opt/mine/lib".to_string())
),
("XDG_DATA_DIRS".to_string(), Some("/usr/share".to_string())),
]
);
}
#[test]
fn a_variable_that_is_entirely_appdir_is_removed() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
(
"GDK_PIXBUF_MODULE_FILE",
"/tmp/.mount_abc/usr/lib/gdk-pixbuf/loaders.cache",
),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
("GDK_PIXBUF_MODULE_FILE".to_string(), None),
("GTK_PATH".to_string(), None),
]
);
}
#[test]
fn a_saved_original_is_restored_rather_than_unset() {
// Restoring beats unsetting: the user may have had one of their own.
for saved_as in ["LD_LIBRARY_PATH_ORIG", "APPIMAGE_ORIGINAL_LD_LIBRARY_PATH"] {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
(saved_as, "/home/someone/lib"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![(
"LD_LIBRARY_PATH".to_string(),
Some("/home/someone/lib".to_string())
)],
"{saved_as} should be restored"
);
}
}
#[test]
fn an_empty_saved_original_means_it_was_unset() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
("LD_LIBRARY_PATH_ORIG", ""),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(changes, vec![("LD_LIBRARY_PATH".to_string(), None)]);
}
#[test]
fn our_own_set_var_is_undone_from_the_pristine_snapshot() {
// The leak `main.rs` documents: we set this after start-up, so the
// start-up snapshot is what says it should not exist at all.
let pristine = map(&[("HOME", "/home/someone")]);
let current = map(&[
("HOME", "/home/someone"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]);
let changes = sanitize_child_env(&current, &pristine, None);
assert_eq!(
changes,
vec![("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), None)]
);
}
#[test]
fn a_value_the_user_set_themselves_is_left_alone() {
let pristine = map(&[("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]);
let current = pristine.clone();
assert!(sanitize_child_env(&current, &pristine, None).is_empty());
}
#[test]
fn outside_an_appimage_nothing_is_touched() {
let env = map(&[
("PATH", "/usr/bin:/bin"),
("LD_LIBRARY_PATH", "/opt/mine/lib"),
("XDG_DATA_DIRS", "/usr/share"),
]);
assert!(
sanitize_child_env(&env, &env, None).is_empty(),
"a dev build or distro build must not have its environment rewritten"
);
}
#[test]
fn nothing_is_invented_for_variables_that_were_never_set() {
let env = map(&[("APPDIR", "/tmp/.mount_abc")]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_prefix_that_merely_looks_like_appdir_is_not_stripped() {
// `/tmp/.mount_abc-other` is not inside `/tmp/.mount_abc`.
let env = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc-other/lib"),
]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_trailing_slash_on_appdir_still_matches() {
let env = map(&[
("APPDIR", "/tmp/.mount_abc/"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
]);
let changes = sanitize_child_env(&env, &env, Some("/tmp/.mount_abc/"));
assert_eq!(changes, vec![("GTK_PATH".to_string(), None)]);
}
}
#[cfg(all(test, target_os = "linux"))]
mod opener_fallback_tests {
use super::*;
/// The codes `xdg-open` documents as "nothing was launched". Falling
/// through to the next opener on these is what keeps `gio open` reachable
/// for the case it was added for: no usable `x-scheme-handler/https`
/// association.
#[test]
fn the_codes_that_mean_no_handler_ran_fall_through() {
for code in [1, 2, 3, 127] {
assert!(
exit_code_means_nothing_was_launched(Some(code)),
"exit {code} means the opener never launched anything"
);
}
}
/// The regression this guards: `xdg-open` returns 4 both when it could not
/// act and when the handler it launched returned non-zero — including a
/// browser that had already opened the tab. Trying `gio open` next would
/// open it a second time, which for an OAuth URL is a second authorize
/// request.
#[test]
fn an_exit_that_may_follow_a_successful_open_does_not_fall_through() {
assert!(!exit_code_means_nothing_was_launched(Some(4)));
for code in [5, 7, 126, 255] {
assert!(
!exit_code_means_nothing_was_launched(Some(code)),
"exit {code} is not a documented 'did nothing', so it must not be assumed to be one"
);
}
}
/// Killed by a signal: `code()` is `None` and the outcome is unknowable,
/// so it is treated like any other unrecognised exit.
#[test]
fn a_death_by_signal_does_not_fall_through() {
assert!(!exit_code_means_nothing_was_launched(None));
}
}
+20 -11
View File
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
writer_handle.abort();
}
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
/// copied so the web terminal cannot drift from it — a duplicated `const` with
/// a "keep these identical" comment is only as good as the next reader.
use crate::commands::terminal_commands::UPDATE_PRELUDE;
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
return cmd;
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
// The args are interpolated into a shell script string below, so
// single-quote each one.
let permission_flags: String = permission_args
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
.collect();
let claude_cmd = format!("exec claude{}", permission_flags);
if !is_bedrock_profile {
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
let script = format!(
r#"
echo "Validating AWS session for profile '{profile}'..."
@@ -260,9 +267,11 @@ else
echo ""
fi
fi
{update_prelude}
{claude_cmd}
"#,
profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd
);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { openUrlExternal } from "../lib/tauri-commands";
import { useDocker } from "../hooks/useDocker";
import Modal from "./ui/Modal";
import Button from "./ui/Button";
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
await openUrlExternal(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
+9 -8
View File
@@ -10,7 +10,7 @@ interface Props {
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
} = useAppState(
useShallow(s => ({
projects: s.projects,
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock,
terminalMouseCaptured: s.terminalMouseCaptured,
releaseActiveMouse: s.releaseActiveMouse,
}))
);
const running = projects.filter((p) => p.status === "running").length;
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
{/* Right-aligned controls: mouse release + Notes + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
{activeSessionId && terminalMouseCaptured && (
<button
onClick={() => scrollActiveToBottom()}
data-mouse-release="true"
onClick={() => releaseActiveMouse()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
>
Jump to Current
🖱 Mouse captured release
</button>
)}
<button
@@ -23,6 +23,7 @@ import {
setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands";
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection";
@@ -338,7 +339,7 @@ export default function BrowserTab({ project, active }: Props) {
// Prefer the probe: it is the fresher of the two, and it is the one that
// reflects an install that just finished.
const probed = detection ?? status.detection;
const ready = isUsable(probed);
const ready = isBrowserViewUsable(probed);
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
// apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser.
@@ -539,11 +540,6 @@ export default function BrowserTab({ project, active }: Props) {
);
}
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
function isUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
@@ -627,7 +623,7 @@ function Setup({
onInstall: (which: Exclude<SetupJob, null>) => void;
}) {
const busy = job !== null;
const havePackages = isUsable(detection);
const havePackages = isBrowserViewUsable(detection);
const missing = missingParts(detection);
const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null;
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
encodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
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;
}
/**
* 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,
* and it is what `git` and `grep` use to decide the same question.
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
hasClaudeToken: vi.fn(),
clearClaudeToken: vi.fn(),
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
}));
const cancelClaudeToken = vi.fn(() => Promise.resolve());
const openUrl = vi.fn();
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: (...args: unknown[]) => openUrl(...args),
}));
const openUrlExternal = vi.fn();
/** Captured event handlers, keyed by event name, so tests can emit. */
const handlers = new Map<string, (event: { payload: unknown }) => void>();
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: url });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
});
it("ignores output belonging to a different project", async () => {
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: FULL_URL });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
});
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
@@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => {
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(openUrl).not.toHaveBeenCalled();
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("ignores a hyperlink belonging to a different project", async () => {
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { cancelClaudeToken } from "../../lib/tauri-commands";
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
return;
}
try {
await openUrl(target);
await openUrlExternal(target);
} catch (e) {
setLinkError(
authErrorMessage(
+2 -2
View File
@@ -1,5 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
import { openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { formatBytes } from "../../lib/formatBytes";
@@ -19,7 +19,7 @@ export default function UpdateDialog({
}: Props) {
const handleDownload = async (url: string) => {
try {
await openUrl(url);
await openUrlExternal(url);
} catch (e) {
console.error("Failed to open URL:", e);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+85 -8
View File
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
// and then posts the result where nothing is listening, and the terminal
// hangs to its timeout — so for these, and only these, the container-side
// browser leads.
// container, so a sign-in is the one case where the host browser may be the
// wrong lead. Whether it actually is depends on the project — a live auth
// bridge carries the callback back, and the container-side alternative is
// not installed on a fresh project — so the owner decides and passes
// `signInDefault`. This component only renders the decision.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
@@ -180,7 +183,77 @@ describe("UrlToast", () => {
.filter((t) => t === "Open" || t === "In container");
}
it("puts the container browser first", () => {
it("puts the container browser first when the caller asks for it", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["In container", "Open"]);
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/callback listener is inside the container/i,
);
});
it("leads with the host, and promises the bridge, when the bridge is live", () => {
// The pair is unchanged; only the order and which one is filled.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="host-bridged"
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
// Still recognised as a sign-in, so the explanation stays — and here the
// explanation is true, which is the only state in which it may be given.
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/the auth bridge is what carries the callback/i,
);
});
it("says the callback has nothing carrying it when the host is the last resort", () => {
// `host-fallback`: bridge off or unknown *and* no browser in the
// container. The old two-state hint said the auth bridge would carry the
// callback here too, which is a false promise — the user opens the link
// in their own browser and `claude login` hangs to its timeout with
// nothing on screen explaining why.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="host-fallback"
onDismiss={noop}
/>,
);
// Which button leads does not change — only what the hint claims.
expect(actions()).toEqual(["Open", "In container"]);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
const hint = screen.getByTestId("url-toast-signin-hint");
expect(hint).toHaveTextContent(/nothing is set up to reach it/i);
// And it points at the two things that would fix it, since a warning
// with no next step is only a nicer way to fail.
expect(hint).toHaveTextContent(/Auth bridge/);
expect(hint).toHaveTextContent(/install browser support/i);
expect(hint).not.toHaveTextContent(/the auth bridge is what carries the callback/i);
});
it("defaults to the least-bad reading when the caller passes nothing", () => {
// A caller that says nothing has not told us a bridge is live, so the
// hint must not invent one. The host still leads: it is the answer more
// likely to work, and the one that reports its own failure.
render(
<UrlToast
url={SIGN_IN}
@@ -189,9 +262,9 @@ describe("UrlToast", () => {
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["In container", "Open"]);
expect(actions()).toEqual(["Open", "In container"]);
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/callback listener is inside the container/i,
/nothing is set up to reach it/i,
);
});
@@ -202,6 +275,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={onOpen}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -211,12 +285,14 @@ describe("UrlToast", () => {
it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host
// browser is the right answer for all of them and stays the default.
// browser is the right answer for all of them and stays the default,
// whatever the project's sign-in preference happens to be.
render(
<UrlToast
url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -232,6 +308,7 @@ describe("UrlToast", () => {
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
+58 -18
View File
@@ -1,5 +1,6 @@
import type { KeyboardEvent } from "react";
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
import type { SignInOpenTarget } from "../../hooks/useSignInOpenTarget";
import Button from "../ui/Button";
/**
@@ -37,6 +38,24 @@ interface Props {
/** Open it in the container's own browser instead of the host's. Omitted when
* the project has no browser to open it in. */
onOpenInContainer?: () => void;
/**
* Which action leads for a *sign-in* link, and why (see the note below).
* Nothing else in the toast moves: both buttons are offered in all three
* states, in one of two orders.
*
* This component does not work it out, because the answer depends on the
* project's auth bridge and on what is installed inside its container
* neither of which a presentational component should be reaching for.
* `hooks/useSignInOpenTarget.ts` owns the rule.
*
* Two of the three lead with the host button and differ only in the hint,
* which is the whole point of carrying three: `"host-bridged"` may promise
* that the auth bridge brings the callback home, `"host-fallback"` may not,
* because in that state nothing does. `"host-fallback"` is the default for
* that reason a caller that says nothing has not told us a bridge is live,
* and the hint must not invent one.
*/
signInDefault?: SignInOpenTarget;
onDismiss: () => void;
}
@@ -57,17 +76,26 @@ interface Props {
* text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later.
*
* ## Anthropic sign-in links default to the container's browser
* ## Anthropic sign-in links get their default from the caller
*
* For an ordinary URL the host browser is the right answer and stays the
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
* is waiting on is inside the container, so a host browser completes the sign-in
* and then posts the result somewhere nothing is listening, and the terminal
* hangs until it times out. Making the host button primary there was quietly
* steering every user into that. The container-side browser closes the loop
* with no host round trip and no auth bridge, so it leads and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
* default, unconditionally. A sign-in is the one case where it might not be:
* the callback listener the CLI is waiting on is inside the container, so a
* host browser can complete the sign-in and then post the result where nothing
* is listening, leaving the terminal to hang to its timeout.
*
* *Can*, not *does* which is why this is no longer decided from the URL. The
* auth bridge mirrors that container listener onto the same host port, and the
* container-side alternative is Playwright's dashboard pane, which a fresh
* project has not installed. Both of those are project facts, so the owner
* passes {@link Props.signInDefault} and this only renders it: the leading
* button is filled and comes first, the other keeps its place beside it.
*
* The hint below the URL renders all *three* states, not the two orderings.
* "Neither is set up" also leads with the host, but it is not the same claim:
* there the callback has nothing carrying it, so the hint names what would fix
* that instead of describing a bridge that is off. A two-way hint keyed on
* which button leads is exactly how that false promise got shipped.
*
* ## Reachable without a mouse, and it does not take focus to manage it
*
@@ -96,6 +124,7 @@ export default function UrlToast({
label = "Long URL detected",
onOpen,
onOpenInContainer,
signInDefault = "host-fallback",
onDismiss,
}: Props) {
const origin = urlOrigin(url);
@@ -103,18 +132,27 @@ export default function UrlToast({
// Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// A sign-in link the caller has decided is better completed inside the
// container. Everything below keys off this rather than off `signIn`, so the
// two orderings differ only in which of the pair leads.
const containerLeads = signIn && signInDefault === "container";
// The third state. Both host states put the same button first, so this is
// read by the hint alone: no bridge and no container browser means nothing is
// carrying the callback back, and saying "the auth bridge is what carries it"
// here is a promise the project cannot keep.
const hostIsLastResort = signIn && signInDefault === "host-fallback";
// `Button` already owns the filled/outlined variants — including the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text.
const hostButton = (
<Button
variant={signIn ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"}
variant={containerLeads ? "secondary" : "primary"}
data-url-toast-primary={containerLeads ? undefined : "true"}
onClick={onOpen}
className="flex-shrink-0"
title={
signIn
containerLeads
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
@@ -128,8 +166,8 @@ export default function UrlToast({
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<Button
variant={signIn ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined}
variant={containerLeads ? "primary" : "secondary"}
data-url-toast-primary={containerLeads ? "true" : undefined}
onClick={onOpenInContainer}
className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab"
@@ -235,14 +273,16 @@ export default function UrlToast({
lineHeight: 1.35,
}}
>
Sign-in link the callback listener is inside the container.
Opening it there closes the loop; the host browser needs the auth
bridge.
{containerLeads
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
: hostIsLastResort
? "Sign-in link — the callback listener is inside the container and nothing is set up to reach it. Turn on Auth bridge in the projects Config tab, or install browser support to sign in inside the container."
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
</div>
)}
</div>
{signIn ? (
{containerLeads ? (
<>
{containerButton}
{hostButton}
@@ -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);
},
};
}
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
checkBrowserViewSupport,
getAuthBridgeStatus,
} from "../lib/tauri-commands";
import { canOpenPageInContainerBrowser } from "../lib/browserViewSupport";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
PlaywrightDetection,
} from "../lib/types";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
/**
* Which of the URL toast's two buttons should lead for a sign-in link and,
* for the host, *why*.
*
* Three states rather than two because "host" covers two worlds that are not
* the same promise to the user:
*
* - `host-bridged` the auth bridge is live, so a sign-in completed in the
* user's own browser has its callback carried back to the listener inside
* the container. The host is genuinely the better answer here.
* - `container` no bridge, but the container has a browser to open, which
* closes the loop locally with nothing crossing to the host.
* - `host-fallback` neither. The host is the *least bad* of two answers
* that can both fail, and the toast has to say so: a hint claiming the
* bridge will carry the callback is a false promise in this state, and the
* user's `claude login` hangs to its timeout with nothing explaining why.
*
* Only `container` changes which button leads; the split between the two host
* states exists so the toast's hint can tell the truth. Keep it that way the
* consumer that folds them back together is the bug this replaced.
*/
export type SignInOpenTarget = "host-bridged" | "container" | "host-fallback";
/**
* Whether the auth bridge can be relied on to catch a callback for this
* project.
*
* Deliberately **not** gated on `active_ports` being non-empty. There is only
* something to bridge once the CLI has bound its callback listener, and the
* order in which that happens against the URL landing in the transcript is not
* ours to control requiring a port here would make the answer depend on a
* race and flip the default button between two otherwise identical sign-ins.
* `enabled` is the durable fact: the poller is watching, and it will mirror the
* port the moment it appears.
*
* A conflict is the exception, because it is the one state where the bridge is
* on and nevertheless *cannot* catch the callback the host port it needed was
* already taken. That is precisely when the container-side browser is the
* better default, so it must not read as live.
*/
export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
if (!status || !status.enabled) return false;
return status.conflicts.length === 0;
}
/**
* The rule, as a pure function of the two things it depends on.
*
* Both host answers land on the same button, for different reasons and they
* are deliberately *not* the same value:
*
* - With the bridge live (`host-bridged`), the host browser is strictly
* better it is the user's own signed-in profile, and the callback still
* reaches the container.
* - With neither available (`host-fallback`), the host is the *more likely to
* work* of two imperfect answers, and it is the one that reports its own
* failure (see `handleOpenUrl` in `TerminalView`). The container-side target
* is Playwright's dashboard pane, and Playwright's browsers are not baked
* into the image, so on a fresh project pointing there fails on every
* platform after a several-second wait. Nothing carries the callback back in
* this state, so the toast says so rather than promising the bridge.
*
* Whichever way it goes, both buttons stay in the toast. This chooses which one
* leads, never which ones exist.
*/
export function chooseSignInTarget(
bridge: AuthBridgeStatus | null,
detection: PlaywrightDetection | null,
): SignInOpenTarget {
if (authBridgeIsLive(bridge)) return "host-bridged";
if (canOpenPageInContainerBrowser(detection)) return "container";
return "host-fallback";
}
/**
* How long a Playwright probe is reused for.
*
* `check_browser_view_support` is a `docker exec` running a Node probe, and
* every terminal tab of a project would otherwise run its own on mount. Five
* minutes is long enough that opening a handful of tabs costs one exec, and
* short enough that pressing "Set up Playwright" in the Browser tab is
* reflected in the default before the user has finished reading the result.
*/
const DETECTION_TTL_MS = 5 * 60_000;
const detectionCache = new Map<
string,
{ at: number; probe: Promise<PlaywrightDetection | null> }
>();
/** The shared, rate-limited probe. Never rejects — "didn't answer" is `null`. */
function probeBrowserSupport(projectId: string): Promise<PlaywrightDetection | null> {
const hit = detectionCache.get(projectId);
if (hit && Date.now() - hit.at < DETECTION_TTL_MS) return hit.probe;
const probe = checkBrowserViewSupport(projectId).catch(() => {
// A failure is usually a stopped container, which is a state the user
// leaves — so it is not worth remembering for five minutes.
detectionCache.delete(projectId);
return null;
});
detectionCache.set(projectId, { at: Date.now(), probe });
return probe;
}
/** Test seam: drops the memoized probes so a case starts from nothing. */
export function resetBrowserSupportCache(): void {
detectionCache.clear();
}
/**
* Resolve the default action for Anthropic sign-in links in this project.
*
* Resolved at mount rather than when a URL arrives, on purpose: the toast has
* two buttons side by side, and a default that settles a second after the
* toast appears moves them under a mouse that is already travelling.
*
* The expensive half is only paid when it can change the answer. The bridge
* status is host-side and cheap; the Playwright probe is a container exec, and
* a live bridge decides the question before it is ever asked which, with the
* bridge now on by default, is the ordinary case.
*/
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
// `host-fallback` is the honest starting point, not `host-bridged`: before
// the status call answers, nothing is known to be carrying the callback, and
// the hint that claims one is the failure this three-state answer exists to
// prevent. Over-warning for the moment before the answer arrives costs a line
// of hedged text; under-warning costs a login that hangs to its timeout.
const [target, setTarget] = useState<SignInOpenTarget>("host-fallback");
useEffect(() => {
if (!projectId) {
setTarget("host-fallback");
return;
}
let cancelled = false;
let bridge: AuthBridgeStatus | null = null;
let detection: PlaywrightDetection | null = null;
const settle = () => {
if (!cancelled) setTarget(chooseSignInTarget(bridge, detection));
};
const consider = (next: AuthBridgeStatus) => {
bridge = next;
settle();
// Only now is the container's side of it worth an exec.
if (authBridgeIsLive(bridge)) return;
probeBrowserSupport(projectId).then((d) => {
if (cancelled) return;
detection = d;
settle();
});
};
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) consider(s);
})
// Nothing to say to the user here: an unanswered status call is fed
// through as a bridge that is off, which lands on `container` or
// `host-fallback` — and `host-fallback`'s hint is the one that tells the
// user the callback has nothing carrying it.
.catch(() => {
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
});
// The switch can be flipped *while a login is hanging* — that is the whole
// reason `set_auth_bridge_enabled` exists outside the Config tab's save —
// so the default has to follow it rather than reflect whatever was true
// when this terminal was opened.
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
consider(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
return target;
}
+9
View File
@@ -33,6 +33,15 @@
/* Two radii only: controls and panels. */
--radius-control: 6px;
--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;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* What a container has to have before anything can be opened *inside* it.
*
* The Browser tab asks this to decide what to offer; the terminal's URL toast
* asks it to decide which of its two buttons should lead. Both need the same
* answer, so the predicates live here rather than beside either caller the
* failure this avoids is the toast steering a user at a container-side browser
* that the Browser tab is, on the very same screen, offering to install.
*
* The important thing to know about `PlaywrightDetection` is that browsers are
* deliberately **not** baked into the image: the libraries they link against
* are, the binaries are a user-pressed install. So "Playwright is present" and
* "a page can actually be opened" are two different questions, and a fresh
* project answers yes to neither.
*/
import type { PlaywrightDetection } from "./types";
/**
* Mirrors Rust `PlaywrightDetection::is_usable` the packages the live
* dashboard needs. Says nothing about whether a browser exists to show in it.
*/
export function isBrowserViewUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Whether `openPageInContainerBrowser` has a browser to launch.
*
* Stricter than {@link isBrowserViewUsable} on purpose: the packages can be
* installed with `~/.cache/ms-playwright` still empty, which is exactly the
* state a `playwright install` step exists to leave behind, and launching into
* it fails several seconds after the click.
*
* Unknown reads as "no". A probe that could not run (stopped container, an
* image predating these fields) leaves the executable fields absent, and the
* caller's fallback the host browser is the one that at least reports its
* own failure. Over-refusing costs a user one extra click on a button that is
* still right there; over-accepting costs them a sign-in that goes nowhere.
*/
export function canOpenPageInContainerBrowser(d: PlaywrightDetection | null): boolean {
if (!isBrowserViewUsable(d) || !d) return false;
// The viewer's own Chromium, confirmed on disk by the probe.
if (d.chromium_executable_exists) return true;
// Google Chrome is an apt package, so it is never in `browsers` and has no
// revision to skew against.
if (d.chrome_channel !== null) return true;
// `== null`, not `=== null`: a probe from a container predating the
// executable fields omits them entirely, and `undefined` there means "didn't
// answer", not "missing". In that case a non-empty bundle list is the only
// evidence available, and it is better than nothing.
return d.chromium_executable == null && d.browsers.length > 0;
}
+6 -5
View File
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
describe("chrome over a pane, with no dialog open", () => {
/** Everything that is painted over a pane and is not a blocker. */
const CHROME: Array<[string, () => HTMLElement]> = [
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
// host — so "does the pane contain what is painted here?" made the
// terminal's top-right corner a dead zone no user action could clear.
["the Following/Paused toggle", () => document.createElement("button")],
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
// and a *sibling* of the xterm host — so "does the pane contain what is
// painted here?" made the terminal's top-right corner a dead zone no
// user action could clear. (The retired Following toggle held the same
// corner and produced the original bug.)
["the mouse-release badge", () => document.createElement("button")],
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
// pane, and its error cards stay until dismissed.
["a toast card", () => document.createElement("div")],
+2 -2
View File
@@ -26,8 +26,8 @@
*
* - Asking `el.contains(document.elementFromPoint(x, y))` "is the thing
* painted here mine?" refused drops onto anything painted *over* a pane
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
* of the xterm host), the URL toast, `ToastHost`'s stack.
* Permanent dead zones no user action could clear.
* - Replacing that with "is a *blocking overlay* painted here?" removed the
* dead zones and opened a hole instead. `elementFromPoint` returns the
+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;
}
+50 -4
View File
@@ -1,5 +1,5 @@
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
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -350,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
// without deleting its volumes. Reset is the destructive alternative: it wipes
// ~/.claude, the OAuth credential, installed skills and every transcript.
//
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
// it on demand rather than polling) → migrateProjectToBase → the project sits
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
// rather than polling) → migrateProjectToBase → the project sits
// in "awaiting-confirmation" while the user tries it → confirmMigration or
// rollbackMigration.
//
@@ -361,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
//
// Progress arrives on the existing `container-progress` event.
/** Read-only. Runs two container/image filesystem probes; not for polling. */
/**
* Runs two container/image filesystem probes; not for polling.
*
* **Not read-only, despite only reporting.** When the container is *stopped*
* the backend has to commit its writable layer to a throwaway image before it
* can read anything `docker exec` needs a running container so this writes
* (and then removes) an image. The result is cached per stop, so repeat calls
* while the container stays stopped are cheap, but the first one after each stop
* pays for a commit of the whole layer: seconds on a small project, tens of
* seconds on a large one. Do not add a caller that fires more often than "the
* container settled into a new state" without re-reading
* `get_container_staleness`'s doc comment first.
*/
export const getContainerStaleness = (projectId: string) =>
invoke<ContainerStaleness>("get_container_staleness", { projectId });
@@ -386,3 +398,37 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
/** Open a URL in the user's own browser.
*
* Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On
* Linux the app ships as an AppImage whose environment leaks into everything
* it spawns, which kills a *cold-launched* browser before it paints while
* `xdg-open` still exits 0 so the plugin path reported success and did
* nothing (triple-c#34). The Rust side hands the child a repaired environment
* and re-validates the URL, which matters because these URLs originate in an
* untrusted container. macOS and Windows still reach the plugin, just from
* Rust, so there is no platform branch here.
*
* Rejects with a string already phrased for a toast. */
export const openUrlExternal = (url: string) =>
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;
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;
}
+3 -2
View File
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
* A direct port of `usable_sign_in_link` in
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
* only thing standing between any of this and `openUrl`, and duplicating its
* only thing standing between any of this and `openUrlExternal`, and
* duplicating its
* rules here would be a second place for them to go stale.
*
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
@@ -293,7 +294,7 @@ export class UrlDetector {
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
// swallowed into the middle of a match becomes a URL that renders as one
// thing in the toast and resolves as another. Everything emitted here is
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
// stopping the match early only means the legitimate prefix survives
// instead of the whole candidate being thrown away.
// eslint-disable-next-line no-control-regex
+51
View File
@@ -4,6 +4,7 @@ import {
MAX_RELAY_URL_LENGTH,
RelayRateLimiter,
URL_RELAY_OSC,
isAnthropicSignInUrl,
parseUrlRelayOsc,
sanitizeRelayUrl,
urlOrigin,
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
});
});
describe("isAnthropicSignInUrl", () => {
// Classification only. Where a sign-in link should be opened is decided by
// `hooks/useSignInOpenTarget.ts`, from facts about the project — this answers
// the narrower question of whether it is a sign-in link at all, and it does
// so through the same allowlist the sign-in flow itself uses.
it("recognises the links `claude setup-token` and `claude login` print", () => {
expect(
isAnthropicSignInUrl(
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
),
).toBe(true);
expect(
isAnthropicSignInUrl("https://platform.claude.com/oauth/code/callback?x=1"),
).toBe(true);
expect(isAnthropicSignInUrl("https://console.anthropic.com/login?x=1")).toBe(
true,
);
});
it("is not fooled by a host that merely contains an allowed domain", () => {
// The thing the allowlist exists for: `claude.ai.evil.tld` ends with
// neither `claude.ai` nor `.claude.ai`.
expect(isAnthropicSignInUrl("https://claude.ai.evil.tld/oauth/authorize")).toBe(
false,
);
expect(isAnthropicSignInUrl("https://notclaude.ai/login")).toBe(false);
});
it("holds the full validator, not just the host test", () => {
// It runs `sanitizeRelayUrl`, so everything that cannot be opened at all
// is not a sign-in link either — no separate, weaker copy of the rules.
expect(isAnthropicSignInUrl("javascript:claude.ai/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude.ai@evil.tld/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude\nai/login")).toBe(false);
});
it("does not claim every allowlisted URL is a sign-in", () => {
expect(isAnthropicSignInUrl("https://claude.ai/chat/abc")).toBe(false);
expect(isAnthropicSignInUrl("https://www.anthropic.com/news")).toBe(false);
});
it("leaves an ordinary link alone, whatever it says in its path", () => {
// A `gh auth login` device code is the common one, and sending it to a
// container-side browser would be actively wrong.
expect(isAnthropicSignInUrl("https://github.com/login/device?code=A")).toBe(
false,
);
});
});
+18 -6
View File
@@ -1,6 +1,7 @@
/**
* URL relay host side of `container/triple-c-open` and the single URL
* validator every `openUrl` call site in the app is required to go through.
* validator every `openUrlExternal` call site in the app is required to go
* through.
*
* A CLI inside the container has no browser. When it wants to open a URL
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
@@ -182,11 +183,22 @@ export function extendsUrl(next: string, current: string): boolean {
/**
* Whether this is a URL that signs the user in to Anthropic.
*
* Used to decide *presentation*, not permission the toast makes the
* container-side browser the default action for these, because the OAuth
* callback listener is inside the container and the host has nothing to catch
* it with. It is deliberately the same host allowlist the sign-in flow itself
* uses, so the two cannot disagree about what a sign-in link is.
* Classification only. It answers "is this a sign-in link", never "where should
* it be opened" that decision moved out to `hooks/useSignInOpenTarget.ts`,
* because it depends on things this module has no business knowing: whether the
* project's auth bridge is live, and whether a browser is actually installed in
* the container. This function stays here because the *rule* it encodes is a
* URL rule, and it is deliberately the same host allowlist the sign-in flow
* itself uses, so the two cannot disagree about what a sign-in link is.
*
* It used to carry the default with it container-side always, on the grounds
* that "the OAuth callback listener is inside the container and the host has
* nothing to catch it with". Both halves of that are now wrong. The host does
* have something to catch it with (the auth bridge mirrors the container's
* loopback listener onto the same host port), and the container-side target is
* not a general browser but Playwright's dashboard pane, whose browsers are
* deliberately not baked into the image so on a fresh project the default
* pointed at something that was not installed, on every platform.
*/
export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
+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 };
}
+14 -10
View File
@@ -205,16 +205,20 @@ interface AppState {
// UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
// Whether a program in the active terminal is holding mouse reporting open,
// and how to take it back. Surfaced so the release control can live in the
// status bar: painted over the terminal it would sit on top of whatever TUI
// is asking for the mouse, and swallow clicks aimed at that program's own
// top-right corner for as long as it ran. Only the active TerminalView
// writes these.
terminalMouseCaptured: boolean;
setTerminalMouseCaptured: (captured: boolean) => void;
releaseActiveMouse: () => void;
setReleaseActiveMouse: (fn: () => void) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "settings";
setSidebarView: (view: "projects" | "settings") => void;
sidebarCollapsed: boolean;
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
// UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
terminalMouseCaptured: false,
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
releaseActiveMouse: () => {},
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),
+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 react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -22,4 +23,12 @@ export default defineConfig({
ignored: ["**/src-tauri/**"],
},
},
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("index.html", import.meta.url)),
viewer: fileURLToPath(new URL("viewer.html", import.meta.url)),
},
},
},
});
+7 -1
View File
@@ -639,8 +639,14 @@ fi
# any terminal session launches `claude`. Runs as the claude user (the CLI is
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
# slow or offline network never blocks container readiness.
# The lock is shared with the per-session update that every Claude terminal
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
# "Container ready" is printed *after* this finishes, so a user who starts a
# project and immediately opens a tab would otherwise have two updaters
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
# damage right before it ran the result.
echo "entrypoint: checking for Claude Code updates..."
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
&& echo "entrypoint: Claude Code is up to date" \
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More