Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s
Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label. Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #60.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
# App-command lockdown via Tauri AppManifest — design
|
||||
|
||||
Date: 2026-09-22
|
||||
Status: approved in conversation (user); revised against the implemented viewer (`bf22910`) and
|
||||
the review rulings in "Decisions made during review"; plan at
|
||||
`docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md`
|
||||
Follows: `2026-09-22-terminal-file-viewer-design.md` (the viewer windows this exists to confine)
|
||||
|
||||
## Goal
|
||||
|
||||
Make every application command (the `generate_handler!` list in `lib.rs`) permission-gated per
|
||||
window, so a `file-viewer-*` window can invoke exactly its five `viewer_*` commands and nothing
|
||||
else, and the `main` window keeps exactly what it invokes today. Close the residual risk the
|
||||
viewer spec records in its §6: "a compromised viewer window can still invoke other app commands,
|
||||
because `build.rs` does not restrict app commands per window."
|
||||
|
||||
Non-goals: scoping arguments of app commands (the ACL only allows/denies command names; argument
|
||||
validation stays in Rust as today), changing any plugin grant, `removeUnusedCommands` (see §3.6).
|
||||
|
||||
## 1. Current state (verified)
|
||||
|
||||
- `app/src-tauri/build.rs` is `tauri_build::build()`, i.e. `try_build(Attributes::default())`
|
||||
with an empty `AppManifest` (tauri-build 2.6.0 `src/lib.rs:454`).
|
||||
- Versions from `Cargo.lock`: tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0,
|
||||
tauri-codegen 2.6.0, tauri-macros 2.6.0. Everything below is read from those sources under
|
||||
`~/.cargo/registry/src/index.crates.io-*/`.
|
||||
- 115 commands are registered in `generate_handler!` (`lib.rs:469-607`): the 109 that predate
|
||||
the viewer plus its six (`open_file_viewer`, `viewer_get_state`, `viewer_choose_file`,
|
||||
`viewer_read_file`, `viewer_poll_file`, `viewer_write_file`). All 115 are invoked from
|
||||
`app/src`, every one through an `export const` wrapper in `lib/tauri-commands.ts`, which is
|
||||
the only non-test file that imports `@tauri-apps/api/core` (the earlier direct
|
||||
`invoke("terminal_input")` in `hooks/useTerminal.ts` is gone; only a comment mentions it).
|
||||
No invoke name exists without a registration. None uses `#[command(rename = …)]`, so the
|
||||
invoke name is always the function identifier.
|
||||
- `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions.
|
||||
`capabilities/file-viewer.json` is `windows: ["file-viewer-*"]` and grants four core
|
||||
permissions. `tauri.conf.json` has no `app.security.capabilities` key, so every file under
|
||||
`capabilities/` is active (tauri-build `acl.rs:424-429`).
|
||||
- Both files already have an exact-set census test: `the_capability_grants_are_the_ones_that_were_reviewed`
|
||||
(`lib.rs:924`) for `default.json`, and `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`
|
||||
plus `the_default_capability_is_scoped_to_the_main_window_only` (`file_viewer/mod.rs:63,89`)
|
||||
for `file-viewer.json` and the `windows` lists. All three will fail as written the moment
|
||||
bare `allow-*` grants appear and are updated in the same change (§3.4).
|
||||
- The viewer's five `viewer_*` commands take `window: tauri::Window` and gate on the caller's
|
||||
label inside `commands/file_viewer_commands.rs`; `open_file_viewer` is main-only by the same
|
||||
mechanism. The `default.json` description, `file-viewer.json` description and `CLAUDE.md`
|
||||
(Backend Structure and Key Conventions) all currently describe app commands as ungated by
|
||||
capability and point at this spec as the pending fix; all three are rewritten here (§3.5).
|
||||
- The only other non-main window is the browser-view pop-out, label `browser-view-<id>`
|
||||
(`browser_view/popout.rs:71,104`), `WebviewUrl::External`. No capability names it and no
|
||||
capability has `remote`, so it has no IPC at all — before and after this change.
|
||||
- `gen/schemas/acl-manifests.json` and `gen/schemas/capabilities.json` are **tracked in git**
|
||||
and rewritten by tauri-build on every build; the app manifest will show up there as a
|
||||
reviewable diff.
|
||||
- CI (`.gitea/workflows/build-app-preview.yml`, `build-app.yml`) runs `npm run build`
|
||||
(`tsc && vite build`) and `tauri build`. It runs **neither `cargo test` nor `vitest`**. Any
|
||||
check that must hold on every merge therefore has to fail the *build*, not a test.
|
||||
|
||||
## 2. Mechanism (tauri-build 2.6.0 / tauri-utils 2.9.0 / tauri 2.11.0)
|
||||
|
||||
### 2.1 What `AppManifest::commands` generates
|
||||
|
||||
```rust
|
||||
tauri_build::try_build(
|
||||
tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(&["check_docker", /* … */])),
|
||||
)
|
||||
```
|
||||
|
||||
- `commands` takes `&'static [&'static str]` (`tauri-build/src/acl.rs:87-114`).
|
||||
- `app_manifest_permissions` (`acl.rs:265-335`) calls
|
||||
`tauri_utils::acl::build::autogenerate_command_permissions`, which for each command writes
|
||||
**into the source tree** at `src-tauri/permissions/autogenerated/<command>.toml`
|
||||
(`tauri-utils/src/acl/build.rs:289-317`):
|
||||
|
||||
```toml
|
||||
[[permission]]
|
||||
identifier = "allow-<command with _ → ->"
|
||||
commands.allow = ["<command verbatim>"]
|
||||
[[permission]]
|
||||
identifier = "deny-<command with _ → ->"
|
||||
commands.deny = ["<command verbatim>"]
|
||||
```
|
||||
|
||||
Naming: only `_` is replaced by `-` (`build.rs:290`). `viewer_read_file` →
|
||||
`allow-viewer-read-file`. Identifiers may not contain `_` (`identifier.rs:96-109`), so the
|
||||
kebab form is mandatory in capability files; the command name inside stays snake_case.
|
||||
- Then `permissions/**/*` is globbed too (default `permissions_path_pattern`), so any hand-written
|
||||
`.toml`/`.json` under `permissions/` is also part of the app manifest. **Stale files are never
|
||||
deleted**: a command dropped from `.commands()` leaves its `.toml` behind, still defining a
|
||||
permission a capability can reference. §3.2 handles this.
|
||||
- No app `default` permission set is generated (`acl.rs` has no such logic; only
|
||||
`InlinedPlugin` has `DefaultPermissionRule`).
|
||||
- The manifest is stored under key `__app-acl__` (`APP_ACL_KEY`, `tauri-utils/src/acl/mod.rs:50`)
|
||||
in `gen/schemas/acl-manifests.json`, copied to `OUT_DIR/acl-manifests.json`, and embedded by
|
||||
`generate_context!` (`tauri-codegen/src/context.rs:393-417`, `Resolved::resolve`).
|
||||
- The app manifest exists iff it has at least one permission, permission set or default
|
||||
(`acl.rs:400-443`, `has_app_manifest`). `.commands(&[])` is the same as no manifest.
|
||||
|
||||
### 2.2 How capabilities reference app permissions
|
||||
|
||||
- Bare identifier, no prefix: `"allow-viewer-read-file"`. Resolution takes
|
||||
`permission_id.get_prefix().unwrap_or(APP_ACL_KEY)` (`resolved.rs:340-372`); `core:event:…`
|
||||
and `dialog:…` keep working unchanged.
|
||||
- `windows` / `webviews` are glob patterns via `glob::Pattern` (`resolved.rs:199-208`,
|
||||
matched in `authority.rs:460-461`), so `"file-viewer-*"` is valid. Labels are **not**
|
||||
validated against configured windows at build time (no such check in tauri-build or
|
||||
tauri-codegen).
|
||||
- `local` defaults to `true`; both the bundled `tauri://localhost` document and the Vite
|
||||
`devUrl` count as local (`webview/mod.rs:1698-1740`, `is_local_url`: tauri protocol, or
|
||||
relative to `devUrl`/`frontendDist`). The viewer's `viewer.html` is served the same way as
|
||||
`index.html`, so it is local in both dev and release.
|
||||
|
||||
### 2.3 Runtime enforcement
|
||||
|
||||
`Webview::on_message` (`tauri-2.11.0/src/webview/mod.rs:1794-1849`):
|
||||
|
||||
```rust
|
||||
// we only check ACL on plugin commands or if the app defined its ACL manifest
|
||||
if (plugin_command.is_some() || has_app_acl_manifest)
|
||||
&& request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
|
||||
&& invoke.acl.is_none()
|
||||
{ /* reject */ }
|
||||
```
|
||||
|
||||
- **Today** (`has_app_acl_manifest == false`): app commands skip the ACL entirely. That is the
|
||||
hole.
|
||||
- **With a manifest**: `resolve_access` (`ipc/authority.rs:439-471`) must return an allow whose
|
||||
capability `windows`/`webviews` glob matches the caller and whose context matches the origin.
|
||||
Otherwise the invoke is rejected **before** the `generate_handler!` closure runs; the
|
||||
`#[command]` wrappers do no allow/deny logic of their own.
|
||||
- **Unlisted command = denied.** A registered command with no `allow-*` permission anywhere never
|
||||
enters `allowed_commands`, so `resolve_access` returns `None`. Debug message:
|
||||
`"<cmd> not allowed. Command not found"`. There is no build-time warning for this case.
|
||||
- **Listed but not registered** = a permission nobody can use; no build error. If granted and
|
||||
invoked, the handler's fallthrough returns "unhandled" — harmless, but §3.2 rejects it anyway.
|
||||
- **`deny-*` is global.** `resolve_access` tests `denied_commands.get(cmd).map(..).is_some()`,
|
||||
which is true for any deny entry regardless of window or origin. Never grant a `deny-*` in the
|
||||
viewer capability expecting it to stay confined; the design uses allow-lists only, and the
|
||||
build check bans `deny-*` outright.
|
||||
- Denial text: debug builds get `resolve_access_message` (`authority.rs:229-437`), e.g.
|
||||
`"<cmd> not allowed. Permissions associated with this command: allow-<slug>"` or
|
||||
`"<cmd> not allowed on window \"file-viewer-3\", … allowed on: [windows: \"main\", …]"`.
|
||||
Release builds get `"Command <cmd> not allowed by ACL"`. The frontend's `refusalText.ts`
|
||||
wrapper-stripping already tolerates the `invoke failed:`/`Error:` prefixes.
|
||||
|
||||
### 2.4 Build-time failure modes
|
||||
|
||||
- Unknown bare permission in a capability file (typo, or a command missing from the manifest):
|
||||
`validate_capabilities` (`tauri-build/src/acl.rs:353-392`) bails with
|
||||
`Permission <id> not found, expected one of …`; `build()` exits 1. This also fires for any
|
||||
bare identifier when there is no app manifest at all.
|
||||
- Capabilities inlined in `tauri.conf.json` are *not* validated there and fail later in
|
||||
`generate_context!` as a panic (`failed to resolve ACL`). Keep capabilities as files.
|
||||
- Duplicate capability identifiers across files are an error (`build.rs:204-244`).
|
||||
- A bad glob in `windows` is a resolve-time error.
|
||||
- The build script's CWD is `src-tauri`; the `./permissions/autogenerated` path is relative to it.
|
||||
|
||||
### 2.5 Dev vs release
|
||||
|
||||
Enforcement is identical. `cfg(debug_assertions)` changes only the error text and whether the
|
||||
raw ACL map is retained for messages (`authority.rs:29-30, 77-101`). `tauri dev` versus `tauri
|
||||
build` differs only in `custom-protocol`, which does not touch the ACL. A command missing from a
|
||||
capability is therefore caught the first time anyone opens the affected screen in `tauri dev`,
|
||||
with a message naming the permission.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Capability files
|
||||
|
||||
`capabilities/default.json` (unchanged `windows: ["main"]`) gains one bare `allow-<slug>` entry
|
||||
per main-window command — 110: the 109 that predate the viewer plus `open_file_viewer`. The
|
||||
existing five plugin/core grants stay exactly as they are. The `description` is amended (§3.5).
|
||||
|
||||
`capabilities/file-viewer.json` (exists, `windows: ["file-viewer-*"]`, four core grants)
|
||||
gains exactly `allow-viewer-get-state`, `allow-viewer-choose-file`, `allow-viewer-read-file`,
|
||||
`allow-viewer-poll-file`, `allow-viewer-write-file`, beside its four core grants.
|
||||
|
||||
No permission sets, no `default`, no hand-written files under `permissions/`. The project's
|
||||
existing test already refuses `*:default` aliases because they expand to sets the file does
|
||||
not spell out; the same rule applies to app permissions. Enumeration is the point.
|
||||
|
||||
Assignment of every command (110 go to **main**; nothing but the viewer's five goes to
|
||||
**file-viewer**). The rule is a function, not a table: a command whose name starts with
|
||||
`viewer_` belongs to `file-viewer-*`, every other command belongs to `main`
|
||||
(`command_census::expected_windows`, §3.2). The table is the rule applied to today's list:
|
||||
|
||||
| Group (`lib.rs` comment) | Commands | Window |
|
||||
|---|---|---|
|
||||
| Docker | `check_docker`, `check_image_exists`, `build_image`, `get_container_info` | main |
|
||||
| Projects | `list_projects`, `add_project`, `remove_project`, `update_project`, `start_project_container`, `stop_project_container`, `rebuild_project_container`, `reconcile_project_statuses` | main |
|
||||
| Notes | `list_notes`, `save_note`, `delete_note` | main |
|
||||
| Migration | `get_container_staleness`, `migrate_project_to_base`, `confirm_migration`, `rollback_migration`, `get_migration_state` | main |
|
||||
| Auth bridge | `set_auth_bridge_enabled`, `get_auth_bridge_status` | main |
|
||||
| Browser view | `set_browser_view_enabled`, `get_browser_view_status`, `check_browser_view_support`, `install_browser_view_support`, `install_browser_view_browser`, `open_browser_view_popout`, `close_browser_view_popout`, `get_browser_view_popout_state`, `set_browser_view_popout_always_on_top`, `open_page_in_container_browser`, `set_container_page_viewport`, `get_container_page_state`, `close_container_page`, `set_browser_view_match_window`, `get_browser_view_match_window` | main |
|
||||
| Claude token | `acquire_claude_token`, `submit_claude_token_code`, `cancel_claude_token`, `has_claude_token`, `clear_claude_token`, `sweep_claude_token_snapshots` | main |
|
||||
| Settings | `get_settings`, `update_settings`, `pull_image`, `detect_aws_config`, `inspect_ca_cert_path`, `list_aws_profiles`, `detect_host_timezone` | main |
|
||||
| Settings export/import | `export_settings`, `preview_settings_import`, `apply_settings_import` | main |
|
||||
| Terminal | `open_terminal_session`, `terminal_input`, `terminal_resize`, `close_terminal_session`, `paste_image_to_terminal`, `upload_host_file_to_terminal`, `start_audio_bridge`, `send_audio_data`, `stop_audio_bridge` | main |
|
||||
| Files | `list_container_files`, `download_container_backup`, `download_container_file`, `upload_files_to_container`, `read_container_file`, `rename_container_path`, `create_container_directory` | main |
|
||||
| AWS | `aws_sso_refresh` | main |
|
||||
| Updates | `get_app_version`, `check_for_updates`, `check_image_update` | main |
|
||||
| Help | `get_help_content` | main |
|
||||
| URL open | `open_url_external` | main |
|
||||
| Install helper | `detect_install_options`, `run_docker_install` | main |
|
||||
| Web terminal | `start_web_terminal`, `stop_web_terminal`, `get_web_terminal_status`, `regenerate_web_terminal_token` | main |
|
||||
| STT | `get_stt_status`, `start_stt`, `stop_stt`, `build_stt_image`, `pull_stt_image`, `transcribe_audio` | main |
|
||||
| Gateway | `get_gateway_status`, `start_gateway`, `stop_gateway`, `check_gateway_health`, `build_gateway_image`, `pull_gateway_image`, `set_gateway_api_key`, `clear_gateway_api_key`, `get_gateway_auth_token`, `regenerate_gateway_auth_token` | main |
|
||||
| Inspect | `list_claude_sessions`, `resume_session_command`, `list_container_capabilities`, `list_scheduled_tasks`, `add_scheduled_task`, `update_scheduled_task`, `get_scheduled_task_log`, `set_scheduled_task_enabled`, `run_scheduled_task_now`, `remove_scheduled_task`, `get_scheduler_notifications`, `clear_scheduler_notifications` | main |
|
||||
| Terminal file viewer | `open_file_viewer` | main |
|
||||
| Terminal file viewer | `viewer_get_state`, `viewer_choose_file`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file` | file-viewer |
|
||||
|
||||
Windows other than main: `browser-view-*` (pop-out) invokes nothing and must stay listed in no
|
||||
capability — the pane's iframe inside main is a remote origin and has no IPC either. The web
|
||||
terminal is a WebSocket server, not IPC, and is unaffected.
|
||||
|
||||
The Rust label checks in the viewer commands (`open_file_viewer` main-only; `viewer_*`
|
||||
caller's-own-label-only) stay. The ACL says *which* window may call; the label check says *whose*
|
||||
registry entry it operates on. They are not redundant.
|
||||
|
||||
### 3.2 `build.rs`: manifest derived from `generate_handler!`, checked against capabilities
|
||||
|
||||
The manifest must not be a second hand-maintained list. The logic lives in a new
|
||||
`src/command_census.rs` — plain functions over strings and `serde_json::Value`, no reference to
|
||||
the crate — that is compiled into **both** the build script (`#[path = "src/command_census.rs"]
|
||||
mod command_census;` in `build.rs`) and the test build (`#[cfg(test)] mod command_census;` in
|
||||
`lib.rs`), so the parser the build uses is the parser the tests test and the two cannot drift.
|
||||
Its surface:
|
||||
|
||||
```rust
|
||||
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>; // handler list, in order, duplicates kept
|
||||
pub fn allow_permission(command: &str) -> String; // "viewer_read_file" → "allow-viewer-read-file"
|
||||
pub fn expected_windows(command: &str) -> &'static [&'static str]; // "viewer_*" → ["file-viewer-*"], else ["main"]
|
||||
pub struct CapabilityFile { pub name: String, pub windows: Vec<String>, pub bare: Vec<String> }
|
||||
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>;
|
||||
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String>; // every violation, or empty
|
||||
```
|
||||
|
||||
`build.rs` becomes:
|
||||
|
||||
1. `registered_commands` is the line-based extraction the existing
|
||||
`every_command_is_registered_exactly_once` test uses today (`lib.rs:849-870`), moved
|
||||
verbatim; that test calls it too. `println!("cargo:rerun-if-changed=…")` for `src/lib.rs`,
|
||||
`src/command_census.rs` and the `capabilities` directory.
|
||||
2. **Fail closed on an empty or malformed parse.** An empty list makes `.commands(&[])` a no-op
|
||||
and silently restores today's unguarded state, so `check` reports (and `build.rs` exits 1 on)
|
||||
an empty list, a name outside `[a-z0-9_]+`, or a duplicate.
|
||||
3. Delete every `permissions/autogenerated/<name>.toml` whose `<name>` is not a registered
|
||||
command before calling tauri-build, so a command removed from `lib.rs` cannot leave a stale
|
||||
permission behind. **Not** `remove_dir_all`: tauri-build emits
|
||||
`cargo:rerun-if-changed=permissions` (spike §7.I), so regenerating every file on every build
|
||||
would give them fresh mtimes and re-run the build script — and recompile the crate — on
|
||||
every `cargo` invocation forever. Live files are left alone; tauri-utils only rewrites them
|
||||
when their content changes (`write_if_changed`, `acl/build.rs:308`), so the build settles.
|
||||
Also refuse anything under `permissions/` other than `autogenerated/`: tauri-build globs
|
||||
hand-written permission files into the manifest, which would be a second source of grants
|
||||
that the census does not see. Add `app/src-tauri/permissions/autogenerated/` to
|
||||
`.gitignore`; the reviewable artifact is `gen/schemas/acl-manifests.json`, already tracked.
|
||||
4. Read every `capabilities/*.json` with `capability_file` (bare = identifiers without `:`;
|
||||
scoped-object grants are read by their `identifier`), and `check` asserts:
|
||||
- every registered command's `allow-<slug>` appears in **exactly one** capability file
|
||||
(missing → names the command and the `windows` of the file it belongs in; more than one →
|
||||
names the files);
|
||||
- every bare identifier is `allow-<slug>` of a registered command (a typo is named; tauri's
|
||||
own `validate_capabilities` would also fail, but its message dumps every generated
|
||||
`allow-*`/`deny-*` identifier);
|
||||
- no bare `deny-*` (global, §2.3) and nothing else bare that is not `allow-*` (so no bare
|
||||
`default`);
|
||||
- the file that grants a command has `windows` equal to `expected_windows(command)` — the
|
||||
"which side does this belong to" rule decided in one place, and the reason the `viewer_*`
|
||||
naming prefix is now load-bearing.
|
||||
All violations are reported together, so ten forgotten grants are one build failure.
|
||||
5. `tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(leaked)))`
|
||||
where `leaked: &'static [&'static str]` comes from `Box::leak` (ruling: acceptable; the
|
||||
process is short-lived and it is two lines), propagating the error the way
|
||||
`tauri_build::build()` does (print and exit 1).
|
||||
|
||||
Because this runs inside the build script, it fails `cargo check`, `cargo test`, `tauri dev`
|
||||
and CI's `tauri build` alike — the one place a check is guaranteed to run on every merge (§1).
|
||||
`serde_json` is already a dependency and is added to `[build-dependencies]` (already in
|
||||
`Cargo.lock`, so `--offline` still resolves).
|
||||
|
||||
Why "exactly one" rather than "at least one": today no command is shared between windows, and
|
||||
a command that genuinely needs both is a design change worth a visible edit to
|
||||
`expected_windows`, not a silent widening.
|
||||
|
||||
### 3.3 Frontend check (vitest)
|
||||
|
||||
`app/src/test/capabilities.test.ts`, following `icon-config.test.ts`'s pattern of reading
|
||||
`src-tauri/` files with `readFileSync`. The earlier draft split invoke literals by directory;
|
||||
that is wrong for the tree as built, because the viewer does not call `invoke` itself — it
|
||||
imports wrappers from the shared `lib/tauri-commands.ts` (`viewerGetState`, `viewerChooseFile`
|
||||
in `ViewerApp.tsx`; `viewerReadFile`, `viewerPollFile`, `viewerWriteFile` in `EditorPane.tsx`).
|
||||
So the test follows imports instead, and does so by parsing every file with the TypeScript
|
||||
compiler (`ts.createSourceFile`), not a regex scan, so comments, strings, template substitutions
|
||||
and regex literals are the parser's problem rather than ours:
|
||||
|
||||
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every*
|
||||
code file under `src/` — tests included, not just the files the two closures below cover,
|
||||
because the viewer's closure can reach anything a relative import can. A specifier this scan
|
||||
cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender
|
||||
too, so an un-auditable dynamic import fails the same way a literal one to
|
||||
`@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file
|
||||
can invoke.
|
||||
- The wrapper map is read from `tauri-commands.ts`'s AST: each exported `const NAME = …` must
|
||||
call `invoke` exactly once, inside its own function body (not at module load, not inside an
|
||||
IIFE, not as a default argument), with a string-literal command name matching Rust's
|
||||
`[a-z0-9_]+` (115 wrappers, 115 commands today). Zero or more than one call, a non-literal
|
||||
argument, `invoke` referenced other than as a direct call, or any dynamic `import()`/`require()`
|
||||
in the file at all, fails the test.
|
||||
- Every other module edge is resolved the way Vite 6 actually resolves it, in Vite's own order:
|
||||
the exact relative path if it names a file; else, for a `.js`/`.mjs`/`.cjs`/`.jsx` path, its
|
||||
TypeScript twin (`.js`→`.ts`, then `.tsx`); else `path + ext` over `resolve.extensions`
|
||||
(`.mjs .js .mts .ts .jsx .tsx .json`) in that order; else, for a directory, `index + ext` in the
|
||||
same order. A path alias, a query/fragment suffix (`?worker`, `?raw`), a directory import that
|
||||
would switch Vite to package-entry resolution, or a relative import that resolves outside
|
||||
`src/`, all fail the test rather than being silently skipped or misresolved — resolution is
|
||||
fail-closed, not best-effort.
|
||||
- A wrapper counts as used when a file names it directly (a named import, a named re-export by
|
||||
name) or reaches it through a namespace import (`import * as X from ".../tauri-commands"`)
|
||||
**used only as member access**, `X.name` (including `typeof X.name` in a type position). Any
|
||||
other use of that namespace binding — passed to a function, spread, indexed, aliased again —
|
||||
fails closed rather than being read as "no wrapper used"; `export *`/`export * as` of the
|
||||
wrappers file is refused outright, since it cannot be resolved to specific names.
|
||||
- The **viewer closure** is the transitive set of module targets reachable from
|
||||
`src/viewer/main.tsx` under that same fail-closed resolution (static imports, `export … from`,
|
||||
literal `import()`; tests excluded, non-code assets ignored). Today it reaches
|
||||
`lib/tauri-commands`, `lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
|
||||
`components/projects/home/filePreview`, none of which import a wrapper except the two viewer
|
||||
files. `V` = commands of the wrappers those files reach; `M` = commands of the wrappers reached
|
||||
by every other non-test file under `src/`.
|
||||
- Assert `M ∩ V = ∅`; `allow-<slug>(V)` **equals** the bare set of `file-viewer.json` (no
|
||||
over-grant to the untrusted window); `allow-<slug>(M)` is a **subset** of the bare set of
|
||||
`default.json`; and every wrapper's command is in exactly one of the two files (a cheap mirror
|
||||
of the Rust rule, so a developer running only vitest sees the same failure).
|
||||
- `default.json`'s five plugin/core grants and `file-viewer.json`'s four core grants are
|
||||
asserted as exact lists, the same census style as the Rust tests.
|
||||
|
||||
This is the test that turns "a forgotten command silently breaks a feature at runtime" into a
|
||||
red test on the developer's machine for the *window* dimension: the Rust build check (§3.2)
|
||||
proves every command is granted in the file its name says it belongs in; this one proves the
|
||||
code that runs in each window only reaches wrappers that window is granted.
|
||||
|
||||
### 3.4 Rust test updates
|
||||
|
||||
- `lib.rs` `the_capability_grants_are_the_ones_that_were_reviewed`: split `listed` into prefixed
|
||||
and bare. The prefixed census stays the exact five-element list. The bare set must equal
|
||||
`allow_permission` over the registered commands whose `expected_windows` is `["main"]`
|
||||
(110 today). Keep the `:default` and `store:` refusals.
|
||||
- `file_viewer/mod.rs` `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`:
|
||||
the exact list becomes the four core grants plus `allow-viewer-get-state`,
|
||||
`allow-viewer-choose-file`, `allow-viewer-read-file`, `allow-viewer-poll-file`,
|
||||
`allow-viewer-write-file`. `the_default_capability_is_scoped_to_the_main_window_only` is
|
||||
unchanged.
|
||||
- New `lib.rs` test `the_generated_app_manifest_matches_the_handler_list`: `cargo test` runs
|
||||
`build.rs`, so `gen/schemas/acl-manifests.json` is fresh; assert its `__app-acl__` entry's
|
||||
permission identifiers equal `allow-<slug>` ∪ `deny-<slug>` over the registered set exactly.
|
||||
This is the end-to-end proof that the parse in `build.rs` produced what tauri embedded,
|
||||
independent of the parser it shares with §3.2.
|
||||
- `every_command_is_registered_exactly_once` is unchanged apart from calling
|
||||
`command_census::registered_commands` instead of its inline copy.
|
||||
- `command_census.rs` carries its own unit tests for `check` (missing grant, duplicate grant,
|
||||
typo, `deny-*`, wrong file, empty list, bad name) — these are the tests that make the build
|
||||
check trustworthy, and they run under `cargo test` without needing a failing build.
|
||||
|
||||
### 3.5 Documentation updates (same change)
|
||||
|
||||
- `capabilities/default.json` `description` (the threat model of record): replace the sentence
|
||||
run that begins "App commands stay ungated by capability in both files" and ends "not yet
|
||||
built" with the closed state: `build.rs` declares an `AppManifest`, so every app command is
|
||||
ACL-gated per window; the bare `allow-*` entries are the complete list of app commands the
|
||||
main window may call and are cross-checked by `build.rs` against `generate_handler!` (the
|
||||
build fails on a missing, misspelled, duplicated or misfiled grant); the `file-viewer-*`
|
||||
windows are confined to `capabilities/file-viewer.json`; `deny-*` is global in tauri 2.11.0
|
||||
and is therefore banned by the same check; the pop-out remains capability-less; and the
|
||||
Rust label gates in `file_viewer_commands.rs` stay because the ACL says *which* window and
|
||||
the label says *whose* entry. Amend the opening "verified against tauri 2.11.0's `PLUGINS`
|
||||
table" framing so it no longer implies app commands are outside this file.
|
||||
- `capabilities/file-viewer.json` `description`: replace "app commands need no entry here and
|
||||
are gated by label inside `commands/file_viewer_commands.rs`" with: the five `allow-viewer-*`
|
||||
grants are the only app commands this window can reach, the label gate is still what stops
|
||||
window A acting on window B's entry, and `build.rs` refuses any other bare grant here.
|
||||
- `CLAUDE.md` "Key Conventions" (`CLAUDE.md:598-600`): the bullet saying
|
||||
`capabilities/default.json` grants plugin commands only and app commands "do not need an
|
||||
entry there" becomes false. Replace with: a new command needs (1) `#[tauri::command]`, (2) a
|
||||
`generate_handler!` entry, (3) an `allow-<kebab-name>` entry in the one capability file for
|
||||
the window that calls it (`viewer_*` → `file-viewer.json`, everything else →
|
||||
`default.json`); `cargo check`/`tauri build` fail otherwise, and
|
||||
`src/test/capabilities.test.ts` fails if the code that runs in a window imports a wrapper that
|
||||
window is not granted. Note the `_`→`-` rule, that `deny-*` is off-limits, and that
|
||||
`permissions/autogenerated/` is generated and ignored.
|
||||
- `CLAUDE.md` Backend Structure (`CLAUDE.md:179-180`): the `file_viewer/` bullet's "residual
|
||||
risk … the AppManifest lockdown spec closes it" becomes "closed by `build.rs`'s
|
||||
`AppManifest`"; add one bullet for `build.rs` + `command_census.rs`.
|
||||
- `file_viewer/mod.rs` module doc: one sentence that `file-viewer-*` is also the capability
|
||||
glob, so labels are minted only here.
|
||||
- `.gitignore`: `app/src-tauri/permissions/autogenerated/`.
|
||||
|
||||
### 3.6 Deliberately not done
|
||||
|
||||
- `build.removeUnusedCommands` stays off. It compiles out commands no capability grants
|
||||
(`tauri-macros/src/command/handler.rs:92-142`), but only when the CLI sets
|
||||
`REMOVE_UNUSED_COMMANDS`, so `cargo test` and `tauri build` would compile different handler
|
||||
lists, and it matches on the function identifier rather than a `rename`. The §3.2 check gives
|
||||
the same guarantee at build time without a second code path.
|
||||
- No `permissions/*.toml` written by hand, no permission sets. Every grant is a literal
|
||||
`allow-<slug>` string in a capability file, so the census tests stay a flat string compare.
|
||||
- No `webviews` patterns: this app has one webview per window.
|
||||
|
||||
## 4. Migration risks
|
||||
|
||||
| Risk | Where it bites | How it is caught |
|
||||
|---|---|---|
|
||||
| A registered command is left out of `default.json` | Runtime: that feature's invoke rejected with `not allowed. Permissions associated with this command: allow-…` (release: `Command … not allowed by ACL`) | `build.rs` §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
|
||||
| Typo in an `allow-*` string | Build | tauri's `validate_capabilities` and the §3.2 check, both at build time |
|
||||
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts `acl-manifests.json` has the full set |
|
||||
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 selectively prunes only the files whose `<name>` is no longer a registered command, every build (never `remove_dir_all` — Decision 9); live files are left alone so the build settles; the directory is gitignored |
|
||||
| A `deny-*` added "for the viewer only" | Denies the command for main too (global) | §3.2 rejects any bare `deny-*` |
|
||||
| `file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 `expected_windows` assertion and §3.3 equality check |
|
||||
| Main-window code imports a `viewer*` wrapper (or the viewer imports a main one) | Runtime denial in that window | §3.3 `M ∩ V = ∅` and the per-side set checks |
|
||||
| A future window label that happens to match `file-viewer-*` | Inherits the viewer grants | Labels are minted only in `file_viewer/`; note in that module's doc comment |
|
||||
| Existing census tests (`lib.rs:924`, `file_viewer/mod.rs:63`) | Fail as written once bare grants appear | Updated in the same commit (§3.4) |
|
||||
| Capability grants and `build.rs` land in different commits | Bare grants without a manifest fail the build; a manifest without grants builds but denies every command at runtime | One commit carries `build.rs`, both capability files and the census-test updates (plan Task 2) |
|
||||
| `gen/schemas/acl-manifests.json` churn in diffs | Noise | Accepted; it is the reviewable evidence of what got embedded |
|
||||
| Frontend invoked via a computed name | Un-auditable | §3.3 fails on a non-literal `invoke` argument |
|
||||
|
||||
Dev vs release: no behavioural difference (§2.5), so `tauri dev` is a faithful rehearsal. The
|
||||
only visible difference is the error text.
|
||||
|
||||
## 5. Testing and manual verification
|
||||
|
||||
Automated (all must be green):
|
||||
- `cd app/src-tauri && cargo test --offline`: §3.4 tests plus the existing census tests.
|
||||
- `cd app && npx vitest run src/test/capabilities.test.ts` and the full `npm run test`.
|
||||
- `npx tsc --noEmit`, `cargo clippy --offline` clean, `npm run build`.
|
||||
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
|
||||
(a) remove one `allow-*` from `default.json` → `cargo check --offline` fails naming the
|
||||
command; (b) misspell one → fails naming it; (c) add `deny-check-docker` → fails; (d) move
|
||||
`allow-viewer-read-file` into `default.json` → fails the `expected_windows` assertion;
|
||||
(e) import `listProjects` from `lib/tauri-commands` in `src/viewer/ViewerApp.tsx` → vitest
|
||||
fails; (f) temporarily make `registered_commands` return `Some(vec![])` → the build fails
|
||||
on the empty-list rule rather than building an unguarded app.
|
||||
|
||||
Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppImage):
|
||||
1. Cold start: projects list, Docker status, settings, help all render (each is an app command
|
||||
on the main window).
|
||||
2. Open a terminal, type, resize, paste an image, drop a file — the terminal group.
|
||||
3. Files tab: list, view, rename, upload, save-to-host; Backup.
|
||||
4. Browser view: enable, pop out, resize, close pop-out; in the pop-out's devtools,
|
||||
`window.__TAURI_INTERNALS__?.invoke("list_projects")` must reject (remote origin, no
|
||||
capability) exactly as before this change.
|
||||
5. File viewer: click a path → window opens, reads, "Choose" list when ambiguous, edits, saves,
|
||||
conflict banner, live reload on an external change (that is `viewer_poll_file`); then in
|
||||
the viewer's devtools run
|
||||
`window.__TAURI_INTERNALS__.invoke("list_projects")` and
|
||||
`…invoke("read_container_file", {...})` — both must reject with
|
||||
`not allowed on window "file-viewer-1"`. From the main window's devtools,
|
||||
`invoke("viewer_read_file")` must reject likewise.
|
||||
6. Settings export/import, gateway, STT, web terminal, scheduler tabs — one action each.
|
||||
7. Watch the app log for `not allowed` during the whole pass; any occurrence is a missed grant.
|
||||
|
||||
## 6. Open questions — resolved
|
||||
|
||||
1. **CI test steps: out of scope** (controller ruling). CI runs no test suite (§1). The
|
||||
build-time check (§3.2) covers the security property on every merge because it runs inside
|
||||
`tauri build`; the vitest half (§3.3) and the `cargo test` half (§3.4) only run on a
|
||||
developer's machine. Recommendation to the user, not part of this change: add
|
||||
`cd app && npm run test` and `cd app/src-tauri && cargo test` steps to
|
||||
`build-app-preview.yml` after the dependency install.
|
||||
2. **`Box::leak` in `build.rs`: acceptable** (controller ruling; either was allowed). Chosen
|
||||
over a generated include file because it is two lines, needs no `OUT_DIR` plumbing, and the
|
||||
build script exits immediately afterwards.
|
||||
3. **No `webviews` patterns** (controller ruling). One webview per window; `windows` globs
|
||||
only.
|
||||
|
||||
## Decisions made during review
|
||||
|
||||
Checked against the tree at `bf22910` (the completed viewer) and the tauri-build 2.6.0 /
|
||||
tauri-utils 2.9.0 / tauri 2.11.0 sources pinned by `Cargo.lock`. The user-approved goal
|
||||
(allow-lists only, one file per window, the build fails rather than a feature breaking at
|
||||
runtime) is unchanged. What changed:
|
||||
|
||||
1. **Five viewer commands, not four (§3.1).** The implemented viewer polls with
|
||||
`viewer_poll_file`, so `file-viewer.json` gets five `allow-viewer-*` grants and `default.json`
|
||||
gets 110, for 115 registered commands.
|
||||
2. **The window rule is a function (§3.2).** `expected_windows(command)` (`viewer_*` →
|
||||
`file-viewer-*`, else `main`) replaces both the "no `viewer_*` in main / only `viewer_*` in
|
||||
the viewer file" policy assertion and the `SHARED` list. A command that must be callable from
|
||||
both windows is an edit to that function, which is the visible design change the earlier
|
||||
draft wanted; the extra list was a second place to say the same thing.
|
||||
3. **The check logic is a module, not inline `build.rs` (§3.2, §3.4).** A build script cannot
|
||||
be unit-tested, and the empty-list, typo, `deny-*` and wrong-file rules are exactly the code
|
||||
whose failure mode is "builds an unguarded app". `command_census.rs` is compiled into both
|
||||
`build.rs` and the test build, so the rules get ordinary `cargo test` coverage and the
|
||||
handler-list parser the census test already used becomes the one the build uses.
|
||||
4. **The vitest test follows imports, not directories (§3.3).** The viewer never calls `invoke`;
|
||||
it imports wrappers from the shared `lib/tauri-commands.ts`. So the test computes the
|
||||
viewer's transitive import closure from `viewer/main.tsx` and takes the wrappers that closure
|
||||
imports; a directory split would have put every viewer command in the "main" bucket and
|
||||
proved nothing.
|
||||
5. **Both census tests change, not one (§3.4).** The viewer feature added its own exact-set
|
||||
test in `file_viewer/mod.rs`; the earlier draft only knew about the `lib.rs` one.
|
||||
6. **`gen/schemas/*.json` is committed with the change.** It is tracked, tauri-build rewrites it
|
||||
on every build, and after this change it contains the `__app-acl__` manifest and the
|
||||
resolved grants — the reviewable evidence of what the binary embeds. Expect a large diff:
|
||||
`desktop-schema.json` and `linux-schema.json` gain an enum entry per generated permission
|
||||
(about 12 lines per command, ~2,700 lines across the two files for 115 commands).
|
||||
9. **Stale-permission cleanup is selective, not `remove_dir_all` (§3.2 step 3).** Found by the
|
||||
spike: wholesale regeneration would re-run the build script on every `cargo` call.
|
||||
7. **Atomicity (§4).** A bare `allow-*` in a capability file with no app manifest is a build
|
||||
error (`Permission allow-… not found`), and a manifest with no grants builds an app in which
|
||||
every command is denied. `build.rs`, both capability files and the census-test updates are
|
||||
therefore one commit; the shared module and its tests land before it, and the end-to-end
|
||||
manifest test, the vitest and the docs after it.
|
||||
8. **Spike (see §7).** Every claim in §2 that the design leans on was reproduced in a throwaway
|
||||
worktree before the plan was written.
|
||||
|
||||
## 7. Spike results (2026-09-22, throwaway worktree at `bf22910`, then removed)
|
||||
|
||||
Environment: tauri-build 2.6.0 / tauri 2.11.0 / tauri-utils 2.9.0 from `Cargo.lock`,
|
||||
`cargo check --offline` with the shared `target/` dir; each run ~3 s (only `triple-c`
|
||||
recompiles). A fresh worktree needs `app/dist` to exist (`npm run build`) or
|
||||
`generate_context!` panics on `frontendDist` before any ACL code runs.
|
||||
|
||||
| # | Experiment | Result |
|
||||
|---|---|---|
|
||||
| A | Untouched tree | Builds. `acl-manifests.json` has only plugin keys (`core`, `core:*`, `dialog`, `opener`); no `__app-acl__`. |
|
||||
| B | `build.rs` with `AppManifest::new().commands(Box::leak(…))` for three names | Builds. `permissions/autogenerated/{check_docker,list_projects,viewer_read_file}.toml` written (`allow-check-docker` / `deny-check-docker`, `commands.allow = ["check_docker"]`, header `# Automatically generated - DO NOT EDIT!`). `acl-manifests.json` gains `__app-acl__` with the six identifiers. `desktop-schema.json` and `linux-schema.json` each gain 36 lines (enum entries). `capabilities.json` unchanged until a file references a grant. Nothing else written. |
|
||||
| C | Bare `allow-check-docker`, `allow-list-projects` in `default.json`; `allow-viewer-read-file` in `file-viewer.json` | Builds. `gen/schemas/capabilities.json` shows the grants under each capability. |
|
||||
| D | Bare `allow-does-not-exist` | **Fails** (exit 101): `Permission allow-does-not-exist not found, expected one of allow-check-docker, …` — one ~10 KB line listing every identifier, no fuzzy hint. Hence the project's own check with a readable message. |
|
||||
| E | Typo `allow-check-dokcer` | Fails, same shape. |
|
||||
| F | Bare `deny-check-docker` in `file-viewer.json` | **Builds** and appears in `capabilities.json` — tauri does not object; only §3.2's ban does. |
|
||||
| G | Drop `list_projects` from the manifest list, keep its grant | **Builds**: the stale `list_projects.toml` is still globbed. After deleting `permissions/autogenerated`, fails with `Permission allow-list-projects not found`. Stale files are real. |
|
||||
| H | Original `tauri_build::build()` plus one bare grant | **Fails**: `Permission allow-check-docker not found, expected one of core:default, …`. The message goes to the build script's **stdout** (`println!`). Capability grants cannot land before the manifest. |
|
||||
| I | Rerun semantics | tauri-build emits `cargo:rerun-if-changed=capabilities` unconditionally (`acl.rs:427`) and `=permissions` when the dir exists (`acl.rs:300-305`). Editing a capability file alone re-runs the script and refreshes `gen/schemas`. Writing a `.toml` makes the *next* run re-run once more, then it settles because `write_if_changed` leaves unchanged files alone. |
|
||||
| J | Signatures | `AppManifest::commands(mut self, commands: &'static [&'static str]) -> Self` (`acl.rs:100`); `AppManifest` is `Copy`, so no owned alternative — `Box::leak` it is. `try_build(Attributes) -> anyhow::Result<()>`; `build()` prints `{error:#}` and `exit(1)`. |
|
||||
@@ -0,0 +1,404 @@
|
||||
# Terminal file viewer/editor — design
|
||||
|
||||
Date: 2026-09-22
|
||||
Status: approved in conversation (user); reviewed against the code 2026-09-22 (see "Decisions
|
||||
made during review" at the end); plan at `docs/superpowers/plans/2026-09-22-terminal-file-viewer.md`
|
||||
|
||||
## Goal
|
||||
|
||||
File locations Claude prints in a terminal tab (`src/foo.ts:42`, `/workspace/x/README.md`,
|
||||
`app/src/lib/urlRelay.ts:139-150`) become clickable. A click opens the file in its **own OS
|
||||
window**, scrolled to and highlighting the line/range, syntax-highlighted, live-reloading while
|
||||
Claude changes it, and **editable** so specs and similar files can be read, edited and saved
|
||||
from inside the app.
|
||||
|
||||
Non-goals: tabs inside a viewer window, creating new files, diff/merge views, opening files in a
|
||||
host editor, locking the viewer window out of every other app command (that is the follow-up
|
||||
AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`).
|
||||
|
||||
## Current state (verified against the tree at `3537b23`)
|
||||
|
||||
- Versions: tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0, wry 0.55, Vite 6.4.1,
|
||||
@xterm/xterm 5.5.0, @xterm/addon-web-links 0.12.0, React 19, bollard 0.18.1.
|
||||
- `TerminalView.tsx` (props `{ sessionId, active }`; `projectId` is derived from the session in
|
||||
the store): `WebLinksAddon` (http/https only, its own `registerLinkProvider` inside the addon),
|
||||
OSC 8 via `linkHandler` (`createOsc8LinkHandler(getHost, readState)`; xterm's
|
||||
`OscLinkProvider` drops any target that is not `http:`/`https:` **or that fails `new URL()`**
|
||||
because `allowNonHttpProtocols` is unset), OSC 7777 URL relay. Nothing calls
|
||||
`terminal.registerLinkProvider` directly. `opensOnClick(event, ctx, modifierPromised)` is a
|
||||
module-private function in `TerminalView.tsx`; toasts are `useAppState.getState().pushToast`.
|
||||
- `read_container_file(project_id, path, max_bytes)` in `commands/file_commands.rs` reads via
|
||||
bollard `download_from_container` (`fetch_container_file`). **The archive endpoint does not
|
||||
follow a final symlink and the helper refuses links** ("is a link — open its target instead"),
|
||||
so any path the viewer reads must already be resolved to a regular file. It caps at
|
||||
`MAX_READ_BYTES` = 8 MiB, returns `FileContents { contents_base64, truncated, size }`, and does
|
||||
**not** call `require_running`. `validate_container_path` (absolute, no NUL, no `..` segment,
|
||||
≤ 4096 bytes; it rejects rather than normalises) and `validate_container_write_path` (that plus
|
||||
`is_under_root` against `CONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp`, compared by
|
||||
whole segments) are private `fn`s in `file_commands.rs`.
|
||||
- Execs: the container user is addressed by **name**, `"claude"` (never `uid:gid`);
|
||||
`docker::exec::exec_oneshot_streams_as(container_id, "claude", cmd, env) -> (stdout, stderr,
|
||||
exit_code)` runs without stdin, cwd `/workspace`, 8 MiB output cap, lossy UTF-8. There is no
|
||||
helper that feeds bytes to an exec's stdin. `ExecSessionManager::write_file_to_container
|
||||
(container_id, file_name, bytes)` lands bytes at `/tmp/<file_name>` owned by the container
|
||||
user (`container_user_ids`) with mode 0644 via the archive API. `upload_host_file_with_ids`
|
||||
is the host-file variant of the same tar path. `resolve_container_dir` runs `realpath -m` as
|
||||
`claude` and re-validates the result against the write roots (allowing the literal path when
|
||||
`realpath` fails).
|
||||
- `FileViewerModal` + `components/projects/home/filePreview.ts` (Files tab): `previewKind`,
|
||||
`imageMimeFor`, `previewLimit`, `decodeBase64`, `looksBinary`, `TEXT_PREVIEW_LIMIT` 1 MiB,
|
||||
`IMAGE_PREVIEW_LIMIT` 5 MiB. Reuse the classification and limits.
|
||||
- Only second window today is the browser-view pop-out (`browser_view/popout.rs`), a
|
||||
remote-origin window with no capability and no IPC. It builds `WebviewWindowBuilder::new(app,
|
||||
&label, WebviewUrl::External(url))` straight from an **async** command (no
|
||||
`run_on_main_thread`; Tauri documents that windows must be created from async commands, not
|
||||
sync ones), hooks `window.on_window_event` for `Destroyed`, and closes with `destroy()`, never
|
||||
`close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`.
|
||||
- `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local
|
||||
window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`.
|
||||
App commands need no capability entry (CLAUDE.md, "Key Conventions"). *(Historical snapshot at
|
||||
`3537b23`. Closed 2026-09-22 by the AppManifest lockdown
|
||||
(`2026-09-22-app-manifest-lockdown-design.md`) — see §6 below.)*
|
||||
- The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project
|
||||
path is bind-mounted at `/workspace/<mount_name>` (`ProjectPath { host_path, mount_name }`,
|
||||
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a
|
||||
mount.
|
||||
- Tauri multi-window facts that the design rests on:
|
||||
- `WebviewUrl::App("viewer.html".into())` is `Url::join`ed onto `build.devUrl` in dev
|
||||
(`http://localhost:1420/viewer.html`) and onto `tauri://localhost/` in a bundle
|
||||
(`http://tauri.localhost/` on Windows); both are `Origin::Local`, so capability files apply.
|
||||
**If `viewer.html` is missing, both Vite's dev server and Tauri's asset lookup silently fall
|
||||
back to `index.html`** — the main app opens in the viewer window. A test guards against this.
|
||||
- Capability `windows` entries are `glob::Pattern`s, so `"file-viewer-*"` matches.
|
||||
- `getCurrentWindow().onCloseRequested(cb)` listens on `tauri://close-requested` and then calls
|
||||
`destroy()` itself; Rust calls `prevent_close()` whenever a JS listener exists. So the
|
||||
viewer needs `core:window:allow-destroy` or **the X button stops working** the moment the
|
||||
listener is registered. `listen`/`unlisten` need `core:event:allow-listen`/`allow-unlisten`.
|
||||
Rust→window emits need no grant on the receiving side.
|
||||
- `app.emit_to(label, …)` targets one label, but a bare `listen()` in the main window
|
||||
(`EventTarget::Any`) still receives it. The viewer listens through
|
||||
`getCurrentWindow().listen(...)`, and the main window never listens to viewer event names.
|
||||
- The `app.security.csp` applies to every `.html` Tauri serves, `viewer.html` included. Tauri
|
||||
adds a `'nonce-…'` to `style-src` only when the entry HTML contains a literal `<style>`
|
||||
element, and a nonce disables `'unsafe-inline'` — which CodeMirror's `style-mod` needs for its
|
||||
runtime `<style>` injection. **`viewer.html` must not contain an inline `<style>`.**
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Path detection in the terminal (`app/src/lib/filePathLinks.ts`, `TerminalView.tsx`)
|
||||
|
||||
- A pure matcher `findFilePathLinks(lineText) -> FilePathMatch[]`, where
|
||||
`FilePathMatch = { start, end, path, line?, col?, endLine? }` (`start` inclusive, `end`
|
||||
exclusive, string indices into `lineText`), used by a new `ILinkProvider` registered with
|
||||
`terminal.registerLinkProvider` right after `term.loadAddon(webLinksAddon)` (providers are
|
||||
asked in registration order; the web-links one runs first, so an `http://` span never reaches
|
||||
the file matcher as a candidate — the matcher additionally refuses any span overlapping `://`).
|
||||
- Matches tokens that look like paths **with a file extension** (or a known extensionless name:
|
||||
`Makefile`, `Dockerfile`, `CLAUDE.md`-style names are already covered by the extension rule),
|
||||
optionally absolute, optionally `./`/`../` prefixed, followed by optional `:line`,
|
||||
`:line:col`, or `:start-end`. Also accepts `#L42` / `#L40-L50` suffixes.
|
||||
- Strips wrapping that Claude's markdown rendering leaves: backticks, parentheses, brackets,
|
||||
quotes, trailing `.,;:` punctuation.
|
||||
- Must not match inside URLs (http links are already handled; skip spans overlapping `://`),
|
||||
bare version numbers (`1.2.3`), or domain names (`example.com` with no `/` — require either a
|
||||
`/` in the token or an extension from an allowlist of common source/doc extensions for
|
||||
slash-less tokens).
|
||||
- Wrapped rows: v1 joins a buffer row with its `isWrapped` continuation rows the way
|
||||
`WebLinksAddon`'s private `LinkComputer._getWindowedLineStrings` does (walk up while the row
|
||||
`isWrapped`, walk down while the next row `isWrapped`, 2048-char budget, `translateToString
|
||||
(true)`), then maps string indices back to `{x, y}` cells. `LinkComputer` is not exported from
|
||||
the built addon, so the walk is re-implemented in `app/src/lib/xtermLineJoin.ts`
|
||||
(`joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts }` plus
|
||||
`stringIndexToCell`). `urlDetector.ts` works on the byte stream, not the buffer, so it has
|
||||
nothing reusable here. `ILink.range` is 1-based on both axes with an **inclusive** end column;
|
||||
`provideLinks(bufferLineNumber)` is 1-based and `buffer.active.getLine(y)` is 0-based.
|
||||
- OSC 8 `file://` targets: set `allowNonHttpProtocols: true` on the handler and dispatch by
|
||||
scheme in `createOsc8LinkHandler`. With the flag on, xterm hands **every** OSC 8 target to
|
||||
`activate`/`hover`, including unparseable ones and `javascript:`; so the handler parses with
|
||||
`new URL()` itself: `file:` → `onOpenFile(pathname decoded, no line)`, `http(s):` → the
|
||||
existing `sanitizeRelayUrl` → `openUrlExternal` path unchanged, anything else → refused
|
||||
(`console.warn`, nothing opened). A refused target (`javascript:`, any other scheme,
|
||||
unparseable text) keeps the existing refusal card ("This link will not be opened — it failed
|
||||
the URL safety check"), which never echoes the target into the DOM; this is the ruled
|
||||
behaviour (Task 9), not a new hover card. The hover card for a `file:` target shows the path and "Open
|
||||
in viewer". The `WebLinksAddon` comment that describes the old `allowNonHttpProtocols`
|
||||
behaviour is updated, not left stale.
|
||||
- Activation uses the same click gating as web links (`opensOnClick`: no selection drag,
|
||||
single click; `modifierPromised` for OSC 8 hovers). A click invokes
|
||||
`openFileViewer(projectId, rawPath, line, col, endLine)` → `open_file_viewer`. No
|
||||
confirmation toast — nothing leaves the app and nothing is written without an explicit save.
|
||||
A rejected open (cap, container not running, project gone) is a `pushToast({ kind: "error" })`.
|
||||
- Hover: underline + pointer cursor (`decorations`), and the same bottom-left card the OSC 8
|
||||
handler draws, reading "Open in viewer".
|
||||
|
||||
### 2. Path resolution (Rust, `commands/file_viewer_commands.rs` + `file_viewer/resolve.rs`)
|
||||
|
||||
- Pure candidate generation `candidate_paths(raw, mount_names) -> Result<Vec<String>, String>`:
|
||||
- Absolute: exactly one candidate, after `validate_container_path` (which rejects `..`).
|
||||
- Relative `p`: strip a leading `./`, collapse repeated `/`, reject any `..` segment or NUL or
|
||||
length > 4096; candidates in order `/workspace/<p>`, then `/workspace/<mount>/<p>` for each
|
||||
non-empty `mount_name`, de-duplicated. Relative paths are the common case (Claude prints
|
||||
project-relative paths) and `/workspace/<p>` is first because the terminal's exec cwd is
|
||||
`/workspace`.
|
||||
- Existence is checked in **one** exec as `claude`: `sh -c` with the candidates as `$1..$n`,
|
||||
printing `realpath -e -- "$c"` for each candidate that is a regular file (`test -f`, which
|
||||
follows symlinks). Output lines are the **resolved** paths, which is what the registry stores
|
||||
(`fetch_container_file` refuses a link, so the resolved path is the only one that reads). The
|
||||
resolved path is re-checked with `validate_container_path`; a candidate whose resolution
|
||||
escapes to an invalid path is dropped. Candidate lists are capped at 16 entries; output lines
|
||||
are de-duplicated (two candidates may resolve to one file).
|
||||
- 0 matches → window opens in a "not found" state listing the candidates tried.
|
||||
- 1 match → open it.
|
||||
- \>1 match → window opens in a "choose file" state listing matches; choosing one calls
|
||||
`viewer_choose_file(index)`; the choice is from the Rust-held candidate list, never a path from
|
||||
the window.
|
||||
- Container must be running (`require_running`, via `project.container_id`); otherwise error
|
||||
toast in the main window, no window opened.
|
||||
|
||||
### 3. One window per click (`src-tauri/src/file_viewer/{mod.rs,registry.rs,window.rs}`)
|
||||
|
||||
- Each successful `open_file_viewer` creates a `WebviewWindow` with label
|
||||
`file-viewer-<counter>` (monotonic `AtomicU64`, never reused within a process), loading the
|
||||
app's own bundle at a second Vite entry (`viewer.html` → `src/viewer/main.tsx`;
|
||||
`build.rollupOptions.input = { main: "index.html", viewer: "viewer.html" }`), title
|
||||
`<basename> — <project name>` (set from Rust), default 900×700, min 480×320, resizable. The
|
||||
command is `async` (Tauri's requirement for window creation from a command).
|
||||
- A registry `ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 }`
|
||||
managed with `app.manage(...)` (separate from `AppState`, like the browser view keeps its own
|
||||
state), where
|
||||
`ViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates}
|
||||
| NotFound{tried}, initial: Location { line, col, end_line } }`.
|
||||
Entry removed on `WindowEvent::Destroyed`.
|
||||
- If a window for the same `(project_id, container_path)` is already open (Resolved entries
|
||||
only): `unminimize()`, `set_focus()` and `app.emit_to(label, "file-viewer-goto", Location)` to
|
||||
that window instead of opening a duplicate; the viewer listens with
|
||||
`getCurrentWindow().listen("file-viewer-goto", ...)`.
|
||||
- Cap: 20 viewer windows (`MAX_VIEWER_WINDOWS`); the 21st click returns an error the main window
|
||||
shows as a toast. The cap counts registry entries, and an entry is reserved **before** the
|
||||
window is built so two concurrent clicks cannot both pass the check.
|
||||
- When the project's container stops/is removed, open viewer windows stay open but their reads
|
||||
fail and they show a "container not running" banner; they are not force-closed (unsaved text
|
||||
must not be destroyed). The registry stores `project_id`, not `container_id`, so a recreated
|
||||
container is picked up on the next poll.
|
||||
- `lib.rs`'s shutdown teardown does nothing for viewers: the process exits and the OS closes
|
||||
them. Unsaved edits in a viewer are lost on app quit (accepted; it is what the main window's
|
||||
close does to a terminal too).
|
||||
|
||||
### 4. Editor (`app/src/viewer/`)
|
||||
|
||||
- CodeMirror 6, direct packages only (no `codemirror` meta-package, no
|
||||
`@codemirror/language-data` — it hard-depends on 13 more grammars, one of which,
|
||||
`legacy-modes/mode/pug`, calls `Function(...)`): `@codemirror/state`, `view`, `commands`,
|
||||
`search`, `language`, `lang-markdown`, `lang-javascript`, `lang-rust`, `lang-python`,
|
||||
`lang-json`, `lang-yaml`, `lang-css`, `lang-html`, `legacy-modes` (toml, shell via
|
||||
`StreamLanguage.define`), plus `@lezer/highlight` for the tag-based highlight style. Language
|
||||
packs are chosen by extension in `viewer/languages.ts` and loaded with dynamic `import()`
|
||||
(same-origin chunks, fine under `script-src 'self'`). Verified: none of these packages uses
|
||||
`eval`/`new Function`/Workers; `style-mod` injects a `<style>` element, allowed by
|
||||
`style-src 'unsafe-inline'`. No CSP change.
|
||||
- Theme: `EditorView.theme` + `HighlightStyle` in `viewer/viewerTheme.ts`, colours from the
|
||||
app's CSS custom properties (`index.css` is imported by the viewer entry so the tokens exist).
|
||||
- Line numbers, search (Ctrl/Cmd+F, `searchKeymap`), go to line (`gotoLine`), history,
|
||||
`indentWithTab`, `lineWrapping` for markdown/plain text, highlight of the target line/range
|
||||
(a `Decoration.line` `StateField` driven by a `setHighlight` `StateEffect`), scroll target
|
||||
into view centred (`EditorView.scrollIntoView(pos, { y: "center" })`) on open and on
|
||||
`file-viewer-goto`.
|
||||
- Header: container path, project name, state badges (Read-only reason / Unsaved / Saved /
|
||||
Changed on disk / Container not running), Save button.
|
||||
- **Editable** only when: text (per `filePreview.ts` classification and `looksBinary`), not
|
||||
truncated (≤ 1 MiB), and Rust says `editable: true` (the resolved path is inside
|
||||
`CONTAINER_WRITE_ROOTS`). Otherwise the editor is read-only (`EditorState.readOnly` +
|
||||
`EditorView.editable` both false) with a badge stating why. Images render read-only as in
|
||||
`FileViewerModal` (Blob + object URL, `IMAGE_PREVIEW_LIMIT`).
|
||||
|
||||
### 5. Save + live reload
|
||||
|
||||
- Content identity: SHA-256 hex of file bytes. Two producers, one definition — the hash of the
|
||||
file's full bytes: Rust computes it (`sha2`, already a dependency) over the bytes it fetched,
|
||||
and `sha256sum` inside the container computes it for polls and for the save check. The two
|
||||
agree exactly when the read was not truncated, which is the only case in which the hash is
|
||||
used as a save base.
|
||||
- `viewer_read_file(max_bytes)` → `ViewerFile { contents_base64, truncated, size, hash,
|
||||
editable, readonly_reason: Option<String> }` for the caller's own target (`Resolved` only).
|
||||
`hash` is over the returned bytes. `editable` is `validate_container_write_path(resolved)`
|
||||
succeeding; `readonly_reason` is its message otherwise.
|
||||
- `viewer_poll_file()` → `ViewerPoll { exists, hash: Option<String>, size: Option<u64> }` via one
|
||||
exec as `claude`: `sha256sum -- "$1"` + `stat -c %s -- "$1"`. Missing file → `exists: false`.
|
||||
This is one small exec per window per tick, not a 1 MiB archive download.
|
||||
- Poll every 2 s while `document.visibilityState === "visible"`; pause otherwise; poll once
|
||||
immediately on `visibilitychange` back to visible. The reducer tracks `diskHash` (last known
|
||||
full-file hash; seeded from the read's `hash` when `truncated` is false, otherwise from the
|
||||
first poll) and `baseHash` (the hash the buffer was loaded from).
|
||||
- Poll hash == `diskHash` → nothing.
|
||||
- Poll hash changed, editor clean → `viewer_read_file` again, replace document, preserve
|
||||
scroll position and cursor (clamped), brief "Reloaded" indicator.
|
||||
- Poll hash changed, editor dirty → banner "Changed on disk" with **Reload (discard mine)** and
|
||||
**Overwrite on save**. No auto-merge. "Overwrite on save" sets `baseHash` to the polled hash
|
||||
so the next save succeeds.
|
||||
- `exists: false` → banner "File no longer exists"; content kept, editable text retained so the
|
||||
user can copy it; saving is disabled (no file creation).
|
||||
- Poll error (container not running) → banner "Container not running", editor keeps its state,
|
||||
polling continues so the banner clears when the container is back.
|
||||
- Save: Ctrl/Cmd+S or Save button → `viewer_write_file(contents_base64, base_hash)` → `Ok(hash)`.
|
||||
- Mechanism (**all mutation of the target directory happens as the container user**; the
|
||||
Docker archive API, which writes as root, only ever lands the payload in `/tmp`):
|
||||
1. Rust: `Resolved` entry only; decode; refuse if `!editable`, if content > 1 MiB, or if
|
||||
`base_hash` is not 64 hex chars.
|
||||
2. `ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>",
|
||||
bytes)` → `/tmp/triple-c-viewer-<uuid>`, owned by the container user, mode 0644.
|
||||
3. One `sh` script as `claude` (`exec_oneshot_streams_as`), args `target tmp base_hash`:
|
||||
`test -f target` else exit 4 (gone); `sha256sum target` ≠ `base_hash` → exit 3
|
||||
(conflict); if the directory is writable: `cp tmp dir/.<name>.triple-c-tmp`, `chmod
|
||||
--reference=target staged`, `mv -f staged target`; else `cat tmp > target` (in-place
|
||||
fallback for a writable file in a read-only directory); `rm -f tmp` in every branch
|
||||
(`trap`); print `sha256sum target` on success.
|
||||
4. Exit 3 → `Err("conflict: …")` (the window shows the "Changed on disk" banner); exit 4 →
|
||||
`Err("gone: …")`; other non-zero → the clipped stderr.
|
||||
- Ownership: a non-root user cannot `chown`, so the file ends up owned by the container user
|
||||
— the same thing Claude Code's own edits produce. Mode is preserved via `chmod --reference`.
|
||||
- Symlinks: the registry already holds the `realpath -e` target (§2), and `editable` was
|
||||
computed on it, so a link into a non-writable root is read-only.
|
||||
- Returns the new hash; editor becomes clean, `baseHash = diskHash = new hash`.
|
||||
- Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by
|
||||
`onCloseRequested` (`event.preventDefault()` when dirty); Discard calls `destroy()`.
|
||||
|
||||
### 6. Security
|
||||
|
||||
- New capability file `capabilities/file-viewer.json`, `windows: ["file-viewer-*"]`, granting
|
||||
exactly: `core:event:allow-listen`, `core:event:allow-unlisten`, `core:window:allow-destroy`
|
||||
(required for `onCloseRequested` to be able to close the window at all — see "Current state"),
|
||||
and `core:webview:allow-internal-toggle-devtools` (dev-only, as in `default.json`). No
|
||||
`allow-close`, no `set-title`/`set-focus`/`is-minimized` (title and focus are set from Rust;
|
||||
polling pauses on `visibilityState`). `default.json` stays `windows: ["main"]`. Update the
|
||||
threat-model census in `default.json`'s description to name the second file and why the viewer
|
||||
gets `allow-destroy`.
|
||||
- Viewer commands take `window: tauri::Window` and check the label:
|
||||
- `open_file_viewer` — `window.label() == "main"` only.
|
||||
- `viewer_get_state`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file`,
|
||||
`viewer_choose_file` — `file-viewer-*` only, and they operate solely on the registry entry
|
||||
for **the caller's own label**. No viewer command accepts a path or a label as an argument.
|
||||
- Rendering: CodeMirror renders text as DOM text nodes; nothing uses `innerHTML` /
|
||||
`dangerouslySetInnerHTML` on file content. CSP unchanged.
|
||||
- Closed by the AppManifest follow-up (`2026-09-22-app-manifest-lockdown-design.md`, implemented):
|
||||
`build.rs` now declares a Tauri `AppManifest` from `generate_handler!`, so a compromised viewer
|
||||
window can invoke only the five `allow-viewer-*` app commands granted in
|
||||
`capabilities/file-viewer.json`, not any other app command.
|
||||
- Update CLAUDE.md (frontend/backend structure, Key Conventions note about the viewer window and
|
||||
label-gated commands).
|
||||
|
||||
### 7. Error handling
|
||||
|
||||
All Rust errors are sentences suitable for display. Container-authored text is clipped via
|
||||
`clip_container_text` (made `pub(crate)`). The main window shows `open_file_viewer` failures as
|
||||
toasts; the viewer shows read/write failures as banners and never loses the editor buffer on
|
||||
failure.
|
||||
|
||||
### 8. Testing
|
||||
|
||||
- Vitest: `filePathLinks` matcher (positive/negative table incl. markdown wrapping, URLs,
|
||||
versions, `:l:c`, ranges, `#L`), `xtermLineJoin` on a fake buffer, OSC 8 scheme dispatch (in
|
||||
`TerminalView.test.tsx`, which already tests `createOsc8LinkHandler`), the viewer
|
||||
reload/dirty/conflict state machine as a pure reducer (`viewer/viewerState.ts`), editability
|
||||
rules, language selection.
|
||||
- Rust unit tests: candidate generation and normalization, label gating, registry
|
||||
dedupe/cap/removal, write script argument shape, size cap, `viewer.html` presence + Vite
|
||||
input assertion, and `every_command_is_registered_exactly_once` (existing) for the new
|
||||
commands.
|
||||
- `npx tsc --noEmit`, `npx vitest run`, `cargo test`, `cargo clippy` clean.
|
||||
- Manual (`npx tauri dev`): click paths in Claude output, multiple windows, edit + save,
|
||||
edit while Claude edits the same file (conflict banner), container stop with window open.
|
||||
|
||||
## Decisions made during review
|
||||
|
||||
Facts were checked against tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0 sources,
|
||||
xterm 5.5.0, Vite 6.4.1 and the tree at `3537b23`. The user-approved decisions (one window per
|
||||
click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, conflict banner,
|
||||
20-window cap, AppManifest deferred) are unchanged. What changed:
|
||||
|
||||
1. **Write mechanism chosen (§5).** No exec helper feeds stdin, and whether a half-close of the
|
||||
hijacked exec connection reaches the process is unverified. Instead: stage bytes at `/tmp`
|
||||
with the existing `write_file_to_container` (container-user-owned), then one `sh` script as
|
||||
`claude` does hash-check + `chmod --reference` + `mv -f` in the target directory. Every write
|
||||
into the target directory is therefore subject to the container user's permissions; the
|
||||
root-privileged archive API never touches it. `chown --reference` was dropped: a non-root
|
||||
exec cannot chown, so the spec now says the saved file is owned by the container user.
|
||||
2. **Polling is an exec (`sha256sum` + `stat`), not a re-download (§5).** A 2 s tick per window
|
||||
that streams a 1 MiB archive was the alternative. The full-file hash from `sha256sum` equals
|
||||
the Rust-side hash exactly when the read was untruncated, which is the only editable case;
|
||||
the reducer keeps `diskHash` separate from `baseHash` so read-only (truncated) files still
|
||||
detect change without a reload loop.
|
||||
3. **Resolution stores the `realpath -e` result (§2).** `fetch_container_file` refuses a
|
||||
symlink, so the raw candidate path could not be read; probing already needs an exec, so it
|
||||
resolves in the same one. Candidate normalisation *rejects* `..` (matching
|
||||
`validate_container_path`, which does not normalise) rather than resolving it.
|
||||
4. **Exact capability set (§6).** `onCloseRequested` in @tauri-apps/api 2.11 calls
|
||||
`this.destroy()`, and Rust `prevent_close()`s whenever a JS listener exists, so
|
||||
`core:window:allow-destroy` is mandatory; `allow-close` is not needed and not granted.
|
||||
Focus/title/unminimize are done from Rust, so no `core:window:allow-set-*` grants.
|
||||
5. **OSC 8 with `allowNonHttpProtocols: true` receives unparseable and `javascript:` targets
|
||||
(§1).** The handler now parses and dispatches itself, and refuses everything but `file:` and
|
||||
`http(s):` before drawing a file or web hover card; a refused target gets only the existing
|
||||
refusal card, which never echoes the target. The stale comment in the `WebLinksAddon` branch is
|
||||
updated as part of the change.
|
||||
6. **Wrapped-row joining is re-implemented (§1).** `WebLinksAddon`'s `LinkComputer` is not
|
||||
exported from the built package and `urlDetector.ts` never touches the buffer.
|
||||
7. **CodeMirror package list pinned (§4).** Direct packages only; `@codemirror/language-data`
|
||||
and the `codemirror` meta-package are excluded (extra grammars, one with `Function(...)`).
|
||||
A home-grown theme from CSS tokens rather than `theme-one-dark`.
|
||||
8. **`viewer.html` fallback trap (Current state, §8).** Both Vite dev and Tauri's asset lookup
|
||||
fall back to `index.html` when the entry is missing; a Rust test asserts the file and the
|
||||
Vite input entry exist.
|
||||
9. **Registry is its own managed state (§3)** rather than a field on `AppState`, with a
|
||||
reserve-before-build cap check; label counter is a process-wide `AtomicU64`.
|
||||
10. **Event delivery (§3).** `emit_to(label, …)` still reaches a bare `listen()` in the main
|
||||
window, so the viewer subscribes via `getCurrentWindow().listen` and the main window has no
|
||||
listener for viewer event names.
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
Not runnable inside the planning/implementation container: it needs `npx tauri dev` on a machine
|
||||
with a display, plus a running project container. Run this after every task on this feature has
|
||||
landed, including any review fix rounds (in particular Task 11's CRLF/BOM save fix, item 16
|
||||
below).
|
||||
|
||||
- [ ] 1. In a Claude session, ask for a file listing; click `src/…` paths, `/workspace/...`
|
||||
absolute paths, a `path:line` and a `path:start-end`. Each opens its own window titled
|
||||
`<basename> — <project>`, scrolled to and highlighting the line/range.
|
||||
- [ ] 2. Click the same path again → the existing window is focused and re-highlights; no
|
||||
duplicate.
|
||||
- [ ] 3. Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
|
||||
- [ ] 4. Edit, `Ctrl+S`: file changes in the container (`cat` it in a bash tab); mode preserved
|
||||
(`stat -c %a`); owner is the container user.
|
||||
- [ ] 5. While a window is open, have Claude edit the file: clean window reloads with "Reloaded";
|
||||
a dirty window shows "Changed on disk" with both buttons; "Overwrite on save" then Save
|
||||
succeeds; "Reload (discard mine)" drops the edits.
|
||||
- [ ] 6. Save while the file changed between polls → "Changed on disk" banner, no data written.
|
||||
- [ ] 7. Delete the file in a bash tab → "File no longer exists", text still copyable, Save
|
||||
disabled.
|
||||
- [ ] 8. Stop the project with a window open → "Container not running" banner; start it → banner
|
||||
clears, polling resumes.
|
||||
- [ ] 9. Click a path under `/etc` or a `file:///etc/hosts` OSC 8 link → read-only badge with the
|
||||
write-roots reason.
|
||||
- [ ] 10. Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open;
|
||||
Discard closes.
|
||||
- [ ] 11. Close the *main* window with viewers open → app exits, viewers close.
|
||||
- [ ] 12. Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console
|
||||
shows no violations (CodeMirror styles apply).
|
||||
- [ ] 13. A wrapped long path (narrow the terminal) underlines across the wrap and opens.
|
||||
- [ ] 14. Image (`.png`) opens read-only; a `.bin`/binary shows "not text".
|
||||
- [ ] 15. **File-path hover key-hint wording.** Hover a plain-text file-path link in the terminal
|
||||
(not an OSC 8 hyperlink) while a foreground program is holding the mouse (e.g. an
|
||||
interactive TUI like `vim`/`htop`/`claude`'s own REPL) and confirm the hover card's key hint
|
||||
reads correctly for the platform/mode: "Shift+click to open" on non-Mac while the program
|
||||
tracks the mouse, "Click to open" when nothing tracks the mouse, "Option+click to open" on
|
||||
Mac with `macOptionClickForcesSelection`, or "Not clickable while a program holds the mouse"
|
||||
on Mac without it (`openHintLabel` / `fillFileCard` in `TerminalView.tsx`). Then confirm the
|
||||
*click itself* is held to whatever the card promised — the modifier actually required to
|
||||
activate the link matches the hint shown, even if the program changes its mouse-tracking
|
||||
mode between the hover and the click.
|
||||
- [ ] 16. **CRLF/BOM file round-trip save.** Open a file in the container that has Windows line
|
||||
endings (CRLF) and/or a UTF-8 BOM (e.g.
|
||||
`printf '\xEF\xBB\xBF\r\nfoo\r\nbar\r\n' > /workspace/<project>/crlf.txt` in a bash tab),
|
||||
open it in the viewer, make a small text edit, and Save. Then `cat -A` (or `xxd`) the file
|
||||
in the container and confirm the CRLF line endings and the BOM are still present/unchanged
|
||||
apart from the edit — i.e. the save did not silently normalize them to LF or strip the BOM.
|
||||
Reference in New Issue
Block a user