Compare commits
115
Commits
168b61d632
...
v0.4.29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8305c96e20 | ||
|
|
3537b234d8 | ||
|
|
83c9c24951 | ||
|
|
c6f9c1d43f | ||
|
|
593b8168eb | ||
|
|
d647b56b43 | ||
|
|
a3840f7263 | ||
|
|
ac50c38891 | ||
|
|
f662ed04ce | ||
|
|
f311ca1990 | ||
|
|
84a5757c74 | ||
|
|
73a6e3d8b4 | ||
|
|
943c83b9e3 | ||
|
|
60188610ee | ||
|
|
db648230ee | ||
|
|
5a452e7a2a | ||
|
|
5a09254538 | ||
|
|
9297020688 | ||
|
|
bf8094dbc4 | ||
|
|
90b7e4ccb2 | ||
|
|
afe9d5cdb2 | ||
|
|
b59c6148ff | ||
|
|
95a78fe9a3 | ||
|
|
307ea07409 | ||
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec | ||
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 | ||
|
|
670450ccfd | ||
|
|
a0b9f1e19b | ||
|
|
9fadfbc37a | ||
|
|
a3bdf6f4da | ||
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 | ||
|
|
3239057f8f | ||
|
|
23364f412e | ||
|
|
b24807bd5f | ||
|
|
0f3fff92f4 | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
2708772bf9 | ||
|
|
436b6dd470 | ||
|
|
5c47656444 | ||
|
|
be47c5edfd | ||
|
|
037ed78570 | ||
|
|
31e8f9df5f | ||
|
|
3704064006 | ||
|
|
f79a44e0a8 | ||
|
|
5a8e24ccbe | ||
|
|
a1f4eee9a3 | ||
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 | ||
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 | ||
|
|
1a79852f65 | ||
|
|
68b73a9102 | ||
|
|
d09e2a2743 | ||
|
|
4371c9f03e | ||
|
|
eead748222 | ||
|
|
2c9482a67d | ||
|
|
88ffb4744a | ||
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 |
@@ -43,7 +43,18 @@ name: Build App (Preview)
|
||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||
# release; the point of a preview is the build you are testing now.
|
||||
#
|
||||
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||
# (never valid semver) already blocks it independently. (The previous
|
||||
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||
# since build-app.yml's inline mirror already does what it was meant to do
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
@@ -70,12 +81,23 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||
# The bundle version fields never see this — see "Set app version" in
|
||||
# each build job — but it is baked into the binary as
|
||||
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||
# An installed preview otherwise reports the same bare number a
|
||||
# production build would, indistinguishable in the About panel and to
|
||||
# `check_for_updates`. See triple-c#32.
|
||||
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute preview version
|
||||
id: version
|
||||
run: |
|
||||
@@ -86,21 +108,60 @@ jobs:
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number is computed exactly as build-app.yml does it, so a
|
||||
# preview is labelled with the version the release it previews would
|
||||
# carry. This used to be hard-coded `.0`, which made every preview
|
||||
# installer claim to be x.y.0 no matter what it contained.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||
# The patch number must be the same "one past the highest patch
|
||||
# already used" build-app.yml computes for a real release — not a
|
||||
# distance from the latest tag. It used to be
|
||||
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||
# own history section documents as broken for exactly this reason:
|
||||
# it resets to zero on every tag cut, so previews went *backwards*
|
||||
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||
# a preview number from later colliding with a real release's.
|
||||
#
|
||||
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||
# / `-win` suffixed ones a partially-published release can leave
|
||||
# behind) means a preview built right before a release computes the
|
||||
# exact number that release is about to take — e.g. `0.4.13` for
|
||||
# both. That makes the two numerically *equal*, not "preview less
|
||||
# than release" — plain semver ordering does not make a
|
||||
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||
# `check_for_updates` compares against the bare, stripped
|
||||
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||
# check, which relaxes that one comparison to `>=` specifically so
|
||||
# "a release exists at my own number" reads as an update. See
|
||||
# triple-c#32.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||
# HEAD can be a commit a release was already cut from. Without this,
|
||||
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||
# that release — and produce exactly the "preview outranks
|
||||
# production" failure triple-c#32 was filed over, just reintroduced
|
||||
# through the manual-dispatch door instead of the automatic one.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||
SUFFIX="preview.${SHORT_SHA}"
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
@@ -238,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
|
||||
@@ -249,16 +336,31 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
env:
|
||||
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||
# no room for a suffix), so this is the only place a preview build
|
||||
# can still tell itself apart from a production one. See
|
||||
# triple-c#32.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
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/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
@@ -350,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
|
||||
@@ -361,6 +465,9 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
env:
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
@@ -489,6 +596,8 @@ jobs:
|
||||
working-directory: ./app
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
|
||||
@@ -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
|
||||
@@ -185,16 +211,38 @@ jobs:
|
||||
working-directory: ./app
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- 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/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm 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:
|
||||
@@ -270,6 +318,19 @@ jobs:
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
# The fixed tag every installed AppImage checks for updates. Separate
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
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
|
||||
needs: [compute-version]
|
||||
@@ -325,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Secret Scan
|
||||
|
||||
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||
# touching the wrong directory is not a scan.
|
||||
#
|
||||
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||
# from inside a repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||
# but depends on getting the range right across pushes, force-pushes,
|
||||
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||
# takes under half a second on this repository and cannot be evaded by
|
||||
# arranging for the interesting commit to sit outside the window.
|
||||
- name: Scan tracked files for credentials
|
||||
run: sh scripts/scan-secrets.sh --tracked
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit that adds something shaped like a live credential.
|
||||
#
|
||||
# Installed by pointing git at this directory:
|
||||
#
|
||||
# git config core.hooksPath .githooks
|
||||
#
|
||||
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||
# a repo would then be enough to run its code. So this is opt-in on every
|
||||
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||
# hook exists to tell you in one second rather than in five minutes.
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -73,20 +73,65 @@ 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
|
||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
|
||||
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
|
||||
hit-test of the physical-pixel payload position against the pane's rect ÷
|
||||
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
|
||||
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
|
||||
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
|
||||
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
|
||||
removed from the ship branch and held back for separate hardening; it lives on
|
||||
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
|
||||
design — do not move it back into the webview.** The tab browses, views (text and image),
|
||||
renames and creates folders inside the container (`list_container_files`,
|
||||
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
|
||||
copies single files in and out (`upload_files_to_container`, `download_container_file`).
|
||||
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
|
||||
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
|
||||
entirety of its influence — it cannot name a host path as an *input*. The claim stops
|
||||
there and should not be widened: host paths still travel outward in error text, canonical
|
||||
ones included. What is closed is the direction that produced the criticals.
|
||||
That shape is not decoration. Four successive audits found that host filesystem paths
|
||||
crossing IPC were where the criticals lived — a caller-named host destination for
|
||||
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
|
||||
upload reservation that succeeded against a directory and failed forever on any filesystem
|
||||
without hard links. The feature was removed rather than fixed a fifth time, and it came
|
||||
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
|
||||
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
|
||||
would undo the whole point while looking like a simplification.
|
||||
None of the reservation machinery came back with it. There is no destination reservation,
|
||||
no placeholder rollback and no collision marker — the OS save dialog already asks about
|
||||
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
|
||||
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
|
||||
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
|
||||
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
|
||||
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
|
||||
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
||||
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
||||
at all; keep both halves and keep `PaneVisibility`). Backup is
|
||||
`file_commands::download_container_backup`.
|
||||
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||
after canonicalisation.** That includes the general hidden-component rule, which
|
||||
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
|
||||
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
|
||||
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
|
||||
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
|
||||
narrow the rule, because the terminal drop and `download_container_backup` still take
|
||||
their host path over IPC and this predicate is their only boundary.
|
||||
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||
host staging directory were held back for separate hardening and live on
|
||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||
@@ -126,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
|
||||
@@ -378,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.
|
||||
@@ -401,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`
|
||||
@@ -466,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
|
||||
@@ -489,6 +643,198 @@ 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
|
||||
|
||||
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||
for `container/**`.
|
||||
|
||||
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||
catches the real incident (`9b2f4fe`) when replayed.
|
||||
|
||||
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||
for it.
|
||||
|
||||
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||
|
||||
## Settings export/import
|
||||
|
||||
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||
deliberately out of scope — this is not a project backup.
|
||||
|
||||
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||
have carried it along as if it were as inert as a port number, and importing it would have
|
||||
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||
every export already gives it.
|
||||
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||
otherwise shared directory that changes between the two calls.
|
||||
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||
render directly.
|
||||
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||
An import is "restore this environment," so the settings half is a full replace, not a
|
||||
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||
window where a gateway recreation happens against the destination's old keys.
|
||||
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||
alone, a running container would keep serving the old key material indefinitely after an import
|
||||
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||
exactly when it should and no more.
|
||||
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||
third must not read as unqualified success just because the settings half of the import (which
|
||||
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||
- **The imported settings are validated *before* any secret is written, not just before the
|
||||
settings replace.** `apply_settings_import` calls
|
||||
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||
hint of.
|
||||
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||
here can hold a live credential.
|
||||
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||
token when none is already set, so a planted token would otherwise activate silently the next
|
||||
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||
it differs from the destination's current value, since re-importing the same risky configuration
|
||||
is still worth surfacing every time a user confirms an import.
|
||||
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||
as a second layer against the same failure mode.
|
||||
|
||||
## Packaging
|
||||
|
||||
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
|
||||
Windows are untouched.
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
|
||||
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
|
||||
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
|
||||
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
|
||||
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
|
||||
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
|
||||
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
|
||||
metadata and update information**, without which an AppImage manager can adopt the app but never
|
||||
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
|
||||
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
|
||||
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
|
||||
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
|
||||
|
||||
**There is deliberately no Arch package.** A
|
||||
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||
the AppImage is the Arch story.
|
||||
|
||||
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+172
-26
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,65 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one.
|
||||
|
||||
> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions.
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
|
||||
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||
because nothing installed it — so it will not show up in your app launcher, and
|
||||
running it from a file manager gives you a generic icon in the taskbar.
|
||||
|
||||
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Apps
|
||||
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
```
|
||||
|
||||
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||
copied or moved. To remove the entry again:
|
||||
|
||||
```bash
|
||||
./scripts/install-appimage.sh --uninstall
|
||||
```
|
||||
|
||||
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||
bundle would appear in the menu and then fail to start anything.
|
||||
|
||||
Two follow-ups worth knowing:
|
||||
|
||||
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||
with a newer version under a different filename, re-run the script against the
|
||||
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||
icon cache, not a failed install — see
|
||||
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -183,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 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -208,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -228,7 +288,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -351,7 +411,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -615,18 +675,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||
real choice — it overrides a global On, which a project could not previously do.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||
> when the container starts. Changing one stops and recreates the container.
|
||||
>
|
||||
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||
> Overview tab first. The other switches work on any image.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
@@ -1155,22 +1224,89 @@ 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
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
||||
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
|
||||
of a container; there is one button per file entry, and the file viewer offers it too
|
||||
- **Upload file** from your host into the current container directory — or **drop files straight
|
||||
onto the pane** from your desktop, which uploads them into the directory on screen
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
viewer
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates.
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
|
||||
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
|
||||
way are judged.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1216,10 +1352,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks (In the Container)
|
||||
### Creating Tasks
|
||||
|
||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
||||
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||
form for the name, the schedule and the prompt.
|
||||
|
||||
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
@@ -1478,3 +1618,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -24,7 +24,7 @@ This file is the architectural tour: what each subsystem is and why it works the
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
@@ -442,6 +442,39 @@ per project.
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
### Host File Transfers
|
||||
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||
canonical ones included, so this closes the inbound direction and not both.
|
||||
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||
save dialog parses its name box as a path, and a container can name a file
|
||||
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||
cannot be a path on any platform this ships to.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
## Inside a Project
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
@@ -495,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 |
|
||||
@@ -513,7 +546,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
@@ -536,7 +569,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
@@ -547,7 +580,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
@@ -561,7 +594,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
+15
-13
@@ -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)
|
||||
|
||||
@@ -412,13 +415,12 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -436,7 +438,7 @@ triple-c/
|
||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File browser operations
|
||||
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||
@@ -464,7 +466,7 @@ triple-c/
|
||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||
@@ -504,7 +506,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
Generated
+348
@@ -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",
|
||||
|
||||
+17
-1
@@ -9,9 +9,25 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"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",
|
||||
|
||||
Generated
+145
@@ -8,6 +8,41 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -310,6 +363,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -569,6 +631,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -5149,6 +5280,8 @@ dependencies = [
|
||||
name = "triple-c"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
@@ -5173,7 +5306,9 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5287,6 +5422,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -36,6 +36,13 @@ tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
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
|
||||
@@ -44,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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
||||
let containers = docker::list_sibling_containers().await?;
|
||||
let result: Vec<serde_json::Value> = containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"names": c.names,
|
||||
"image": c.image,
|
||||
"state": c.state,
|
||||
"status": c.status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
+1569
-1250
File diff suppressed because it is too large
Load Diff
@@ -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, ®istry)?;
|
||||
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, ®istry)?;
|
||||
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, ®istry)?;
|
||||
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, ®istry)?;
|
||||
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, ®istry)?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,16 @@ 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;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod notes_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod settings_export_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::models::Note;
|
||||
use crate::storage::notes_store;
|
||||
|
||||
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||
/// recently edited.
|
||||
///
|
||||
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||
/// of the same list — cannot drift into two different orders.
|
||||
#[tauri::command]
|
||||
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||
let mut notes = notes_store::load(&project_id)?;
|
||||
notes.sort_by(|a, b| {
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||
});
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
/// Insert or replace one note.
|
||||
///
|
||||
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||
/// here is editing one note.
|
||||
#[tauri::command]
|
||||
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||
notes_store::upsert(&project_id, note)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||
notes_store::delete(&project_id, ¬e_id)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
||||
Ok(state.settings_store.get())
|
||||
}
|
||||
|
||||
/// Everything `update_settings` refuses a save over, run against the store's
|
||||
/// *current* value and the incoming one.
|
||||
///
|
||||
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||
/// settings save — `settings_export_commands::apply_settings_import`
|
||||
/// restores three keychain secrets in the same command — can run this
|
||||
/// *first* and bail before touching anything, rather than discovering the
|
||||
/// rejection only when `update_settings` itself runs partway through.
|
||||
pub fn validate_settings_update(
|
||||
before: &AppSettings,
|
||||
incoming: &AppSettings,
|
||||
) -> Result<(), String> {
|
||||
// The global half of the same rule the project half gets in
|
||||
// `update_project`: a global custom env var is merged into every project's
|
||||
// container environment, so an unchecked name here reaches all of them.
|
||||
crate::models::validate_env_vars_update(
|
||||
&before.global_custom_env_vars,
|
||||
&incoming.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
// validated its per-project overrides and this side validated nothing,
|
||||
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||
// fallback for **every** project without an override
|
||||
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||
// home volume this release exists to bound.
|
||||
//
|
||||
// Grandfathered the same way project paths are: a value carried over
|
||||
// unchanged still saves, so a store written before this check cannot lock
|
||||
// the user out of their own settings.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
incoming.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
incoming.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
// Third host path this struct owns, same reasoning: any project with
|
||||
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||
// here is a read-write bind mount of whatever it names into every such
|
||||
// project's container.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"Docker socket path",
|
||||
before.docker_socket_path.as_deref(),
|
||||
incoming.docker_socket_path.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
@@ -90,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
GatewayAction::StopIfRunning => {
|
||||
log::info!("Model gateway disabled in settings — stopping the container");
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
||||
log::error!(
|
||||
"Failed to stop the model gateway after it was disabled: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
GatewayAction::RestartIfRunning => {
|
||||
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_image(
|
||||
image_name: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
docker::pull_image(&image_name, move |msg| {
|
||||
let _ = app_handle.emit("image-pull-progress", msg);
|
||||
@@ -302,7 +362,10 @@ mod tests {
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.enabled = false;
|
||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
||||
assert_eq!(
|
||||
gateway_action(&before, &after),
|
||||
GatewayAction::StopIfRunning
|
||||
);
|
||||
// Still true when it was already off — a stray running container is
|
||||
// still a container that shouldn't be up.
|
||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||
//! out of scope: per-project settings, per-project secrets, and anything
|
||||
//! living in a project's Docker volumes.
|
||||
//!
|
||||
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||
//! host path string is the exact shape of bug that produced this app's past
|
||||
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||
//! settings_import` resolves the chosen path itself and remembers it
|
||||
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||
//! the same file without the path ever crossing back over IPC.
|
||||
//!
|
||||
//! The *decrypted payload* is not cached between preview and apply — the
|
||||
//! password the frontend passes to each call is what it already held for
|
||||
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||
//! keeps the plaintext itself — export/import secrets included — around for
|
||||
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||
//!
|
||||
//! **This is new attack surface**: a settings export is a file one person
|
||||
//! can hand another and ask them to import, together with a password, and
|
||||
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||
//! wholesale — see the module doc on `models::settings_export` for the
|
||||
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||
//! and treat that as the standing example of the class of thing to keep
|
||||
//! checking for here, not a one-off fixed bug.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{secure, settings_crypto};
|
||||
use crate::AppState;
|
||||
|
||||
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||
/// whether the file it's about to re-read is the same one the user actually
|
||||
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||
/// on what gets applied — without this, a file replaced on disk between the
|
||||
/// two calls (this app's own stated threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||
/// apply silently different content than what the confirmation dialog showed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingSettingsImport {
|
||||
path: PathBuf,
|
||||
ciphertext_hash: [u8; 32],
|
||||
}
|
||||
|
||||
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(data).into()
|
||||
}
|
||||
|
||||
const FILE_EXTENSION: &str = "triplec";
|
||||
|
||||
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||
/// attacker who can just try a three-character password directly.
|
||||
const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
||||
fn suggested_export_name() -> String {
|
||||
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||
// earlier file just because the save dialog defaults to the same name.
|
||||
format!(
|
||||
"triple-c-settings-{}.{}",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
FILE_EXTENSION
|
||||
)
|
||||
}
|
||||
|
||||
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Export Triple-C settings")
|
||||
.set_file_name(suggested)
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.save_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Import Triple-C settings")
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.pick_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||
/// export with the web-terminal token blanked out of it — see the module
|
||||
/// doc comment on `models::settings_export` for why that field cannot
|
||||
/// travel through `settings` like the rest of this struct.
|
||||
///
|
||||
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||
/// treated as "nothing to export" for that one entry rather than aborting
|
||||
/// the whole export, matching how the rest of this app degrades a keychain
|
||||
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||
/// rather than surfacing it as a hard failure.
|
||||
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||
let mut settings = current;
|
||||
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||
|
||||
let secrets = ExportedSecrets {
|
||||
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||
web_terminal_access_token,
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
/// rather than a "failed" toast for a plain cancel.
|
||||
#[tauri::command]
|
||||
pub async fn export_settings(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||
// close as this pair of languages allows to the frontend's `.length`
|
||||
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||
// characters, which no reasonable password touches.
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(format!(
|
||||
"Use a password of at least {} characters.",
|
||||
MIN_PASSWORD_LEN
|
||||
));
|
||||
}
|
||||
|
||||
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||
if secrets.is_empty() {
|
||||
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||
}
|
||||
|
||||
let payload = SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
};
|
||||
|
||||
let plaintext = Zeroizing::new(
|
||||
serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||
);
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||
|
||||
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||
/// preview (counts and presence flags only — never a secret value) for a
|
||||
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||
///
|
||||
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||
/// against — does **not** remember the decrypted payload itself, so the
|
||||
/// password must be supplied again to actually apply it — seeing the preview
|
||||
/// is not the same as committing to it. The hash exists so it also can't be
|
||||
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||
/// proceed if the file on disk no longer matches what was just previewed.
|
||||
#[tauri::command]
|
||||
pub async fn preview_settings_import(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<SettingsImportPreview>, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to open a settings export.".to_string());
|
||||
}
|
||||
|
||||
let Some(path) = pick_import_open_path(&window).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||
});
|
||||
|
||||
Ok(Some(preview))
|
||||
}
|
||||
|
||||
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||
/// required, not just encouraged, since it is the only place a user is told
|
||||
/// what an import is about to touch before it touches it. That requirement
|
||||
/// is only real if the file can't change out from under it, so this also
|
||||
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||
/// two calls (this feature's own threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) must not be able
|
||||
/// to apply silently different content than what the confirmation dialog
|
||||
/// showed.
|
||||
///
|
||||
/// Global settings are replaced wholesale — an import is "restore this
|
||||
/// environment," not a field-by-field merge. Global secrets are handled
|
||||
/// differently and on purpose: **only secrets actually present in the
|
||||
/// import are written**; a secret the export doesn't have is left alone on
|
||||
/// this machine rather than cleared, because an absent secret in the export
|
||||
/// means "the source machine never had this configured," not "delete this
|
||||
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||
/// for that (signing out of shared auth, clearing the gateway key).
|
||||
///
|
||||
/// Order matters here, twice over.
|
||||
///
|
||||
/// First: the imported settings are **validated before any secret is
|
||||
/// written**, using the same checks `update_settings` itself runs
|
||||
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||
/// host path used to be caught only when `update_settings` ran, by which
|
||||
/// point the three keychain secrets below were already overwritten with the
|
||||
/// file's, each with a fresh rotation id, silently flagging every project
|
||||
/// container for recreation — while the error the user saw talked only
|
||||
/// about the rejected setting and said nothing about the credentials that
|
||||
/// had already moved. Failing this check first makes a rejected import
|
||||
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||
/// mean.
|
||||
///
|
||||
/// Second, among the things that *do* get written: secrets are restored
|
||||
/// **before** the settings replace runs (which is what triggers
|
||||
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||
/// the final key material rather than racing it — restoring the other way
|
||||
/// round left a real window where the running gateway and the keychain
|
||||
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||
/// nudges a running gateway container to recreate itself whenever a secret
|
||||
/// this import carried was actually written — otherwise the running
|
||||
/// container keeps serving the old key material indefinitely while every
|
||||
/// project container is handed the new one.
|
||||
///
|
||||
/// A keychain write failing is reported back rather than only logged: an
|
||||
/// import that silently restores two of three secrets but not the third
|
||||
/// must not read as unqualified success.
|
||||
///
|
||||
/// The pending import is only cleared on success. A failure here (rejected
|
||||
/// by the validation above, a stale-file mismatch, or some other error)
|
||||
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||
/// making them pick the file and re-enter the password again — the
|
||||
/// preview's job was confirming *what* to import, not spending the one
|
||||
/// attempt at applying it.
|
||||
#[tauri::command]
|
||||
pub async fn apply_settings_import(
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SettingsImportOutcome, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to import settings.".to_string());
|
||||
}
|
||||
|
||||
let pending = state
|
||||
.pending_settings_import
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||
|
||||
let encrypted = std::fs::read(&pending.path)
|
||||
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||
return Err(
|
||||
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
|
||||
let current = state.settings_store.get();
|
||||
|
||||
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||
// the keychain, so "leave an absent secret alone" has to be done by
|
||||
// hand here: carry the destination's current token forward when the
|
||||
// import doesn't have one, instead of letting the wholesale replace
|
||||
// below blank it (every export writes `None` there — see
|
||||
// `split_settings_and_secrets`).
|
||||
let mut settings = payload.settings;
|
||||
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||
.or_else(|| current.web_terminal.access_token.clone());
|
||||
|
||||
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the shared Claude login: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||
match secure::store_gateway_api_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway provider API key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings.push(format!(
|
||||
"Could not restore the gateway provider API key: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||
match secure::store_gateway_master_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway master key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore the gateway master key: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
|
||||
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||
// *shape* — port, provider, base URL, models — because that's what's
|
||||
// rendered into the container's config. A secret changing with the shape
|
||||
// held constant is invisible to it, so a running gateway container would
|
||||
// otherwise keep serving the old key material forever after an import
|
||||
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||
// (which does include the secret rotation id) means the *next* unrelated
|
||||
// settings save would suddenly and confusingly recreate it instead.
|
||||
if gateway_secret_changed && saved.gateway.enabled {
|
||||
match crate::docker::gateway::gateway_container_presence().await {
|
||||
Ok((true, true)) => {
|
||||
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||
{
|
||||
log::error!(
|
||||
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||
}
|
||||
}
|
||||
|
||||
state.pending_settings_import.lock().await.take();
|
||||
|
||||
Ok(SettingsImportOutcome {
|
||||
settings: saved,
|
||||
secret_restore_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||
/// of the payload is even worth attempting to parse.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FormatVersionProbe {
|
||||
format_version: u32,
|
||||
}
|
||||
|
||||
/// Read and decrypt an export file at `path`, then parse it — see
|
||||
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||
/// exists now for tests that don't need that.
|
||||
#[cfg(test)]
|
||||
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let encrypted =
|
||||
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
read_and_decrypt_bytes(&encrypted, password)
|
||||
}
|
||||
|
||||
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||
/// format version **before** attempting to deserialize the full payload.
|
||||
///
|
||||
/// That ordering is not just tidiness: a version bump that isn't
|
||||
/// deserialize-compatible (a field's type changes, not just a new
|
||||
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||
/// before the version check ever ran, surfacing a raw parse error instead
|
||||
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||
/// errors quote the offending value inline. This file is not attacker
|
||||
/// content in the usual sense (it must still decrypt under the right
|
||||
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||
/// so neither error path below ever interpolates what `serde_json`
|
||||
/// actually says — only a fixed, generic message.
|
||||
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||
|
||||
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||
return Err(format!(
|
||||
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||
Update Triple-C before importing it.",
|
||||
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_blank_treats_whitespace_only_as_absent() {
|
||||
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||
assert_eq!(non_blank(Some("".to_string())), None);
|
||||
assert_eq!(non_blank(None), None);
|
||||
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||
// What `apply_settings_import` compares against the pinned hash from
|
||||
// `preview_settings_import` to detect a file swapped out from under a
|
||||
// pending import — this only defends anything if identical bytes
|
||||
// always hash identically and any change to those bytes changes the
|
||||
// hash.
|
||||
let bytes = b"pretend this is an encrypted export file";
|
||||
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||
|
||||
let mut tampered = bytes.to_vec();
|
||||
tampered[0] ^= 0xFF;
|
||||
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||
}
|
||||
|
||||
fn write_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
payload: &SettingsExportPayload,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||
}
|
||||
|
||||
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||
/// deliberately not shape-compatible, which the typed helper above can't
|
||||
/// produce at all.
|
||||
fn write_raw_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
let plaintext = serde_json::to_vec(value).unwrap();
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, &encrypted).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(
|
||||
secrets.web_terminal_access_token,
|
||||
Some("super-secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(secrets.web_terminal_access_token, None);
|
||||
}
|
||||
|
||||
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||
SettingsExportPayload {
|
||||
format_version,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings: AppSettings::default(),
|
||||
secrets: ExportedSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-settings-export-test-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||
// future version could easily have changed `settings` from an object
|
||||
// to something else) as well as newer — so this only passes under
|
||||
// the probe-first ordering. Parsing the full struct first (the old
|
||||
// behavior) would fail on the shape mismatch and never reach the
|
||||
// version check, producing the "unexpected shape" message instead of
|
||||
// "newer version" / "Update Triple-C".
|
||||
let dir = temp_dir("newer-format");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "9.9.9",
|
||||
"settings": "this-app-version-stores-settings-differently",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||
assert!(err.contains("Update Triple-C"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_at_the_current_format_is_accepted() {
|
||||
let dir = temp_dir("current-format");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||
// A `format_version` the probe accepts, but a `settings` field of
|
||||
// the wrong *type* rather than just a missing field — this is what
|
||||
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||
// struct AppSettings" error that quotes the offending value
|
||||
// verbatim. That value here stands in for plaintext that, in a real
|
||||
// export, could be a live credential — the assertion below is only
|
||||
// meaningful against a fixture that actually exercises serde's
|
||||
// value-quoting behavior, which a merely-missing-field fixture does
|
||||
// not.
|
||||
let dir = temp_dir("malformed");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "0.4.14",
|
||||
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(
|
||||
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||
"leaked plaintext into the error: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("doesn't look like a valid settings export"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||
let dir = temp_dir("wrong-password");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||
assert!(
|
||||
err.contains("Wrong password"),
|
||||
"unexpected message: {}",
|
||||
err
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -197,18 +238,20 @@ pub async fn upload_host_file_to_terminal(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||
// itself, so it gets the same host-read policy as the Files pane's upload:
|
||||
// absolute, no traversal, and nothing out of a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
|
||||
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
|
||||
// is refused too. What comes back is that resolved path, and it is what
|
||||
// gets opened.
|
||||
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||
// no traversal, and nothing whose path passes through a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. Four commands touch a host path now,
|
||||
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||
// open their dialog from Rust instead, so for them the policy above is
|
||||
// defence in depth and for these two it is the boundary itself.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
// a symlink, would land it in the container as `2026-08-23.log`. The Files
|
||||
// pane's upload had the same bug and fixes it the same way — one helper, so
|
||||
// the two drop targets cannot drift.
|
||||
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
@@ -217,8 +260,24 @@ pub async fn upload_host_file_to_terminal(
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||
// regular file, reports `len() == 0`, and passes both the directory check
|
||||
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. This is one of two routes for getting a host file into a
|
||||
// container (the Files pane's upload is the other), so it is the wrong
|
||||
// place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
} else {
|
||||
format!(
|
||||
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||
host_path
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||
@@ -229,7 +288,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
meta.len() as f64 / (1024.0 * 1024.0),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
@@ -246,7 +305,13 @@ pub async fn upload_host_file_to_terminal(
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -301,20 +366,138 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Both drop targets must name a dropped file the way the *user* named it.
|
||||
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
|
||||
/// name from the path *after* symlink resolution, so dropping
|
||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||
/// target's name. Nothing errored; the user just got a name they never
|
||||
/// typed. The Files pane had the identical bug.
|
||||
/// typed.
|
||||
///
|
||||
/// What actually keeps the two from drifting is that they now call one
|
||||
/// helper, so this asserts that helper's contract from the terminal side:
|
||||
/// the 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
|
||||
/// This asserts the shared helper's contract from the terminal side: the
|
||||
/// 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;
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -330,29 +330,58 @@ impl ExecSessionManager {
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Same policy as the Files pane's upload — this
|
||||
// is the terminal's drop target, and the two must not differ.
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
@@ -381,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -389,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -601,44 +640,6 @@ pub async fn exec_oneshot_as(
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
|
||||
///
|
||||
/// H8. Nothing in this module bounds how long a container command may take,
|
||||
/// which is right for the callers that need it — a base-image migration replays
|
||||
/// `apt-get` and takes minutes — and wrong for a short command that can be made
|
||||
/// to block forever by a *file* the caller does not control. The upload
|
||||
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
|
||||
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
|
||||
/// directory left the Files pane on "Uploading…" for the rest of the session
|
||||
/// with the rest of the batch abandoned.
|
||||
///
|
||||
/// So the ceiling is opt-in per call site rather than global. Note what it can
|
||||
/// and cannot do: dropping the future closes our end of the stream, but Docker
|
||||
/// has no "kill an exec" API, so a process that is genuinely wedged stays
|
||||
/// wedged in the container's process table. That is why the primitive matters
|
||||
/// more than the timeout — this turns "the app never comes back" into "that
|
||||
/// upload failed", and it is the caller's job not to run something that blocks.
|
||||
pub async fn exec_oneshot_as_within(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: std::time::Duration,
|
||||
) -> Result<(String, i64), String> {
|
||||
match tokio::time::timeout(
|
||||
limit,
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!(
|
||||
"The container did not answer within {}s — the command may still be running inside it.",
|
||||
limit.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
@@ -817,8 +818,17 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
// Finished: use the reported code (default 0 if somehow absent).
|
||||
return Some(info.exit_code.unwrap_or(0));
|
||||
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||
// that has stopped without a reported code is a status
|
||||
// nobody can vouch for, and flattening it to *success* is
|
||||
// the wrong default when a caller is deciding whether to
|
||||
// rename a downloaded file over the user's own.
|
||||
// `download_container_file` treats `None` as a failure
|
||||
// precisely because it cannot tell that silence from a
|
||||
// clean exit; an `unwrap_or` here made that check
|
||||
// unreachable. Callers that only care about "did it fail
|
||||
// loudly" use `is_some_and`, which reads `None` as before.
|
||||
return info.exit_code;
|
||||
}
|
||||
}
|
||||
Err(_) => return None,
|
||||
@@ -930,4 +940,25 @@ mod tests {
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
|
||||
/// The reported path, which is what the terminal echoes back to Claude and
|
||||
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||
/// same file to the kernel and different strings to a person reading either
|
||||
/// of those.
|
||||
#[test]
|
||||
fn container_join_produces_one_separator() {
|
||||
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||
// The terminal's drop passes a nested name; it must not gain a second
|
||||
// slash at the seam.
|
||||
assert_eq!(
|
||||
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
// A directory the user navigated to can carry a trailing slash, and the
|
||||
// container root is the case where trimming it must not eat the only
|
||||
// separator there is.
|
||||
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||
let mut out: Vec<String> = paths
|
||||
.iter()
|
||||
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||
// That function skips a row with an empty `host_path` or `mount_name`
|
||||
// so a legacy row cannot brick the create. The consequence is that
|
||||
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||
// ordinary writable-layer content. Excluding it here would tell
|
||||
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||
// would then destroy whatever the user has put there. The two
|
||||
// predicates must agree or a migration silently eats a directory.
|
||||
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -877,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.
|
||||
///
|
||||
@@ -984,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
|
||||
@@ -1368,6 +1584,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The mount filter and the migration's exclusion list must agree.
|
||||
///
|
||||
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||
/// writable-layer content rather than a bind mount — and if this function
|
||||
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||
/// the container swap would destroy whatever is there. A migration eating a
|
||||
/// directory is the quietest kind of data loss there is.
|
||||
#[test]
|
||||
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||
let paths = vec![
|
||||
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||
// Legacy shapes that `project_path_mounts` skips.
|
||||
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||
];
|
||||
let excluded = bind_mount_exclusions(&paths);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec!["/workspace/code".to_string()],
|
||||
"only rows that are actually mounted may be excluded from staging"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
use crate::models::{
|
||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||
@@ -2113,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 \"$@\""));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+329
-7
@@ -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};
|
||||
@@ -29,6 +33,21 @@ pub struct AppState {
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
/// The file `preview_settings_import` last decrypted successfully, held
|
||||
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||
/// without the frontend ever passing a host path back to Rust as an
|
||||
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||
/// for why that direction specifically is the one this app treats as
|
||||
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||
/// nothing here holds a decrypted secret in memory for longer than one
|
||||
/// command's execution.
|
||||
///
|
||||
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||
/// changed underneath the pending import — otherwise confirming a
|
||||
/// preview is not actually binding on what gets applied.
|
||||
pub pending_settings_import:
|
||||
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -222,7 +241,9 @@ pub fn run() {
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
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) => {
|
||||
@@ -247,16 +268,34 @@ 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);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
// A container/image/volume `remove_project` could not delete
|
||||
// is recorded rather than lost — see triple-c#31 — and this is
|
||||
// the only place anything ever retries it. Takes the store so
|
||||
// it can refuse to touch a project that turns out to still be
|
||||
// live — see the long comment on the function itself.
|
||||
crate::commands::project_commands::retry_pending_cleanup_logged(
|
||||
&projects_store_for_cleanup,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
@@ -435,7 +474,6 @@ pub fn run() {
|
||||
commands::docker_commands::check_image_exists,
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
@@ -445,6 +483,10 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Notes
|
||||
commands::notes_commands::list_notes,
|
||||
commands::notes_commands::save_note,
|
||||
commands::notes_commands::delete_note,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
@@ -485,6 +527,10 @@ pub fn run() {
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
commands::settings_export_commands::preview_settings_import,
|
||||
commands::settings_export_commands::apply_settings_import,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
@@ -497,12 +543,19 @@ pub fn run() {
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_files_to_container,
|
||||
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
|
||||
@@ -511,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,
|
||||
@@ -689,6 +745,154 @@ mod tests {
|
||||
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
||||
/// unconditional `std::fs::read` of any host path with no scope check, and
|
||||
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
||||
/// Every `#[tauri::command]` is registered, and every registration names a
|
||||
/// command that exists.
|
||||
///
|
||||
/// This is the shape of the bug that caused the original OAuth-callback
|
||||
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
||||
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
||||
/// users to flip was never wired to anything, so the bridge stayed off and
|
||||
/// every login callback was refused. Nothing failed; the feature was simply
|
||||
/// absent, and no test noticed because both halves compiled.
|
||||
///
|
||||
/// The reverse direction matters too, and for a sharper reason: a command
|
||||
/// that is registered but reachable from nowhere is still IPC surface a
|
||||
/// compromised webview can call. `list_sibling_containers` — which returned
|
||||
/// every container on the daemon, including the user's unrelated work —
|
||||
/// sat in exactly that state, and this test is what found it. It has since
|
||||
/// been removed at all four levels: registration, command, docker helper,
|
||||
/// and the frontend wrapper and type.
|
||||
///
|
||||
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
||||
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
||||
/// vitest suite cover that side.
|
||||
#[test]
|
||||
fn every_command_is_registered_exactly_once() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// Walk the source tree for the command attribute and take the `fn` name
|
||||
// that follows.
|
||||
//
|
||||
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||
// Every one of those is a command the frontend could not call, which is
|
||||
// the bug this test exists for, and the test stayed green.
|
||||
//
|
||||
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||
// pass, while on the *registration* side it fails loudly against
|
||||
// legitimate code — and rustc already covers that direction. So this
|
||||
// errs toward over-matching definitions.
|
||||
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim();
|
||||
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||
// `#[tauri :: command]`, a bare `#[command]` under
|
||||
// `use tauri::command`, and any of those with a
|
||||
// trailing comment.
|
||||
let attr = t.strip_prefix("#[").map(|a| {
|
||||
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||
});
|
||||
let is_command_attr = attr.is_some_and(|a| {
|
||||
a == "command" || a == "tauri::command"
|
||||
|| a.starts_with("command(")
|
||||
|| a.starts_with("tauri::command(")
|
||||
});
|
||||
if !is_command_attr {
|
||||
continue;
|
||||
}
|
||||
// Skip further attributes and doc comments rather than
|
||||
// giving up at the first line that is not an attribute.
|
||||
for next in lines.iter().skip(i + 1) {
|
||||
let t = next.trim();
|
||||
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any visibility, then `fn` or `async fn`.
|
||||
let after_vis = t
|
||||
.strip_prefix("pub(crate) ")
|
||||
.or_else(|| t.strip_prefix("pub(super) "))
|
||||
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||
.or_else(|| t.strip_prefix("pub "))
|
||||
.unwrap_or(t);
|
||||
let after_async =
|
||||
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||
if let Some(name) = rest.split(['(', '<']).next() {
|
||||
out.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(
|
||||
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
||||
&mut defined,
|
||||
);
|
||||
|
||||
// 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");
|
||||
let registered: BTreeSet<String> = ordered.iter().cloned().collect();
|
||||
|
||||
assert!(
|
||||
!defined.is_empty() && !registered.is_empty(),
|
||||
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
||||
defined.len(),
|
||||
registered.len()
|
||||
);
|
||||
|
||||
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
||||
assert!(
|
||||
unregistered.is_empty(),
|
||||
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
||||
unregistered
|
||||
);
|
||||
|
||||
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
||||
assert!(
|
||||
undefined.is_empty(),
|
||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||
undefined
|
||||
);
|
||||
|
||||
// "exactly once" was in this test's name and not in its body: both
|
||||
// sides were sets, so registering the same command twice in a
|
||||
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||
// passed here.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut duplicated: Vec<&str> = Vec::new();
|
||||
for name in &ordered {
|
||||
if seen.contains(&name.as_str()) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicated.is_empty(),
|
||||
"these are registered more than once: {:?}",
|
||||
duplicated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
||||
let raw = include_str!("../capabilities/default.json");
|
||||
@@ -708,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",
|
||||
@@ -716,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.
|
||||
@@ -743,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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,157 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, killing the webview and leaving a blank window — see
|
||||
/// triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// **This is not the only cause of a blank window, and the error text alone
|
||||
/// does not tell them apart.** An earlier version of this comment quoted
|
||||
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
|
||||
/// the error this fixes. The AppImage produces that same string for an
|
||||
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
|
||||
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
|
||||
/// DT_NEEDED on that library, so the EGL driver failed to load before any
|
||||
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
|
||||
/// which cost a round of debugging that started from the comment rather than
|
||||
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone — with one
|
||||
/// correction. The earlier version of this function left *any* pre-set value
|
||||
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||
/// sees any difference.
|
||||
///
|
||||
/// That matters more than it looks, because the trade described above is not
|
||||
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||
/// this comment assumed it would degrade to, not faster. See
|
||||
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||
/// whether the addon is loaded at all.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
/// call. Narrow in practice (an already-running browser just receives the
|
||||
/// 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";
|
||||
|
||||
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||
/// already set to. Split from the mutation so it can be tested without
|
||||
/// touching process-wide environment state from a parallel test runner.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DmabufAction {
|
||||
/// Not set by the user — apply the workaround.
|
||||
Disable,
|
||||
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||
/// way to express "enabled" is for the variable not to exist.
|
||||
Remove,
|
||||
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||
LeaveAlone,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||
match current {
|
||||
None => DmabufAction::Disable,
|
||||
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||
_ => DmabufAction::LeaveAlone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
let current = std::env::var(DMABUF_VAR).ok();
|
||||
match dmabuf_action(current.as_deref()) {
|
||||
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||
DmabufAction::LeaveAlone => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::{dmabuf_action, DmabufAction};
|
||||
|
||||
#[test]
|
||||
fn unset_gets_the_workaround() {
|
||||
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||
// The bug this replaces: these all previously read as "user set it,
|
||||
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||
// it only checks presence. There was no way to ask for the GPU path.
|
||||
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::Remove,
|
||||
"{value:?} should opt out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_values_are_left_alone() {
|
||||
for value in ["1", "true", "yes", "anything"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::LeaveAlone,
|
||||
"{value:?} should be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||
///
|
||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||
/// The addon therefore loads successfully and then renders every frame on
|
||||
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||
/// disabled on Linux.
|
||||
///
|
||||
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||
/// the zero value has to mean "we choose", not "off", or every existing
|
||||
/// settings file would silently pin the answer at whatever the default was
|
||||
/// the day it was written.
|
||||
#[serde(default)]
|
||||
pub terminal_gpu_rendering: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
pub mod project;
|
||||
pub mod settings_export;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
pub use settings_export::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||
/// session.
|
||||
///
|
||||
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||
/// classification decision at writing time is one the user is least willing to
|
||||
/// make, and it would turn one pane into two features.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Pinned notes sort first, then by `updated_at` descending.
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(title: String, body: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title,
|
||||
body,
|
||||
pinned: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// ## Why a charset rule, and not just the reserved-name list
|
||||
///
|
||||
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||
/// that is not an identifier travels through unchallenged.
|
||||
///
|
||||
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||
/// exported shell function: bash imports those at startup and the *body* is the
|
||||
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||
/// code execution inside the container at commit time.
|
||||
///
|
||||
/// So the rule is the shape of the thing rather than a list of the names that
|
||||
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||
/// a `%`, a `(` or a space in an environment variable name.
|
||||
///
|
||||
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||
/// break a setting that works.
|
||||
pub fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.trim().chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Validate a custom environment variable list that is about to be stored,
|
||||
/// admitting the entries it is already stored with.
|
||||
///
|
||||
/// Same shape, and the same reasoning, as
|
||||
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||
/// a project unsavable *entirely* — `update_project` is the single command
|
||||
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||
/// is already being handed to every container that starts. An entry carried
|
||||
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||
/// key through this command.
|
||||
///
|
||||
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||
/// second copy of an existing entry is a new entry.
|
||||
///
|
||||
/// The blank entry is not a violation. "+ Add variable" appends
|
||||
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||
/// turn the button itself into an error toast; `create_container` skips an
|
||||
/// empty key, so it reaches nothing.
|
||||
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||
// An entry with no key is the placeholder, whatever is in its value:
|
||||
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||
// to refuse. The editor saves on every blur, and typing the value before
|
||||
// the name is an ordinary way to fill a row in.
|
||||
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||
|
||||
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||
std::collections::HashMap::new();
|
||||
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||
*carried
|
||||
.entry((v.key.as_str(), v.value.as_str()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||
Some(remaining) if *remaining > 0 => {
|
||||
*remaining -= 1;
|
||||
}
|
||||
_ => {
|
||||
if !is_valid_env_key(&v.key) {
|
||||
return Err(format!(
|
||||
"'{}' is not a usable environment variable name. Use a letter or \
|
||||
underscore followed by letters, digits or underscores.",
|
||||
v.key
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectPath {
|
||||
pub host_path: String,
|
||||
@@ -38,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
|
||||
@@ -85,6 +199,7 @@ impl PermissionMode {
|
||||
/// Settings for Claude Code CLI behavior inside the container.
|
||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(from = "StoredClaudeCodeSettings")]
|
||||
/// Every field is three-state, and the third state is load-bearing.
|
||||
///
|
||||
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||
@@ -98,24 +213,24 @@ pub struct ClaudeCodeSettings {
|
||||
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||
/// one. All three are distinct — "let it choose" is not "classic".
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tui_mode: Option<String>,
|
||||
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||
/// zero value of this field has to mean "leave it on".
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_scroll_disabled: Option<bool>,
|
||||
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus_mode: Option<bool>,
|
||||
/// Show thinking summaries in responses
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_thinking_summaries: Option<bool>,
|
||||
/// Turn the session recap **off**.
|
||||
///
|
||||
@@ -128,16 +243,99 @@ pub struct ClaudeCodeSettings {
|
||||
/// never touched the control holds — as "the user turned the recap off" and
|
||||
/// silently disabled it for all of them. A new name lets the old key be
|
||||
/// ignored, which lands every existing project on the correct default.
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_recap_disabled: Option<bool>,
|
||||
/// Strip credentials from subprocess environments
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_scrub: Option<bool>,
|
||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_caching_1h: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||
/// through.
|
||||
///
|
||||
/// ## The upgrade this exists to survive
|
||||
///
|
||||
/// Before the widening, the five booleans were plain `bool`s with
|
||||
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||
/// information at all: it was the only value an unset switch could produce, and
|
||||
/// the global always won.
|
||||
///
|
||||
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||
/// credentials from subprocess environments") among them. There is no store
|
||||
/// migration anywhere: `projects_store` parses these structs directly.
|
||||
///
|
||||
/// ## How an old record is told apart from a new one
|
||||
///
|
||||
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||
/// so the old key could be ignored (see the doc on that field), and the new
|
||||
/// code has never written it. Its presence is therefore an exact statement that
|
||||
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||
/// booleans are read back that way: `true` is a real choice and survives,
|
||||
/// `false` becomes `None` and inherits again.
|
||||
///
|
||||
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||
/// an older binary reads an absent key as `false` through its own
|
||||
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||
/// and starts empty on an error.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredClaudeCodeSettings {
|
||||
#[serde(default)]
|
||||
tui_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
effort: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_scroll_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
focus_mode: Option<bool>,
|
||||
#[serde(default)]
|
||||
show_thinking_summaries: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_recap_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
env_scrub: Option<bool>,
|
||||
#[serde(default)]
|
||||
prompt_caching_1h: Option<bool>,
|
||||
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||
/// use of its value: presence dates the record. Its meaning was inverted
|
||||
/// and it never worked, so it is read for the marker and discarded.
|
||||
#[serde(default)]
|
||||
enable_session_recap: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||
let pre_widening = stored.enable_session_recap.is_some();
|
||||
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||
// so it means "not set at this level" and must inherit. A `true` was a
|
||||
// real choice either way.
|
||||
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||
ClaudeCodeSettings {
|
||||
tui_mode: stored.tui_mode,
|
||||
effort: stored.effort,
|
||||
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||
focus_mode: read(stored.focus_mode),
|
||||
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||
session_recap_disabled: read(stored.session_recap_disabled),
|
||||
env_scrub: read(stored.env_scrub),
|
||||
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
@@ -158,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:
|
||||
@@ -244,6 +455,61 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||
/// of reporting a clean removal that was not one.
|
||||
///
|
||||
/// The project record is dropped from `projects.json` regardless — see the
|
||||
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||
/// anything named here is also written to a pending-cleanup record that
|
||||
/// startup housekeeping retries, so it stays reachable after the project it
|
||||
/// belonged to no longer exists.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectRemovalReport {
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||
/// not the container id, since the id can be stale or absent and the
|
||||
/// name is what a later retry can still resolve.
|
||||
pub container: Option<String>,
|
||||
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||
pub image: Option<String>,
|
||||
/// Named volumes (home, claude config) that could not be removed.
|
||||
pub volumes: Vec<String>,
|
||||
/// True once the leftovers above were durably recorded for automatic
|
||||
/// retry on the next launch. False means the pending-cleanup record
|
||||
/// itself could not be written — nothing will retry these, and the UI
|
||||
/// must say so rather than promising a retry that will not happen.
|
||||
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||
pub retry_scheduled: bool,
|
||||
}
|
||||
|
||||
impl ProjectRemovalReport {
|
||||
/// True when nothing was left behind.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||
/// stands after restarting, and anything Reset could not clear.
|
||||
///
|
||||
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||
/// there is no pending-cleanup record for either: the project id survives
|
||||
/// Reset, so a later Reset attempt can retry them itself.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectResetOutcome {
|
||||
pub project: Project,
|
||||
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||
/// more serious of the two leftovers here: the new container is created
|
||||
/// from this image whenever it exists, so a surviving image means Reset
|
||||
/// silently rebuilt the exact system layer it was asked to discard.
|
||||
pub leftover_image: Option<String>,
|
||||
/// Volumes that survived Reset and were mounted into the new container
|
||||
/// unchanged.
|
||||
pub leftover_volumes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
@@ -406,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(),
|
||||
@@ -467,3 +733,254 @@ impl Project {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||
assert!(ProjectRemovalReport::default().is_clean());
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.container = Some("abc123".to_string());
|
||||
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.volumes.push("triple-c-home-x".to_string());
|
||||
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||
}
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||
}
|
||||
for bad in [
|
||||
// bash's wire format for an exported shell function: the value is
|
||||
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||
"BASH_FUNC_stat%%",
|
||||
"BASH_FUNC_ls()",
|
||||
"MY VAR",
|
||||
"2FAST",
|
||||
"WITH-DASH",
|
||||
"WITH.DOT",
|
||||
"",
|
||||
" ",
|
||||
"$(id)",
|
||||
"A=B",
|
||||
] {
|
||||
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||
}
|
||||
}
|
||||
|
||||
fn env(key: &str, value: &str) -> EnvVar {
|
||||
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||
// Introducing it through the Config tab is the escalation.
|
||||
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||
// Already stored: it is handed to every container that starts whether
|
||||
// or not an unrelated save is allowed through, and refusing the save
|
||||
// would make every toggle on the Config tab fail.
|
||||
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||
// Editing its value is a new entry, and refused again.
|
||||
assert!(
|
||||
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||
);
|
||||
// Fixing the name is what the message asks for, and it saves.
|
||||
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||
// Dropping it entirely is always fine.
|
||||
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||
// so this is the button, not an attempt at anything.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||
// Typing the value before the name is an ordinary way to fill it in,
|
||||
// and an entry with no name reaches no container either way.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||
let stored = [env("BAD NAME", "v")];
|
||||
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||
// A second copy is a new entry, and held to the rule.
|
||||
assert!(
|
||||
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code settings written before the fields were widened ───────
|
||||
|
||||
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||
/// booleans were plain `bool`s that always serialised, so every project
|
||||
/// that ever opened the editor carries `false` for the ones it never
|
||||
/// touched.
|
||||
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||
"container_id": null,
|
||||
"status": "stopped",
|
||||
"backend": "anthropic",
|
||||
"bedrock_config": null,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"claude_code_settings": {
|
||||
"tui_mode": "fullscreen",
|
||||
"effort": null,
|
||||
"auto_scroll_disabled": false,
|
||||
"focus_mode": false,
|
||||
"show_thinking_summaries": false,
|
||||
"enable_session_recap": false,
|
||||
"env_scrub": false,
|
||||
"prompt_caching_1h": false
|
||||
},
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
let stored = project.claude_code_settings.expect("settings should parse");
|
||||
|
||||
// Read verbatim these would be `Some(false)`, which under
|
||||
// `docker::container::merge_claude_code_settings` beats the global.
|
||||
assert_eq!(stored.env_scrub, None);
|
||||
assert_eq!(stored.auto_scroll_disabled, None);
|
||||
assert_eq!(stored.focus_mode, None);
|
||||
assert_eq!(stored.show_thinking_summaries, None);
|
||||
assert_eq!(stored.prompt_caching_1h, None);
|
||||
assert_eq!(stored.session_recap_disabled, None);
|
||||
// A value the user did choose is untouched.
|
||||
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||
|
||||
// The merge rule itself, spelled the way
|
||||
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||
// and it has to go on winning, because the user never turned this off.
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(
|
||||
stored.env_scrub.or(global.env_scrub),
|
||||
Some(true),
|
||||
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||
// Same record without the pre-widening key: this `false` is the
|
||||
// deliberate off the widening exists to make expressible.
|
||||
let json = r#"{ "env_scrub": false }"#;
|
||||
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(chosen.env_scrub, Some(false));
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||
// A downgrade parses these fields as plain `bool` with
|
||||
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||
// with one null empties the whole list and the next save persists that.
|
||||
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
assert!(!json.contains("null"));
|
||||
|
||||
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||
let json = serde_json::to_string(&partial).unwrap();
|
||||
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||
// And it reads back as what it is.
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
||||
//! and after decryption (see `storage::settings_crypto`). It bundles
|
||||
//! `AppSettings` — with one field carved out, see below — with the global
|
||||
//! secrets that live in the OS keychain instead: the shared Claude Code
|
||||
//! OAuth login and the model gateway's two keys. Per-project settings,
|
||||
//! per-project secrets, and anything living in a project's Docker volumes
|
||||
//! are deliberately out of scope: this exports the *host* environment, not
|
||||
//! any one project's.
|
||||
//!
|
||||
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
||||
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
||||
//! server that binds every interface, stored as a plain field on the
|
||||
//! struct that is otherwise safe to treat as config. A review of this
|
||||
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
||||
//! that token along as if it were as inert as a port number, and — worse —
|
||||
//! importing it would apply `web_terminal.enabled` and the token together
|
||||
//! with no more warning than any other setting, letting a crafted export
|
||||
//! silently stand up a LAN-listening terminal server with an
|
||||
//! attacker-known token on the next launch. `export_settings` /
|
||||
//! `apply_settings_import` blank this field out of the `settings` they
|
||||
//! read from and write to, and it travels only through
|
||||
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
||||
//! "only overwrite what the import actually has" treatment as the other
|
||||
//! three secrets.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AppSettings, ImageSource};
|
||||
|
||||
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
||||
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
||||
/// field is ever removed or its meaning changes. `apply_settings_import`
|
||||
/// checks this before touching anything.
|
||||
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// The global secrets bundled into an export. Deliberately a separate struct
|
||||
/// from `AppSettings`: these live in the OS keychain, never in
|
||||
/// `settings.json`, and — outside of this export/import flow — the values
|
||||
/// themselves never cross into the frontend; see the doc comments on
|
||||
/// `storage::secure::get_gateway_api_key` and
|
||||
/// `commands::settings_export_commands` for why that boundary matters here
|
||||
/// too.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExportedSecrets {
|
||||
#[serde(default)]
|
||||
pub claude_oauth_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_master_key: Option<String>,
|
||||
/// See the module doc comment — this is `AppSettings::web_terminal
|
||||
/// .access_token`, carved out because it is a live bearer credential,
|
||||
/// not config, despite living on a struct that is otherwise safe to
|
||||
/// export wholesale.
|
||||
#[serde(default)]
|
||||
pub web_terminal_access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportedSecrets {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
||||
blank(&self.claude_oauth_token)
|
||||
&& blank(&self.gateway_api_key)
|
||||
&& blank(&self.gateway_master_key)
|
||||
&& blank(&self.web_terminal_access_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `apply_settings_import` hands back: the settings that were actually
|
||||
/// saved, plus a human-readable note for each keychain secret this import
|
||||
/// carried but could not be restored. A keychain write failing partway
|
||||
/// through must not read as unqualified success just because the settings
|
||||
/// half of the import went through.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportOutcome {
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secret_restore_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The full plaintext payload — this is what gets encrypted on export and
|
||||
/// what decryption recovers on import. Never written to disk unencrypted;
|
||||
/// see `storage::settings_crypto`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsExportPayload {
|
||||
pub format_version: u32,
|
||||
/// RFC3339. Purely informational — shown in the import preview so a user
|
||||
/// picking between a few old export files has something to go on.
|
||||
pub exported_at: String,
|
||||
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
||||
/// field below already round-trips through `#[serde(default)]`-covered
|
||||
/// `AppSettings`, so an older or newer export still deserializes; this is
|
||||
/// for a human to notice "this is from a much older version" if an import
|
||||
/// ever looks wrong, not something the code branches on.
|
||||
pub app_version: String,
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secrets: ExportedSecrets,
|
||||
}
|
||||
|
||||
/// What `preview_settings_import` hands the frontend before anything is
|
||||
/// applied — counts and presence flags only, **never** a secret value itself,
|
||||
/// so this type is safe to return across the IPC boundary and render
|
||||
/// directly. The confirmation UI is built from this.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportPreview {
|
||||
pub exported_at: String,
|
||||
pub app_version: String,
|
||||
pub custom_env_var_count: usize,
|
||||
pub gateway_model_count: usize,
|
||||
pub has_claude_code_settings: bool,
|
||||
pub has_claude_oauth_token: bool,
|
||||
pub has_gateway_api_key: bool,
|
||||
pub has_gateway_master_key: bool,
|
||||
pub has_web_terminal_access_token: bool,
|
||||
/// Whether the imported settings turn the web terminal on. Named
|
||||
/// separately from the token above: `enabled` and the token are two
|
||||
/// different fields, either can be true without the other, and
|
||||
/// "this import turns on a service that listens on your network" is
|
||||
/// exactly the kind of change a wholesale settings replace must not
|
||||
/// bury in a generic "settings replaced" line — see the module doc
|
||||
/// comment on why this field exists at all.
|
||||
pub enables_web_terminal: bool,
|
||||
/// Non-blank custom base URLs the import would set, so a redirect of
|
||||
/// model traffic to somewhere other than the usual provider is visible
|
||||
/// at import time rather than discovered later. These are endpoints, not
|
||||
/// secrets — safe to show verbatim, unlike everything above.
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub llamacpp_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_base: Option<String>,
|
||||
/// Whether the import sets a custom Docker image, and its name if so —
|
||||
/// disclosed for the same reason as the base URLs above, and arguably
|
||||
/// more sharply: this is the image *every* project container is created
|
||||
/// from (`models::container_config::resolve_image_name`), so a crafted
|
||||
/// export pointing it at an attacker-controlled image is a path to
|
||||
/// running arbitrary code with whatever a project's containers are
|
||||
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
||||
/// not merely a redirected API endpoint.
|
||||
#[serde(default)]
|
||||
pub image_source: ImageSource,
|
||||
#[serde(default)]
|
||||
pub custom_image_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
/// into a preview a user reads and a UI renders without truncation of its
|
||||
/// own. Applied to every field above that carries free-form text straight
|
||||
/// from the import file rather than a count or a boolean — a base URL or an
|
||||
/// image name a hostile export author controls has had no validation done
|
||||
/// on it yet at preview time, and nothing stops it from being pathological
|
||||
/// (embedded control characters, or long enough to blow out the confirmation
|
||||
/// dialog and push the security warnings below it off screen).
|
||||
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
||||
|
||||
fn sanitize_for_preview(value: &str) -> String {
|
||||
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
||||
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsImportPreview {
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
.map(sanitize_for_preview)
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
Self {
|
||||
exported_at: payload.exported_at.clone(),
|
||||
app_version: payload.app_version.clone(),
|
||||
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
||||
gateway_model_count: payload.settings.gateway.models.len(),
|
||||
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
||||
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
||||
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
||||
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
||||
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
||||
enables_web_terminal: payload.settings.web_terminal.enabled,
|
||||
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
||||
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
||||
openai_compatible_base_url: sanitized_non_blank(
|
||||
&payload.settings.global_openai_compatible.base_url,
|
||||
),
|
||||
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
||||
image_source: payload.settings.image_source.clone(),
|
||||
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::AppSettings;
|
||||
|
||||
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
||||
let settings = AppSettings {
|
||||
global_custom_env_vars: vec![
|
||||
crate::models::EnvVar {
|
||||
key: "A".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::models::EnvVar {
|
||||
key: "B".to_string(),
|
||||
value: "2".to_string(),
|
||||
},
|
||||
],
|
||||
..AppSettings::default()
|
||||
};
|
||||
SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_never_carries_a_secret_value() {
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
||||
gateway_api_key: Some("sk-another-secret".to_string()),
|
||||
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
||||
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let serialized = serde_json::to_string(&preview).unwrap();
|
||||
|
||||
assert!(!serialized.contains("sk-super-secret-token"));
|
||||
assert!(!serialized.contains("sk-another-secret"));
|
||||
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
||||
assert!(!serialized.contains("wt-super-secret-token"));
|
||||
assert!(preview.has_claude_oauth_token);
|
||||
assert!(preview.has_gateway_api_key);
|
||||
assert!(preview.has_gateway_master_key);
|
||||
assert!(preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
||||
// A keychain entry that exists but holds only whitespace must not
|
||||
// read as "present" — same "blank counts as absent" rule the
|
||||
// keychain layer itself applies when storing these.
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
gateway_api_key: None,
|
||||
gateway_master_key: None,
|
||||
web_terminal_access_token: Some(" ".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(!preview.has_claude_oauth_token);
|
||||
assert!(!preview.has_gateway_api_key);
|
||||
assert!(!preview.has_gateway_master_key);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
||||
// `enabled` and the token are independent fields — a crafted export
|
||||
// could set one without the other, and both are worth a user's
|
||||
// attention: this is the field that exists specifically so "this
|
||||
// import turns on a service that listens on your network" cannot
|
||||
// hide inside a generic "settings replaced" summary.
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.web_terminal.enabled = true;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(preview.enables_web_terminal);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
||||
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
||||
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(
|
||||
preview.ollama_base_url.as_deref(),
|
||||
Some("http://attacker.example:11434")
|
||||
);
|
||||
assert_eq!(preview.llamacpp_base_url, None);
|
||||
assert_eq!(preview.openai_compatible_base_url, None);
|
||||
assert_eq!(
|
||||
preview.gateway_api_base.as_deref(),
|
||||
Some("https://gateway.example/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_reflect_the_real_settings() {
|
||||
let payload = payload_with(ExportedSecrets::default());
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.custom_env_var_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
||||
assert!(ExportedSecrets::default().is_empty());
|
||||
assert!(!ExportedSecrets {
|
||||
claude_oauth_token: Some("x".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
||||
// Matches the "blank counts as absent" rule every other consumer of
|
||||
// these fields applies (`has_claude_oauth_token` and friends above) —
|
||||
// a keychain entry that exists but holds only whitespace carries
|
||||
// nothing usable, so the export-time "nothing to export" log line
|
||||
// must still fire for it.
|
||||
assert!(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_docker_image_is_surfaced() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.image_source = crate::models::ImageSource::Custom;
|
||||
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
||||
assert_eq!(
|
||||
preview.custom_image_name.as_deref(),
|
||||
Some("ghcr.io/attacker/triple-c:latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url =
|
||||
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let shown = preview.ollama_base_url.expect("non-blank base url");
|
||||
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
||||
// +1 for the trailing ellipsis appended when truncated.
|
||||
assert!(
|
||||
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
||||
"preview string was not capped: {} chars",
|
||||
shown.chars().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod migration_store;
|
||||
pub mod notes_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_crypto;
|
||||
pub mod settings_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Host-side persistence for per-project notes.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||
//! free-function shape as `migration_store` — no struct, nothing in
|
||||
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||
//! caches the project list; a store that reads and writes the file per call
|
||||
//! has nothing to cache and nothing to guard.
|
||||
//!
|
||||
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||
//! there would mean the whole project list is rewritten per edit, and a note
|
||||
//! save racing a Config save would silently drop one of them.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Note;
|
||||
|
||||
/// The version stamped into every notes file this build writes.
|
||||
const NOTES_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// What is actually on disk: a version envelope around the notes.
|
||||
///
|
||||
/// The list is wrapped rather than written bare because the wrapper costs
|
||||
/// nothing today and cannot be added cheaply later — once files exist in the
|
||||
/// field, every reader has to sniff two shapes forever. `version` is written
|
||||
/// and read back but nothing branches on it yet: it is the hook a future
|
||||
/// format change hangs off, and its value is only useful if it has been there
|
||||
/// since the first file.
|
||||
///
|
||||
/// Not in `models/` and not exposed over IPC: the frontend receives
|
||||
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
|
||||
/// storage detail rather than part of the IPC contract.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ProjectNotes {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
notes: Vec<Note>,
|
||||
}
|
||||
|
||||
/// Serialises the read-modify-write half of an upsert or delete.
|
||||
///
|
||||
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||
/// those interleaving would lose whichever note was written first. The read
|
||||
/// path does not take it.
|
||||
fn write_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||
pub fn notes_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the notes directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||
|
||||
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||
load_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||
upsert_in(¬es_dir()?, project_id, note)
|
||||
}
|
||||
|
||||
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
delete_in(¬es_dir()?, project_id, note_id)
|
||||
}
|
||||
|
||||
/// Remove a project's notes file entirely. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
// ── Implementation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a project's notes. A missing file is an empty list.
|
||||
///
|
||||
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||
/// that project with no way out through the UI; deleting instead would destroy
|
||||
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||
/// corruption cannot overwrite the first — which is the one taken before
|
||||
/// anything rewrote the file, and therefore the one worth having — and capped,
|
||||
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
|
||||
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||
match parse(&data) {
|
||||
Ok(notes) => Ok(notes),
|
||||
Err(e) => {
|
||||
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||
log::error!(
|
||||
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||
left in place{}",
|
||||
project_id,
|
||||
e,
|
||||
kept.describe()
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a notes file: the versioned envelope, or a bare array.
|
||||
///
|
||||
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
|
||||
/// only ever on a development build, but a developer's own notes are still
|
||||
/// prose nothing else holds a copy of, and the alternative is `load_in`
|
||||
/// declaring a perfectly readable file corrupt. It is read, never written: the
|
||||
/// first save rewrites the file with an envelope.
|
||||
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
|
||||
match serde_json::from_str::<ProjectNotes>(data) {
|
||||
Ok(file) => Ok(file.notes),
|
||||
// Report the envelope's error, not the array's — the envelope is the
|
||||
// shape this store writes, so its message is the one that describes
|
||||
// what is actually wrong with the file.
|
||||
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt notes file are kept.
|
||||
///
|
||||
/// Timestamping fixes "a second corruption overwrote the first" and introduces
|
||||
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
|
||||
/// mount — every project switch, every dock-follows-tab change, every sub-tab
|
||||
/// toggle. A file that is *persistently* unparseable (the normal case, since
|
||||
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
|
||||
/// prose every time the clock's second changed. Nothing ever reads them back
|
||||
/// and nothing ever removed them.
|
||||
///
|
||||
/// Four is enough for the only use there is: a human looking at what the file
|
||||
/// held. Same constant, same reasoning as `migration_store`.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
|
||||
/// whether a file exists.
|
||||
///
|
||||
/// Three outcomes, and they must not be conflated. Folding "already kept
|
||||
/// enough" into success and then saying "a copy was kept" names a file that
|
||||
/// was never created — which is what someone reads before going to look for
|
||||
/// their data.
|
||||
enum Kept {
|
||||
Copied(PathBuf),
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere(PathBuf),
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
fn describe(&self) -> String {
|
||||
match self {
|
||||
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.display()),
|
||||
// The earliest copies are the ones worth having, so the cap keeps
|
||||
// those and drops this one. Say so, rather than implying a file
|
||||
// exists.
|
||||
Kept::EnoughAlready(n) => format!(
|
||||
" (no copy kept — {} earlier copies of this file are already saved alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a copy of an unreadable notes file is kept.
|
||||
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file already exist.
|
||||
///
|
||||
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||
/// implemented by writing a file and deleting it again on every pass — and so
|
||||
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||
/// whatever produced the corruption.
|
||||
///
|
||||
/// A directory that cannot be listed answers "not full": failing open costs at
|
||||
/// most one extra file, and failing closed would drop the very first copy of
|
||||
/// prose nothing else has kept.
|
||||
fn corrupt_backups_full(path: &Path) -> bool {
|
||||
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||
return false;
|
||||
};
|
||||
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||
// this can never match another project's copies or an unrelated `.bak`.
|
||||
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||
})
|
||||
.count()
|
||||
>= MAX_CORRUPT_BACKUPS
|
||||
}
|
||||
|
||||
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
|
||||
let backup = corrupt_backup_path(path, now);
|
||||
if backup.exists() {
|
||||
return Kept::AlreadyThere(backup);
|
||||
}
|
||||
if corrupt_backups_full(path) {
|
||||
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
|
||||
}
|
||||
match fs::copy(path, &backup) {
|
||||
Ok(_) => Kept::Copied(backup),
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace one note, leaving the rest untouched.
|
||||
///
|
||||
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||
/// `updated_at` is stamped here for the same reason.
|
||||
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||
Some(existing) => {
|
||||
note.created_at = existing.created_at.clone();
|
||||
*existing = note.clone();
|
||||
}
|
||||
None => notes.push(note.clone()),
|
||||
}
|
||||
save_all(dir, project_id, ¬es)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||
/// retry a delete whose result it never saw.
|
||||
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
let before = notes.len();
|
||||
notes.retain(|n| n.id != note_id);
|
||||
if notes.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
save_all(dir, project_id, ¬es)
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = notes_path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove notes: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write the whole list.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||
/// applied and the data not written — a truncated file, produced by the code
|
||||
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||
/// the directory after it, since the rename is directory metadata. Notes are
|
||||
/// prose the user typed and nothing else holds a copy.
|
||||
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
let file = ProjectNotes {
|
||||
version: NOTES_FORMAT_VERSION,
|
||||
notes: notes.to_vec(),
|
||||
};
|
||||
let data = serde_json::to_string_pretty(&file)
|
||||
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file =
|
||||
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||
///
|
||||
/// Best effort only where it is meaningless: Windows has no directory handle
|
||||
/// to sync and returns an error for the attempt, so a failure is logged rather
|
||||
/// than propagated. The file's own `sync_all` carries the data and is not best
|
||||
/// effort.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
log::debug!(
|
||||
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-notes-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.contains(".corrupt-"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_notes_directory() {
|
||||
// The id arrives over IPC. It must not be able to steer the write.
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize("a\\b"), "a_b");
|
||||
// A real UUID must survive untouched, or every note file would move
|
||||
// the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||
let dir = temp_dir("missing");
|
||||
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upserted_note_round_trips() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
assert_eq!(saved.id, note.id);
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||
let dir = temp_dir("replace");
|
||||
let mut note = Note::new("Title".into(), "first".into());
|
||||
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
note.body = "second".into();
|
||||
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||
assert_eq!(loaded[0].body, "second");
|
||||
assert_ne!(
|
||||
saved.created_at, "1999-01-01T00:00:00Z",
|
||||
"created_at is owned by the store, not by whatever the webview sent"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||
let dir = temp_dir("delete");
|
||||
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
|
||||
// Idempotent: removing what is already gone is not an error, because
|
||||
// the UI can retry a delete it never saw the result of.
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||
// Same reasoning as migration_store: a corrupt file must not make the
|
||||
// tab permanently unusable, and the bytes must not be destroyed.
|
||||
let dir = temp_dir("corrupt");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||
assert!(path.exists(), "the unreadable file is left in place");
|
||||
|
||||
assert_eq!(
|
||||
corrupt_copies(&dir).len(),
|
||||
1,
|
||||
"the bytes must be kept exactly once"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
|
||||
// The envelope costs nothing now and cannot be added cheaply once
|
||||
// files exist in the field, so the very first file has to carry it.
|
||||
let dir = temp_dir("envelope");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
|
||||
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["notes"][0]["body"], "b");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
|
||||
// Only a development build ever wrote this shape, but declaring a
|
||||
// perfectly readable file corrupt is the one outcome this store exists
|
||||
// to avoid. It is read, never written back.
|
||||
let dir = temp_dir("legacy");
|
||||
let note = Note::new("Deploy".into(), "one\ntwo".into());
|
||||
std::fs::write(
|
||||
notes_path_in(&dir, "p1"),
|
||||
serde_json::to_string(&vec![note.clone()]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
let copies = corrupt_copies(&dir);
|
||||
assert!(copies.is_empty(), "a readable file must not be copied aside");
|
||||
|
||||
// The next write upgrades it in place.
|
||||
upsert_in(&dir, "p1", note).unwrap();
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
assert!(raw.contains("\"version\""));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_are_capped_rather_than_one_per_second() {
|
||||
// `list_notes` runs on every panel mount, so an unrepaired file would
|
||||
// otherwise mint a full copy of the user's prose every time the
|
||||
// clock's second changed.
|
||||
let dir = temp_dir("cap");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let base = chrono::Utc::now();
|
||||
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
|
||||
let at = base + chrono::Duration::seconds(i);
|
||||
let kept = keep_corrupt_copy(&path, &at);
|
||||
if i < MAX_CORRUPT_BACKUPS as i64 {
|
||||
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
|
||||
"copy {} should be refused by the cap",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_read_in_the_same_second_does_not_re_copy() {
|
||||
let dir = temp_dir("samesecond");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let at = chrono::Utc::now();
|
||||
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
|
||||
assert!(matches!(
|
||||
keep_corrupt_copy(&path, &at),
|
||||
Kept::AlreadyThere(_)
|
||||
));
|
||||
assert_eq!(corrupt_copies(&dir).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_line_never_claims_a_backup_that_was_not_written() {
|
||||
// A message that invents a backup is worse than no message: it is what
|
||||
// someone reads before going to look for their data.
|
||||
let dir = temp_dir("honesty");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
|
||||
assert!(copied.contains("a copy is at"));
|
||||
|
||||
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
|
||||
assert!(refused.contains("no copy kept"));
|
||||
assert!(!refused.contains("a copy is at"));
|
||||
|
||||
let failed = Kept::Failed("permission denied".into()).describe();
|
||||
assert!(failed.contains("could not keep a copy"));
|
||||
assert!(!failed.contains("a copy is at"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_leaves_no_temp_file_behind() {
|
||||
let dir = temp_dir("tmp");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||
let dir = temp_dir("clear");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
assert!(notes_path_in(&dir, "p1").exists());
|
||||
|
||||
clear_in(&dir, "p1").unwrap();
|
||||
assert!(!notes_path_in(&dir, "p1").exists());
|
||||
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||
// `remove_project` must not be able to fail because a project simply
|
||||
// never had any notes — an orphaned notes file is harmless, a project
|
||||
// that cannot be removed is not.
|
||||
let dir = temp_dir("removal");
|
||||
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||
//!
|
||||
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||
//! in the app can name the leftover container, image or volume again by any
|
||||
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||
//! written *before* the project record is dropped. Startup housekeeping
|
||||
//! retries every record on the next launch (see
|
||||
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||
//! the ones that fully succeed.
|
||||
//!
|
||||
//! **This record is written in the same instant its record in `projects.json`
|
||||
//! is destroyed, and it is the only remaining handle on the leftover
|
||||
//! resource** — which is a stronger claim on durability than an ordinary
|
||||
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||
//! to other readers, not to power loss. A crash in that window leaves the
|
||||
//! rename applied and the data half-written, which [`list`] then treats as
|
||||
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||
//! directory-sync shape `migration_store` does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCleanup {
|
||||
pub project_id: String,
|
||||
/// Kept only so a log line or a future UI can name the project without a
|
||||
/// second lookup — the project record itself is already gone by the time
|
||||
/// this is read back.
|
||||
pub project_name: String,
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||
/// container id Docker handed out — Docker's remove-container API
|
||||
/// accepts either, and the name is the one identifier guaranteed to still
|
||||
/// resolve to the same container by the time a retry runs.
|
||||
pub container_id: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
impl PendingCleanup {
|
||||
/// True once nothing named here still needs to be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||
fn dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("pending-cleanup");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||
/// `storage::migration_store::sanitize`.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) a project's pending-cleanup record.
|
||||
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||
save_in(&dir()?, record)
|
||||
}
|
||||
|
||||
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(&dir()?, project_id)
|
||||
}
|
||||
|
||||
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||
/// skipped rather than blocking every other project's retry — the same
|
||||
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||
pub fn list() -> Vec<PendingCleanup> {
|
||||
let Ok(dir) = dir() else { return Vec::new() };
|
||||
list_in(&dir)
|
||||
}
|
||||
|
||||
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||
/// after it — see the module doc comment for why a plain
|
||||
/// write-temp-then-rename is not enough here. Mirrors
|
||||
/// `storage::migration_store::save`/`sync_dir`.
|
||||
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||
let path = path_in(dir, &record.project_id);
|
||||
let data = serde_json::to_string_pretty(record)
|
||||
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let path = path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let data = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) => {
|
||||
// Moved aside rather than left in place: a record nothing
|
||||
// ever repairs would otherwise warn on every single
|
||||
// startup forever, same as an ordinary `.json` file it
|
||||
// would keep looking like one to `list_in` on the next
|
||||
// call too. One aside-copy is enough here — this only
|
||||
// ever holds names to retry removing, not the class of
|
||||
// once-in-a-lifetime crash evidence `migration_store`
|
||||
// keeps multiple timestamped backups of.
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||
log::warn!(
|
||||
"Could not parse pending cleanup record {}: {}{}",
|
||||
path.display(),
|
||||
err,
|
||||
if moved {
|
||||
format!(" — moved aside to {}", corrupt.display())
|
||||
} else {
|
||||
" — leaving it in place".to_string()
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power
|
||||
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||
/// private to that module, so this is a small deliberate duplicate rather
|
||||
/// than a shared dependency between two otherwise-independent stores.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-pending-cleanup-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn record(project_id: &str) -> PendingCleanup {
|
||||
PendingCleanup {
|
||||
project_id: project_id.to_string(),
|
||||
project_name: "Some Project".to_string(),
|
||||
container_id: Some("triple-c-abc".to_string()),
|
||||
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||
volumes: vec!["triple-c-home-abc".to_string()],
|
||||
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||
let mut r = record("p1");
|
||||
assert!(!r.is_empty());
|
||||
|
||||
r.container_id = None;
|
||||
r.image = None;
|
||||
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||
|
||||
r.volumes.clear();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||
/// re-implementation of their bodies — against a temp directory standing
|
||||
/// in for `dir()`.
|
||||
#[test]
|
||||
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let rec = record("proj-1");
|
||||
|
||||
save_in(&dir, &rec).expect("save");
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-1");
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
clear_in(&dir, "proj-1").expect("clear");
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A second `save` for the same project overwrites rather than appending
|
||||
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||
/// record behind it.
|
||||
#[test]
|
||||
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||
let dir = temp_dir("overwrite");
|
||||
let mut rec = record("proj-1");
|
||||
save_in(&dir, &rec).expect("save");
|
||||
|
||||
rec.container_id = None;
|
||||
rec.image = None;
|
||||
save_in(&dir, &rec).expect("save again");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||
assert!(found[0].container_id.is_none());
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse must not poison the rest of the listing.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||
let dir = temp_dir("corrupt");
|
||||
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||
save_in(&dir, &record("proj-2")).expect("save");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-2");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse is moved aside once, rather than left in
|
||||
/// place to be re-warned about — and re-warned about — on every future
|
||||
/// launch forever.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||
let dir = temp_dir("corrupt-aside");
|
||||
let bad = dir.join("bad.json");
|
||||
fs::write(&bad, "{ not json").unwrap();
|
||||
|
||||
list_in(&dir);
|
||||
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||
let corrupt = dir.join("bad.json.corrupt");
|
||||
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||
|
||||
// A second pass must not warn about `bad.json` again — it is gone —
|
||||
// and must not choke on `.json.corrupt` already being there.
|
||||
assert!(list_in(&dir).is_empty());
|
||||
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||
/// leaves behind if a crash lands between the write and the rename — the
|
||||
/// whole point of the temp-then-rename dance is that only the renamed
|
||||
/// file is ever a complete record.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_not_listed() {
|
||||
let dir = temp_dir("tmp-leftover");
|
||||
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing by project id must remove exactly the file that id maps to
|
||||
/// under `sanitize`, and nothing else.
|
||||
#[test]
|
||||
fn clearing_one_project_does_not_touch_another() {
|
||||
let dir = temp_dir("clear-scoped");
|
||||
save_in(&dir, &record("proj-a")).unwrap();
|
||||
save_in(&dir, &record("proj-b")).unwrap();
|
||||
|
||||
clear_in(&dir, "proj-a").unwrap();
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-b");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
if let Some(existing) = get_gateway_master_key()? {
|
||||
return Ok(existing);
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Read the gateway master key without minting one if none exists yet.
|
||||
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||
/// effect the read half of that function must not have — settings export
|
||||
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||
/// one exists".
|
||||
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||
.filter(|k| !k.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
store_gateway_master_key(&key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an exact given gateway master key, replacing any previous one.
|
||||
///
|
||||
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||
/// restoring the *same* key an export captured is the point — projects on
|
||||
/// the destination machine may not exist yet, but a project migrated or
|
||||
/// re-added later that still has the old key pasted into its config must
|
||||
/// keep working against it. Blank input is rejected rather than silently
|
||||
/// stored, matching every other `store_*` function in this module.
|
||||
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Password-based encryption for the settings export/import file — see
|
||||
//! triple-c#35.
|
||||
//!
|
||||
//! The exported payload can carry live credentials (the shared Claude OAuth
|
||||
//! token, the gateway provider/master keys — see
|
||||
//! `commands::settings_export_commands`), so this is not encryption for its
|
||||
//! own sake; a wrong or missing key here is a real credential leak, not a
|
||||
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
|
||||
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
|
||||
//! any reasonable iteration count is not), and AES-256-GCM is what actually
|
||||
//! encrypts — authenticated, so a wrong password is detected by a failed tag
|
||||
//! check rather than producing silent garbage.
|
||||
//!
|
||||
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
|
||||
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
|
||||
//! the clear right here, on purpose. The salt's only job is to make two
|
||||
//! exports with the same password derive different keys (defeats a
|
||||
//! precomputed-table attack against the password alone); the nonce's job is
|
||||
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
|
||||
//! because a fresh random value is drawn for each, on every call to
|
||||
//! [`encrypt`].
|
||||
//!
|
||||
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
|
||||
//! associated data, not just placed alongside the ciphertext — free to do,
|
||||
//! and it makes tampering with any header byte fail the same authentication
|
||||
//! check the ciphertext gets, by construction rather than as a side effect
|
||||
//! of the salt/nonce also feeding key derivation and the cipher.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Identifies the file as a Triple-C settings export and pins the format —
|
||||
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
|
||||
/// new magic value, not a silent reinterpretation of old bytes.
|
||||
const MAGIC: &[u8; 4] = b"TCX1";
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
|
||||
|
||||
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
|
||||
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
|
||||
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
|
||||
/// this runs once per export/import rather than on every request, so trading
|
||||
/// roughly a second of wall time for real brute-force resistance costs
|
||||
/// nothing a user would notice.
|
||||
fn argon2_params() -> Params {
|
||||
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
|
||||
}
|
||||
|
||||
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
|
||||
/// when it drops rather than left in freed memory for whatever reuses that
|
||||
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
|
||||
/// tree via `aes-gcm`) for material that exists only to decrypt live
|
||||
/// credentials.
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
|
||||
let mut key = Zeroizing::new([0u8; KEY_LEN]);
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
|
||||
/// file's bytes (header + ciphertext) — see the module doc for the layout.
|
||||
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
let key = derive_key(password, &salt)?;
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut header = Vec::with_capacity(HEADER_LEN);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.extend_from_slice(&salt);
|
||||
header.extend_from_slice(&nonce_bytes);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
// The header (magic + salt + nonce) is authenticated as associated data
|
||||
// even though none of it is secret: it costs nothing extra here, and it
|
||||
// means tampering with any header byte is caught by the same tag check
|
||||
// that already covers the ciphertext, by construction rather than as a
|
||||
// side effect of the header also feeding key/nonce derivation.
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut out = header;
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
|
||||
/// wrong password is deliberately generic ("wrong password, or the file is
|
||||
/// corrupted") rather than distinguishing the two: GCM's authentication tag
|
||||
/// fails to verify for the wrong key on essentially any ciphertext, so there
|
||||
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
|
||||
/// and guessing would be worse than saying so.
|
||||
///
|
||||
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
|
||||
/// this recovers is the whole settings-plus-secrets payload, so it gets the
|
||||
/// same "wipe it when it drops" treatment as the derived key in
|
||||
/// [`derive_key`].
|
||||
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
|
||||
}
|
||||
if &data[..MAGIC.len()] != MAGIC {
|
||||
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
|
||||
}
|
||||
let header = &data[..HEADER_LEN];
|
||||
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
|
||||
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let key = derive_key(password, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
|
||||
let plaintext = b"{\"settings\": \"whatever\"}";
|
||||
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
|
||||
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(&*decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_fails_rather_than_returning_garbage() {
|
||||
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
|
||||
let result = decrypt(&encrypted, "wrong password");
|
||||
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
|
||||
// If this ever failed it would mean the salt or nonce stopped being
|
||||
// randomized — either one repeating is a real security regression
|
||||
// (a fixed salt lets an attacker precompute against the password
|
||||
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
|
||||
// outright), not just a cosmetic one.
|
||||
let a = encrypt(b"same plaintext", "same password").unwrap();
|
||||
let b = encrypt(b"same plaintext", "same password").unwrap();
|
||||
assert_ne!(a, b, "two independent exports must not be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
|
||||
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
|
||||
let last = encrypted.len() - 1;
|
||||
encrypted[last] ^= 0xFF;
|
||||
assert!(decrypt(&encrypted, "a password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
|
||||
assert!(decrypt(b"short", "any password").is_err());
|
||||
assert!(decrypt(b"", "any password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_the_wrong_magic_is_rejected() {
|
||||
let mut encrypted = encrypt(b"payload", "password").unwrap();
|
||||
encrypted[0] = b'X';
|
||||
assert!(decrypt(&encrypted, "password").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, &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(¤t, ¤t, 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(¤t, ¤t, 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(¤t, ¤t, 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(¤t, ¤t, 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(¤t, &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(¤t, &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));
|
||||
}
|
||||
}
|
||||
@@ -431,6 +431,18 @@
|
||||
const mobileInput = document.getElementById('mobileInput');
|
||||
const btnEnter = document.getElementById('btnEnter');
|
||||
const btnNewline = document.getElementById('btnNewline');
|
||||
|
||||
// Whether the *active* session understands ESC+CR as "insert a newline".
|
||||
//
|
||||
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
|
||||
// sending it there is a silent no-op — which is worse from the mobile bar
|
||||
// than from a hardware key, because the bar puts a dedicated button on
|
||||
// screen that appears to do nothing. The xterm key handler is already scoped
|
||||
// this way; these two paths were not.
|
||||
function activeSessionTakesEscCr() {
|
||||
const s = activeSessionId && sessions[activeSessionId];
|
||||
return !!s && s.type === 'claude';
|
||||
}
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
@@ -590,7 +602,7 @@
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
@@ -641,8 +653,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||
// Prefer the type the *server* reports for this session. The old path read
|
||||
// a single `pendingSessionType` global set at request time, so opening two
|
||||
// sessions before the first reply landed swapped their labels — routine on
|
||||
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||
// sends a bare CR and submits a half-written prompt.
|
||||
//
|
||||
// The fallback keeps an older server working, and defaults to `claude`,
|
||||
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||
// a bare CR in Claude Code loses the prompt.
|
||||
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
@@ -735,7 +757,13 @@
|
||||
sessionType === 'claude'
|
||||
) {
|
||||
sendTerminalInput('\x1b\r');
|
||||
return false; // xterm must not also send a bare CR, which submits
|
||||
// `preventDefault()` is what stops the submit, not the `return false`.
|
||||
// xterm's `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still fires and emits a bare CR for Enter — inserting the
|
||||
// newline and then submitting the prompt anyway. See the same comment
|
||||
// in TerminalView.tsx.
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -791,6 +819,7 @@
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
syncNewlineButton();
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -817,6 +846,7 @@
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
syncNewlineButton();
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -896,7 +926,7 @@
|
||||
// reasoning, as the terminal's own key handler above. A hardware
|
||||
// keyboard on a tablet is the only way to reach this; the phone case is
|
||||
// the dedicated newline button beside Enter.
|
||||
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
|
||||
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
sendTerminalInput('\t');
|
||||
@@ -904,7 +934,27 @@
|
||||
});
|
||||
|
||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => {
|
||||
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
|
||||
sendTerminalInput('\x1b\r');
|
||||
mobileInput.focus();
|
||||
};
|
||||
|
||||
// Keep the button's affordance honest: on a shell tab there is no byte that
|
||||
// means "newline without running the line", so the control is disabled
|
||||
// rather than left looking live.
|
||||
function syncNewlineButton() {
|
||||
const usable = activeSessionTakesEscCr();
|
||||
btnNewline.disabled = !usable;
|
||||
btnNewline.title = usable
|
||||
? 'Insert a newline without submitting (Shift+Enter)'
|
||||
: 'Only Claude sessions support this — a shell runs the line instead';
|
||||
}
|
||||
|
||||
// With no session open yet, `activeSessionTakesEscCr()` is already false —
|
||||
// but nothing had called this, so the button rendered live before the first
|
||||
// tab existed.
|
||||
syncNewlineButton();
|
||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
||||
Opened {
|
||||
session_id: String,
|
||||
project_name: String,
|
||||
/// Echoed back so the client can label the session from the reply
|
||||
/// rather than from a global set at request time.
|
||||
///
|
||||
/// Without it the client correlates through a single
|
||||
/// `pendingSessionType`, so opening two sessions before the first
|
||||
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||
/// Claude session mislabelled as a shell now submits a half-written
|
||||
/// prompt instead of inserting a newline.
|
||||
session_type: String,
|
||||
},
|
||||
Output {
|
||||
session_id: String,
|
||||
@@ -196,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
|
||||
@@ -207,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
|
||||
@@ -226,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}'..."
|
||||
@@ -250,9 +267,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
@@ -319,6 +338,11 @@ async fn handle_open(
|
||||
let _ = out_tx.send(ServerMessage::Opened {
|
||||
session_id,
|
||||
project_name,
|
||||
// Derived from the same match that chose `cmd` above, not echoed from
|
||||
// the request: anything that is not exactly "bash" runs Claude, so
|
||||
// echoing the raw value would label an unrecognised string as its own
|
||||
// type and put the client back where it started.
|
||||
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
import NotesDock from "./components/layout/NotesDock";
|
||||
import TerminalView from "./components/terminal/TerminalView";
|
||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<NotesDock />
|
||||
</div>
|
||||
<StatusBar stt={stt} />
|
||||
<ToastHost />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../store/appState";
|
||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import type { PermissionMode } from "../../lib/types";
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -195,11 +196,10 @@ export default function MainTabs() {
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
return sessionDisplayName(
|
||||
session,
|
||||
projects.find((p) => p.id === session.projectId),
|
||||
);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
@@ -358,13 +358,7 @@ export default function MainTabs() {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const displayLabel = sessionDisplayName(session, project);
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NotesDock from "./NotesDock";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
vi.mock("../notes/NotesDockPanel", () => ({
|
||||
default: ({ projectId }: { projectId: string }) => (
|
||||
<div data-testid="panel">{`panel:${projectId}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let state: Record<string, unknown> = {};
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
isHomeTab: (k: string) => k.startsWith("home:"),
|
||||
isTerminalTab: (k: string) => k.startsWith("term:"),
|
||||
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
|
||||
// The mocked store module still needs to supply the width constants the
|
||||
// dock imports from it for the separator's aria-value attributes.
|
||||
NOTES_DOCK_MIN_WIDTH: 260,
|
||||
NOTES_DOCK_MAX_WIDTH: 720,
|
||||
}));
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: "s1",
|
||||
projectId: "p9",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
notesDockOpen: true,
|
||||
setNotesDockOpen: vi.fn(),
|
||||
toggleNotesDock: vi.fn(),
|
||||
notesDockWidth: 352,
|
||||
setNotesDockWidth: vi.fn(),
|
||||
activeTabKey: null,
|
||||
sessions: [session],
|
||||
projects: [{ id: "p9", name: "api" } as unknown as Project],
|
||||
};
|
||||
});
|
||||
|
||||
describe("NotesDock", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
state.notesDockOpen = false;
|
||||
const { container } = render(<NotesDock />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("follows a project home tab", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
|
||||
});
|
||||
|
||||
it("follows the project of the active terminal tab", () => {
|
||||
// The dock exists to be visible while the agent runs, so a terminal tab
|
||||
// must resolve to its project, not to nothing.
|
||||
state.activeTabKey = "term:s1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
|
||||
});
|
||||
|
||||
it("explains itself when no project is active", () => {
|
||||
state.activeTabKey = null;
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing for a terminal whose session has gone", () => {
|
||||
state.activeTabKey = "term:vanished";
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at the stored width", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
state.notesDockWidth = 420;
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
|
||||
});
|
||||
|
||||
it("has a keyboard-reachable resize handle", () => {
|
||||
// Drag is a mouse gesture; a separator that only responds to pointer
|
||||
// events is unusable without one.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
|
||||
// The dock sits on the right edge, so dragging or pressing left grows it
|
||||
// and right shrinks it. Asserting only "was called" would pass even if
|
||||
// the branches were swapped or the sign inverted.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
isTerminalTab,
|
||||
tabKeyId,
|
||||
NOTES_DOCK_MIN_WIDTH,
|
||||
NOTES_DOCK_MAX_WIDTH,
|
||||
} from "../../store/appState";
|
||||
import NotesDockPanel from "../notes/NotesDockPanel";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
* Notes beside whatever is on screen.
|
||||
*
|
||||
* Project Home and Terminal are sibling top-level tabs, so notes living only
|
||||
* in a sub-tab would be hidden exactly when the agent is running — which is
|
||||
* when a note is worth sending. The dock is the answer to that.
|
||||
*
|
||||
* **It takes space from inside the window and never resizes it.** Growing the
|
||||
* OS window was tried and rejected on evidence: honoured under XWayland,
|
||||
* silently corrupting under native Wayland, where `outer_position()` returns a
|
||||
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
|
||||
* §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s
|
||||
* ResizeObserver already reflows xterm and resizes the container PTY.
|
||||
*/
|
||||
export default function NotesDock() {
|
||||
const {
|
||||
notesDockOpen,
|
||||
setNotesDockOpen,
|
||||
notesDockWidth,
|
||||
setNotesDockWidth,
|
||||
activeTabKey,
|
||||
sessions,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
setNotesDockOpen: s.setNotesDockOpen,
|
||||
notesDockWidth: s.notesDockWidth,
|
||||
setNotesDockWidth: s.setNotesDockWidth,
|
||||
activeTabKey: s.activeTabKey,
|
||||
sessions: s.sessions,
|
||||
})),
|
||||
);
|
||||
|
||||
// Dragging the separator. Pointer capture rather than window listeners, so
|
||||
// the drag survives the pointer crossing the terminal — which swallows
|
||||
// events — and ends correctly if the button is released outside the window.
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const handle = e.currentTarget;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX;
|
||||
const startWidth = notesDockWidth;
|
||||
// The dock is on the right, so dragging left widens it.
|
||||
const onMove = (move: PointerEvent) =>
|
||||
setNotesDockWidth(startWidth + (startX - move.clientX));
|
||||
const onUp = () => {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
handle.removeEventListener("pointermove", onMove);
|
||||
handle.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
handle.addEventListener("pointermove", onMove);
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 64 : 16;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth + step);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth - step);
|
||||
}
|
||||
};
|
||||
|
||||
if (!notesDockOpen) return null;
|
||||
|
||||
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||
// is the project it belongs to.
|
||||
let projectId: string | null = null;
|
||||
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||
projectId = tabKeyId(activeTabKey);
|
||||
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
|
||||
projectId =
|
||||
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Notes"
|
||||
style={{ width: `${notesDockWidth}px` }}
|
||||
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
|
||||
>
|
||||
{/* Separator, not decoration: it carries a role and arrow keys, because
|
||||
a resize that only answers to a drag is unavailable to anyone not
|
||||
using a mouse. */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize notes panel"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={notesDockWidth}
|
||||
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
|
||||
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
|
||||
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{projectId ? (
|
||||
<NotesDockPanel projectId={projectId} />
|
||||
) : (
|
||||
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
|
||||
Open a project or a terminal to see its notes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function StatusBar({ stt }: Props) {
|
||||
const {
|
||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||
terminalAtBottom, scrollActiveToBottom,
|
||||
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
projects: s.projects,
|
||||
@@ -18,8 +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;
|
||||
@@ -58,17 +60,26 @@ 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
|
||||
onClick={toggleNotesDock}
|
||||
aria-pressed={notesDockOpen}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Show or hide the notes panel beside the current tab"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
{sttEnabled && activeSessionId && (
|
||||
<SttButton
|
||||
state={stt.state}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onBodyChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and body, saved when a field loses focus.
|
||||
*
|
||||
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||
* so there is no moment where the text on screen is not the text that would be
|
||||
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||
* than nearly true.
|
||||
*/
|
||||
export default function NoteEditor({
|
||||
projectId,
|
||||
title,
|
||||
body,
|
||||
onTitleChange,
|
||||
onBodyChange,
|
||||
onCommit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||
{/* Wraps rather than overflows. The two buttons are a group with a fixed
|
||||
appetite (~190px) and the title field can shrink only so far, so in a
|
||||
narrow dock the title takes the first row and the buttons the second.
|
||||
Without the wrap the group is simply clipped by the dock's
|
||||
`overflow-hidden`, which puts Delete off-window with no scrollbar to
|
||||
reach it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-24 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* The live editor text, not `note.body` — what is on screen is what
|
||||
gets sent. */}
|
||||
<SendToAgentButton projectId={projectId} body={body} />
|
||||
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => onBodyChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||
prompt — you press Enter.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const onTitleChange = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") =>
|
||||
render(
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selectedId}
|
||||
title={title}
|
||||
onTitleChange={onTitleChange}
|
||||
onCommit={onCommit}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("NoteSwitcher", () => {
|
||||
it("edits the title in place, committing on blur", () => {
|
||||
setup([note()]);
|
||||
const field = screen.getByLabelText("Note title");
|
||||
expect(field).toHaveValue("Deploy steps");
|
||||
|
||||
fireEvent.change(field, { target: { value: "Deploy steps v2" } });
|
||||
expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2");
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(field);
|
||||
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the other notes out of the way until asked for", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports whether the list is open", () => {
|
||||
setup([note()]);
|
||||
const trigger = screen.getByRole("button", { name: /switch note/i });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("marks the current note as the selected option", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas");
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a note and closes", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names an untitled note rather than showing an empty row", () => {
|
||||
setup([note({ title: " " })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Notes are addressed by id, never by title. Two untitled notes are the
|
||||
// ordinary case, and a title-keyed list would collapse them into one row.
|
||||
it("lists two notes that share a title as two options", () => {
|
||||
setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
const options = screen.getAllByRole("option", { name: "Untitled note" });
|
||||
expect(options).toHaveLength(2);
|
||||
|
||||
fireEvent.click(options[1]);
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
});
|
||||
|
||||
it("closes on Escape without selecting anything", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
export const UNTITLED = "Untitled note";
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
selectedId: string;
|
||||
title: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row that both names the current note and switches to another.
|
||||
*
|
||||
* The dock has no room for a permanent list of titles, so the title field
|
||||
* doubles as the label of what is open and the chevron beside it holds the
|
||||
* rest. Renaming therefore needs no separate affordance.
|
||||
*
|
||||
* Two honest controls rather than one `role="combobox"`: a text field and a
|
||||
* button that opens a listbox. A real combobox owes its listbox keyboard
|
||||
* navigation, active-descendant tracking and an input that filters — none of
|
||||
* which this needs, and half of which is worse than not claiming the role.
|
||||
*
|
||||
* `OverflowMenu` is deliberately not reused here despite the shape being
|
||||
* close. It keys its items by label, and notes are addressed by id: two
|
||||
* untitled notes are the ordinary case and would collapse into one row.
|
||||
*/
|
||||
export default function NoteSwitcher({
|
||||
notes,
|
||||
selectedId,
|
||||
title,
|
||||
onTitleChange,
|
||||
onCommit,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true" className="leading-none text-[10px]">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Notes"
|
||||
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
|
||||
would put an implicit `listitem` between the listbox and its
|
||||
options, which is not a child role a listbox owns. */}
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={n.id === selectedId}
|
||||
onClick={() => {
|
||||
onSelect(n.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
|
||||
n.id === selectedId
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
|
||||
: "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendProps: Record<string, unknown>[] = [];
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
sendProps.push(props);
|
||||
return <button type="button">Send to agent</button>;
|
||||
},
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendProps.length = 0;
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesDockPanel", () => {
|
||||
it("says it is loading rather than flashing an empty state", () => {
|
||||
loading = true;
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a first note when the project has none", async () => {
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// The point of the redesign: the dock spends its height on the note being
|
||||
// written, not on a permanent list of the ones that are not.
|
||||
it("shows one note at a time, the rest behind the switcher", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the note picked from the list", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("careful");
|
||||
});
|
||||
|
||||
it("saves the body when it loses focus, and not before", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
|
||||
it("opens the note it just created", async () => {
|
||||
notes = [note()];
|
||||
createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
|
||||
const view = render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
|
||||
notes = [note(), note({ id: "n9", title: "" })];
|
||||
view.rerender(<NotesDockPanel projectId="p1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue(""),
|
||||
);
|
||||
});
|
||||
|
||||
// The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
|
||||
// panel, so both of these are load-bearing rather than cosmetic.
|
||||
it("sends from a full-width bar whose menu opens upward", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
|
||||
expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
|
||||
});
|
||||
|
||||
it("sends what is on screen, not what was last saved", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), {
|
||||
target: { value: "edited but not blurred" },
|
||||
});
|
||||
|
||||
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
import OverflowMenu from "../ui/OverflowMenu";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes at dock width.
|
||||
*
|
||||
* Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
|
||||
* of titles beside the editor; the dock cannot, and shrinking that layout
|
||||
* spends its height on chrome — a title strip, a wrapped button row and a
|
||||
* paragraph of help — for a body that ends up a few words wide.
|
||||
*
|
||||
* So the dock shows exactly one note. The title row names it and switches to
|
||||
* another, the actions that are not writing live in the overflow menu, and
|
||||
* everything left over is the body. Roughly 240px of height comes back.
|
||||
*
|
||||
* What the two surfaces share is the part that must not drift: `useNotes` for
|
||||
* the cache and its write ordering, and `useNoteDraft` for when a keystroke
|
||||
* becomes a save. Only the layout is different.
|
||||
*/
|
||||
export default function NotesDockPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
Keep reminders here, and send any of them straight to a running Claude
|
||||
session.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selected.id}
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
onCommit={commit}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
{/* Renders nothing while idle, so it costs no width until it matters. */}
|
||||
<SaveIndicator state={saveState} />
|
||||
<OverflowMenu
|
||||
label="Note actions"
|
||||
items={[
|
||||
{ label: "New note", onSelect: () => void onCreate() },
|
||||
{
|
||||
label: "Delete note",
|
||||
danger: true,
|
||||
onSelect: () => void deleteNote(selected.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={commit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
|
||||
/>
|
||||
|
||||
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
|
||||
{/* The live draft, not `selected.body` — what is on screen is what gets
|
||||
sent. `dropUp` because the dock clips its own overflow. */}
|
||||
<SendToAgentButton
|
||||
projectId={projectId}
|
||||
body={body}
|
||||
fullWidth
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Two panels, one project — the configuration the app actually runs in.
|
||||
*
|
||||
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
|
||||
* and the dock follows the active tab's project, so opening the dock over a
|
||||
* Project Home tab mounts both for the *same* project. Every other notes test
|
||||
* mounts exactly one, which is precisely the configuration in which a
|
||||
* per-panel cache looks correct: it is only with two that an edit made in one
|
||||
* is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
|
||||
* here; the cache is what is under test.
|
||||
*
|
||||
* The two are different components on purpose, which is exactly why this test
|
||||
* pairs them rather than mounting the same one twice: the layouts diverged,
|
||||
* and the cache and draft rules they share are what must not.
|
||||
*/
|
||||
|
||||
const files: Record<string, Note[]> = {};
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
listNotes: async (p: string) => [...(files[p] ?? [])],
|
||||
saveNote: async (p: string, n: Note) => {
|
||||
const list = files[p] ?? (files[p] = []);
|
||||
const at = list.findIndex((x) => x.id === n.id);
|
||||
if (at === -1) list.unshift(n);
|
||||
else list[at] = n;
|
||||
return n;
|
||||
},
|
||||
deleteNote: async (p: string, id: string) => {
|
||||
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: () => <button type="button">Send to agent</button>,
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The tab and the dock, mounted together the way `App` mounts them. */
|
||||
function renderBothSurfaces() {
|
||||
render(
|
||||
<>
|
||||
<div data-testid="tab">
|
||||
<NotesPanel projectId="p1" />
|
||||
</div>
|
||||
<div data-testid="dock">
|
||||
<NotesDockPanel projectId="p1" />
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
return {
|
||||
tab: () => within(screen.getByTestId("tab")),
|
||||
dock: () => within(screen.getByTestId("dock")),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(files)) delete files[key];
|
||||
files.p1 = [note()];
|
||||
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||
});
|
||||
|
||||
describe("the tab and the dock both open on one project", () => {
|
||||
it("shows an edit made in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
|
||||
// The other surface's list *and* its editor, not just one of them.
|
||||
await waitFor(() =>
|
||||
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
|
||||
);
|
||||
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("does not write one surface's stale copy over the other's edit", async () => {
|
||||
// The reported repro: edit in the dock, then go back to the tab and edit
|
||||
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
|
||||
// and the dock's edit was gone from disk with no error and no indicator.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "two" } });
|
||||
fireEvent.blur(tabBody);
|
||||
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("two"));
|
||||
expect(files.p1).toHaveLength(1);
|
||||
expect(files.p1[0].title).toBe("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("reads the project once for both surfaces", async () => {
|
||||
// Two panels are two `useNotes`, but the in-flight flag is per project, so
|
||||
// mounting the dock over an open Notes tab does not re-read the file.
|
||||
const listNotes = vi.spyOn(
|
||||
await import("../../lib/tauri-commands"),
|
||||
"listNotes",
|
||||
);
|
||||
renderBothSurfaces();
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
|
||||
);
|
||||
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||
listNotes.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps text the user is part-way through typing when the other surface saves", async () => {
|
||||
// Showing a remote edit must never mean discarding an unsaved local one.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "half-typed" } });
|
||||
|
||||
const dockBody = dock().getByLabelText("Note body");
|
||||
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
|
||||
fireEvent.blur(dockBody);
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
|
||||
|
||||
expect(tabBody).toHaveValue("half-typed");
|
||||
});
|
||||
|
||||
it("falls back to another note when the selected one is deleted", async () => {
|
||||
// The claim a differently-named test in NotesPanel.test.tsx used to make
|
||||
// and could not keep: `useNotes` is mocked there and its list never
|
||||
// changes, so the fallback was invisible. Here the list is real.
|
||||
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
const { tab } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
|
||||
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
|
||||
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
|
||||
expect(files.p1).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a note created in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
// The dock keeps New behind its overflow menu — its height belongs to the
|
||||
// note being written, not to a button row.
|
||||
fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
|
||||
);
|
||||
expect(files.p1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: ({ body }: { body: string }) => (
|
||||
<button type="button" data-testid="send">{`send:${body}`}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesPanel", () => {
|
||||
it("invites the user to start when there are no notes", () => {
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists notes by title and selects the first", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
||||
});
|
||||
|
||||
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
||||
notes = [note({ title: "" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the editor when another note is selected", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
||||
});
|
||||
|
||||
it("saves on blur, not on every keystroke", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "edited" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "edited" }),
|
||||
));
|
||||
});
|
||||
|
||||
it("does not save on blur when nothing changed", async () => {
|
||||
// Clicking through notes to read them must not write the file.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.blur(screen.getByLabelText("Note body"));
|
||||
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands the live editor text to the send button, not the last saved copy", () => {
|
||||
// Sending what is on screen is the whole contract: no transform on the way
|
||||
// out except the newline substitution.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
||||
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||
});
|
||||
|
||||
it("asks the hook to delete the selected note", async () => {
|
||||
// Only the call: `useNotes` is mocked here and the mocked list never
|
||||
// changes, so nothing in this file can exercise what the panel selects
|
||||
// afterwards. The fallback is covered against the real hook in
|
||||
// NotesPanel.shared.test.tsx.
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteEditor from "./NoteEditor";
|
||||
import Button from "../ui/Button";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const UNTITLED = "Untitled note";
|
||||
|
||||
/**
|
||||
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||
* two cannot drift into different behaviour.
|
||||
*
|
||||
* Master/detail: titles beside the editor when there is room, stacked above it
|
||||
* when there is not. That is a **container** query, not a viewport one, because
|
||||
* the two surfaces differ in width while sharing a viewport — the dock opens at
|
||||
* 352px and the tab is the width of the main area. A `md:` breakpoint would
|
||||
* read the window and give both the same answer, which is the wrong answer for
|
||||
* one of them.
|
||||
*
|
||||
* The threshold is arithmetic, not taste: side by side needs the 192px list,
|
||||
* plus an editor wide enough for its own action row (~280px), plus the divider.
|
||||
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
|
||||
* first stop that clears it.
|
||||
*
|
||||
* The editor holds draft text locally and commits on blur, which is how every
|
||||
* other editable field in the app behaves (`ClaudeInstructionsEditor`, the
|
||||
* Config tab).
|
||||
*/
|
||||
export default function NotesPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
<SaveIndicator state={saveState} />
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
No notes yet. Keep reminders here, and send any of them straight to a
|
||||
running Claude session.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col @lg:flex-row">
|
||||
{/* Stacked: a capped strip of titles above the editor, so the note
|
||||
being written keeps most of the height. Side by side: a full-height
|
||||
column of the fixed width the editor's arithmetic assumes. */}
|
||||
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||
selected?.id === n.id
|
||||
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex-1 min-w-0">
|
||||
{selected && (
|
||||
<NoteEditor
|
||||
projectId={projectId}
|
||||
title={title}
|
||||
body={body}
|
||||
onTitleChange={setTitle}
|
||||
onBodyChange={setBody}
|
||||
onCommit={commit}
|
||||
onDelete={() => void deleteNote(selected.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
const sendInput = vi.fn(async () => {});
|
||||
let sessions: TerminalSession[] = [];
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({ sessions, sendInput }),
|
||||
}));
|
||||
|
||||
const setActiveTabKey = vi.fn();
|
||||
const requestTerminalFocus = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
let projects: Project[] = [];
|
||||
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) =>
|
||||
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||
{
|
||||
getState: () => ({
|
||||
projects,
|
||||
setActiveTabKey,
|
||||
requestTerminalFocus,
|
||||
pushToast,
|
||||
}),
|
||||
},
|
||||
),
|
||||
terminalTabKey: (id: string) => `term:${id}`,
|
||||
}));
|
||||
|
||||
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessions = [];
|
||||
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
||||
});
|
||||
|
||||
describe("SendToAgentButton", () => {
|
||||
// Unavailable, not `disabled`: the reason a note cannot be sent is the whole
|
||||
// content of these states, and native `disabled` announces it to nobody.
|
||||
it("says why it cannot send when the project has no running session", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(
|
||||
"No running Claude session for this project",
|
||||
);
|
||||
});
|
||||
|
||||
it("says why it cannot send an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAccessibleDescription("Nothing to send — this note is empty");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session belongs to another project", () => {
|
||||
sessions = [session({ projectId: "other" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session is a bash tab", () => {
|
||||
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
||||
// shell is never a target.
|
||||
sessions = [session({ sessionType: "bash" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
|
||||
// guard this swap would turn a greyed-out button into a live one.
|
||||
it("sends nothing when activated while unavailable", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
|
||||
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
|
||||
});
|
||||
|
||||
it("focuses the terminal it sent to, so the user watches it land", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
|
||||
});
|
||||
|
||||
it("offers a menu of display names when several sessions are open", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
projects = [
|
||||
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
|
||||
];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
|
||||
});
|
||||
|
||||
it("reports a failed send rather than looking like it worked", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("session closed"));
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does nothing for an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the session menu upward when it sits at the foot of the dock", async () => {
|
||||
sessions = [session({ id: "s1" }), session({ id: "s2" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
// Anchored to the button's top edge, not below it: the dock clips its own
|
||||
// overflow, so a downward menu at the bottom edge is invisible.
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||
});
|
||||
|
||||
// Switching to the tab is not enough. When the dock is open beside the
|
||||
// terminal it sends to, that terminal is already the active tab, so
|
||||
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||
// this button, one click short of the Enter the user came to press.
|
||||
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||
});
|
||||
|
||||
it("leaves focus alone when the send failed", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("focuses the session picked from the menu, not the first one", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState, terminalTabKey } from "../../store/appState";
|
||||
import { toClaudePayload } from "../../lib/claudeInput";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
body: string;
|
||||
/**
|
||||
* Open the session menu above the button instead of below. The dock puts
|
||||
* this at its foot, and the dock clips its own overflow, so a downward menu
|
||||
* there is drawn outside the panel and never seen.
|
||||
*/
|
||||
dropUp?: boolean;
|
||||
/** Fill the row. The dock's send bar is the width of the dock. */
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a note into a running Claude session's prompt.
|
||||
*
|
||||
* Three behaviours by target count: none disables the button, one sends
|
||||
* straight there, several ask which. It never guesses — the note goes to a
|
||||
* session the user named, or to the only one there is.
|
||||
*
|
||||
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
|
||||
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
|
||||
*/
|
||||
export default function SendToAgentButton({
|
||||
projectId,
|
||||
body,
|
||||
dropUp = false,
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const { sessions, sendInput } = useTerminal();
|
||||
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
|
||||
useAppState(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
requestTerminalFocus: s.requestTerminalFocus,
|
||||
pushToast: s.pushToast,
|
||||
})),
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
sessions.filter(
|
||||
(s) => s.projectId === projectId && s.sessionType === "claude",
|
||||
),
|
||||
[sessions, projectId],
|
||||
);
|
||||
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const hasBody = body.trim().length > 0;
|
||||
const unavailable = targets.length === 0 || !hasBody;
|
||||
|
||||
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const send = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setMenuOpen(false);
|
||||
try {
|
||||
// No trailing CR: the note lands in the prompt and the user presses
|
||||
// Enter. Newlines become ESC+CR so it arrives as one message rather
|
||||
// than one prompt per line.
|
||||
await sendInput(sessionId, toClaudePayload(body));
|
||||
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||
// text still went.
|
||||
setActiveTabKey(terminalTabKey(sessionId));
|
||||
// Switching tabs is not the same as taking focus, and when the dock is
|
||||
// open beside the terminal it just sent to, that tab is already the
|
||||
// active one — so nothing above moves the caret off this button. The
|
||||
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||
requestTerminalFocus(sessionId);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not send the note to the agent",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
// The target is resolved at click time and pinned for the whole send, the
|
||||
// hazard `useSTT` guards against by capturing its session at record start:
|
||||
// the list can change while the request is in flight.
|
||||
if (targets.length === 1) {
|
||||
void send(targets[0].id);
|
||||
return;
|
||||
}
|
||||
setMenuOpen((open) => !open);
|
||||
}, [targets, send]);
|
||||
|
||||
const title = !hasBody
|
||||
? "Nothing to send — this note is empty"
|
||||
: targets.length === 0
|
||||
? "No running Claude session for this project"
|
||||
: "Put this note into the agent's prompt (you press Enter)";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={fullWidth ? "md" : "sm"}
|
||||
className={fullWidth ? "w-full" : ""}
|
||||
// Not `disabled`: every one of these reasons is information, and
|
||||
// `disabled` takes the button — reason and all — out of the
|
||||
// accessibility tree. `Button` guards the click for us.
|
||||
unavailable={unavailable}
|
||||
unavailableReason={title}
|
||||
onClick={onClick}
|
||||
aria-haspopup={targets.length > 1 ? "menu" : undefined}
|
||||
aria-expanded={targets.length > 1 ? menuOpen : undefined}
|
||||
title={title}
|
||||
>
|
||||
Send to agent
|
||||
</Button>
|
||||
{menuOpen && targets.length > 1 && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
|
||||
dropUp ? "bottom-full mb-1" : "mt-1"
|
||||
}`}
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{targets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => void send(s.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
{sessionDisplayName(s, project)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Draft text for the note being edited, committed when a field loses focus.
|
||||
*
|
||||
* This is the half the dock and the tab must never disagree on, so it lives
|
||||
* here rather than in either layout. The two surfaces differ in how they show
|
||||
* notes; they must not differ in when a keystroke becomes a save.
|
||||
*
|
||||
* The draft is "untouched" exactly while it still matches what was last copied
|
||||
* out of the store, which is what lets an edit made on the *other* surface
|
||||
* reach this one's editor without ever discarding half-typed text.
|
||||
*/
|
||||
export function useNoteDraft(
|
||||
selected: Note | null,
|
||||
saveNote: (note: Note) => Promise<unknown>,
|
||||
) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||
id: null,
|
||||
title: "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
// Re-seed on a change of note, and on a change to the *stored* text of the
|
||||
// note already open — the second case is the dock and the tab showing one
|
||||
// project at once.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
seeded.current = { id: null, title: "", body: "" };
|
||||
setTitle("");
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
const untouched =
|
||||
title === seeded.current.title && body === seeded.current.body;
|
||||
if (seeded.current.id !== selected.id || untouched) {
|
||||
seeded.current = {
|
||||
id: selected.id,
|
||||
title: selected.title,
|
||||
body: selected.body,
|
||||
};
|
||||
setTitle(selected.title);
|
||||
setBody(selected.body);
|
||||
}
|
||||
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const commit = () => {
|
||||
if (!selected) return;
|
||||
// Reading is not editing: clicking through notes must not rewrite the file.
|
||||
if (title === selected.title && body === selected.body) return;
|
||||
// Mark the draft as matching what was just committed, so the store update
|
||||
// this save produces reads as "no change" rather than as a stale re-seed.
|
||||
seeded.current = { id: selected.id, title, body };
|
||||
void saveNote({ ...selected, title, body });
|
||||
};
|
||||
|
||||
return { title, body, setTitle, setBody, commit };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
|
||||
const add = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({ add }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||
function deferred() {
|
||||
let resolve!: (v: unknown) => void;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fillValidForm() {
|
||||
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||
target: { value: "my-project" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||
target: { value: "/home/user/my-project" },
|
||||
});
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||
}
|
||||
|
||||
describe("AddProjectDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("adds the project with the name and folder entered", async () => {
|
||||
add.mockResolvedValue({ id: "p1" });
|
||||
const onClose = vi.fn();
|
||||
render(<AddProjectDialog onClose={onClose} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith("my-project", [
|
||||
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||
]),
|
||||
);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
// Native `disabled` would remove the button from the accessibility tree
|
||||
// exactly when it has something to say.
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
expect(submitButton()).not.toBeDisabled();
|
||||
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
fireEvent.click(submitButton());
|
||||
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||
fireEvent.keyDown(submitButton(), { key: " " });
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
// Enter in a text field submits a form regardless of the submit button's
|
||||
// state, so the handler has to guard itself too.
|
||||
// Modal portals to document.body, so the form is not under `container`.
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
fireEvent.submit(form!);
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("leaves the submit button plainly available when idle", () => {
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||
expect(submitButton()).toHaveAccessibleDescription("");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||
// is in flight, and Enter inside a text field submits the form without
|
||||
// touching the button at all. Both routes end here, so the guard does too.
|
||||
if (loading) return;
|
||||
if (!name.trim()) {
|
||||
setError("Project name is required");
|
||||
return;
|
||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form={formId}
|
||||
unavailable={loading}
|
||||
unavailableReason="The project is being added. Wait for it to finish."
|
||||
title={
|
||||
loading
|
||||
? "The project is being added. Wait for it to finish."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? "Adding…" : "Add Project"}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
|
||||
});
|
||||
|
||||
it("offers every effort level Claude Code accepts", () => {
|
||||
// Verified against the shipped `claude` binary's own schema rather than
|
||||
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||
// checked externally — which is the whole weakness of this test. It can
|
||||
// only prove the editor agrees with this list, never that the list is the
|
||||
// one Claude Code reads. The same blind spot is why `effort` and
|
||||
// `focusMode` were confidently wrong for months.
|
||||
renderEditor(null);
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||
).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
||||
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
});
|
||||
|
||||
describe("project scope", () => {
|
||||
@@ -193,4 +199,27 @@ describe("ClaudeCodeSettingsEditor", () => {
|
||||
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
||||
* struct skips serialising a field it has no value for, which is what keeps
|
||||
* an older binary able to parse `projects.json` after a downgrade. It is also
|
||||
* the exact shape a project stored before the fields were widened is read
|
||||
* back as — every one of its `false`s meant "unset" — so reading absent as
|
||||
* "off" would show a switch the user never touched as a deliberate choice.
|
||||
*/
|
||||
it("reads an absent field as Global rather than as Off", () => {
|
||||
renderEditor({} as ClaudeCodeSettings, "project");
|
||||
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("still collapses to null when an absent-field object is edited back", () => {
|
||||
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
||||
// Off and straight back on: the round trip has to land on `null`, or an
|
||||
// untouched global stops being indistinguishable from one never opened.
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,20 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
* overrides a global on, so a settings object holding one has to be persisted.
|
||||
*/
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
|
||||
// The Rust struct skips serialising one it has no value for, so a project
|
||||
// whose stored settings were all "unset" arrives here as `{}` — and reading
|
||||
// that as "off" is exactly the mistake the three-state control exists to
|
||||
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === null &&
|
||||
s.focus_mode === null &&
|
||||
s.show_thinking_summaries === null &&
|
||||
s.session_recap_disabled === null &&
|
||||
s.env_scrub === null &&
|
||||
s.prompt_caching_1h === null
|
||||
s.tui_mode == null &&
|
||||
s.effort == null &&
|
||||
s.auto_scroll_disabled == null &&
|
||||
s.focus_mode == null &&
|
||||
s.show_thinking_summaries == null &&
|
||||
s.session_recap_disabled == null &&
|
||||
s.env_scrub == null &&
|
||||
s.prompt_caching_1h == null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +68,15 @@ const BOOLEAN_FIELDS: {
|
||||
hint: string;
|
||||
invert?: boolean;
|
||||
}[] = [
|
||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||
{
|
||||
key: "focus_mode",
|
||||
label: "Focus mode",
|
||||
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||
// which is the complaint that started this whole round of work.
|
||||
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||
},
|
||||
{
|
||||
key: "show_thinking_summaries",
|
||||
label: "Thinking summaries",
|
||||
@@ -163,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">Extra high</option>
|
||||
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||
against the shipped claude binary's own schema, not just docs. */}
|
||||
<option value="max">Maximum</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
@@ -204,7 +220,7 @@ export default function ClaudeCodeSettingsEditor({
|
||||
// `stored` holds the deviation from Claude Code's default, so an
|
||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||
const selected =
|
||||
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
|
||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
||||
});
|
||||
|
||||
it("only allows opening a terminal while the container runs", () => {
|
||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
// Native `disabled` would drop the button out of the accessibility tree
|
||||
// and out of the tab order, taking the reason with it.
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops aria-disabled once the container is running", () => {
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("shows container progress inline rather than in a blocking modal", () => {
|
||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
||||
import { useAppState, homeTabKey } from "../../store/appState";
|
||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { useUnavailable } from "../ui/unavailable";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
|
||||
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||
// attribute that hides the button — and the reason — from anyone not using a
|
||||
// mouse and eyes.
|
||||
const terminal = useUnavailable({
|
||||
unavailable: !isRunning,
|
||||
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||
onClick: () => openClaudeTerminal(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => openClaudeTerminal()}
|
||||
title={`Open a Claude terminal for ${project.name}`}
|
||||
{...terminal.controlProps}
|
||||
title={
|
||||
isRunning
|
||||
? `Open a Claude terminal for ${project.name}`
|
||||
: `${project.name} is not running. Start it to open a terminal.`
|
||||
}
|
||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
||||
<line x1="13" y1="15" x2="17" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
{terminal.reasonNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,14 +16,12 @@ interface Props {
|
||||
projectId: string;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
/** "Save to host…" — the way out for anything the viewer can't render. */
|
||||
onSaveToHost: (entry: FileEntry) => void;
|
||||
}
|
||||
|
||||
type Preview =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** Too big to render whole — offered as a download rather than a half-file. */
|
||||
/** Too big to render whole — said so rather than shown as a half-file. */
|
||||
| { kind: "too-large" }
|
||||
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
||||
| { kind: "image"; url: string }
|
||||
@@ -37,7 +35,7 @@ type Preview =
|
||||
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
|
||||
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
|
||||
*/
|
||||
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
|
||||
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
||||
|
||||
/**
|
||||
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onSaveToHost(entry);
|
||||
}}
|
||||
>
|
||||
Save to host…
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -156,14 +144,16 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Save it
|
||||
to the host to open it there.
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Save it to the host to open it there.
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const downloadContainerFile = vi.fn(async () => {});
|
||||
const uploadFileToContainer = vi.fn(async () => {});
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -31,29 +31,6 @@ const toastText = () =>
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const save = vi.fn(async () => "/host/out");
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: (o: unknown) => save(o),
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
/** The webview's window-wide native drag-drop listener, captured for driving. */
|
||||
type DragPayload =
|
||||
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
|
||||
| { type: "leave" }
|
||||
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
|
||||
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
|
||||
const unlistenDrag = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
|
||||
dragHandler = cb;
|
||||
return unlistenDrag;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||
|
||||
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
@@ -82,39 +59,17 @@ async function renderTab() {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
|
||||
async function drop(paths: string[], position = { x: 100, y: 100 }) {
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "drop", position, paths } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||
|
||||
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
|
||||
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
||||
let pending: unknown;
|
||||
act(() => {
|
||||
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
|
||||
});
|
||||
return pending as Promise<void> | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dragHandler = null;
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
]);
|
||||
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
@@ -224,7 +179,15 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
@@ -239,7 +202,7 @@ describe("FilesTab viewer", () => {
|
||||
expect(screen.getByText("first megabyte")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers Save to host for a file it cannot render", async () => {
|
||||
it("says there is no preview, and where to open the file instead", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||
await renderTab();
|
||||
@@ -314,191 +277,6 @@ describe("FilesTab new folder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab host drag-and-drop", () => {
|
||||
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
await drop(["/host/a.png", "/host/b.png"]);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
|
||||
it("drops into the directory the user has navigated to", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
await drop(["/host/a.png"]);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
|
||||
});
|
||||
|
||||
it("ignores a drop outside the pane — the listener is window-wide", async () => {
|
||||
// This is the whole routing discipline: the terminal's listener is live at
|
||||
// the same time, and only the hit-test keeps them apart.
|
||||
await renderTab();
|
||||
await drop(["/host/a.png"], { x: 5000, y: 5000 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("divides the payload position by devicePixelRatio on Windows only", async () => {
|
||||
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
|
||||
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
|
||||
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
|
||||
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
|
||||
// is (900, 900) and belongs to nobody.
|
||||
const originalDpr = window.devicePixelRatio;
|
||||
const originalUa = window.navigator.userAgent;
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
configurable: true,
|
||||
});
|
||||
await renderTab();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(uploadFileToContainer).mockClear();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
|
||||
// user was losing.
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
value: originalDpr,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: originalUa,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a drop that lands on a toast floating over the pane", async () => {
|
||||
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
|
||||
// and its error cards stay until dismissed — so a z-order gate asking "is
|
||||
// what is painted here part of my pane?" made the bottom-right corner of
|
||||
// this pane refuse drops for as long as one error was on screen. jsdom has
|
||||
// no `elementFromPoint`, so that branch only ran when a test supplied one;
|
||||
// the gate no longer asks, and this pins that nothing painted over a pane
|
||||
// can refuse a drop on its own account.
|
||||
await renderTab();
|
||||
const toastCard = document.createElement("div");
|
||||
document.body.appendChild(toastCard);
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => toastCard,
|
||||
});
|
||||
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
});
|
||||
|
||||
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
|
||||
// Round 2, which is the reason this file exists in its current shape. The
|
||||
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
|
||||
// is `z-50` in the same stacking context, so the *toast* becomes the
|
||||
// topmost element over a covered pane. A gate that asked `elementFromPoint`
|
||||
// "is a blocker painted here?" then answered no and uploaded into the
|
||||
// directory the dialog was covering — one refused drop was all it took to
|
||||
// open the hole. Both stubs below therefore have to be refused.
|
||||
await renderTab();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(backdrop);
|
||||
const toastCard = document.createElement("div"); // z-[60], above the backdrop
|
||||
document.body.appendChild(toastCard);
|
||||
const stub = (top: Element) =>
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => top,
|
||||
});
|
||||
|
||||
stub(backdrop);
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
stub(toastCard);
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
backdrop.remove();
|
||||
});
|
||||
|
||||
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
await dragHandler?.({
|
||||
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
|
||||
});
|
||||
});
|
||||
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "leave" } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab save to host", () => {
|
||||
it("copies a file out to the path the user picks", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||
});
|
||||
|
||||
it("does not offer a directory download, which cannot work", async () => {
|
||||
await renderTab();
|
||||
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drop hit test", () => {
|
||||
it("uploads nothing when a dialog is covering the pane", async () => {
|
||||
// The pane still has its rect underneath the viewer's `fixed inset-0`
|
||||
// portal, which is exactly why a rect alone was the wrong test.
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not paint the hint under a dialog either", async () => {
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid focus", () => {
|
||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||
@@ -592,22 +370,28 @@ describe("FilesTab grid semantics", () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain("Rename");
|
||||
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
|
||||
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
|
||||
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// A `role="status"` node inserted already carrying its text is frequently
|
||||
// not announced at all, which is how every one of these went by in silence.
|
||||
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
expect(live.textContent).toBe("");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
});
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "new" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
// Same node throughout — it is never unmounted.
|
||||
expect(screen.getByRole("status")).toBe(live);
|
||||
expect(live.textContent).toContain("Uploaded 1 item");
|
||||
expect(live.textContent).toContain('Created "new"');
|
||||
});
|
||||
|
||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||
@@ -619,129 +403,72 @@ describe("FilesTab grid semantics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab overwrite prompt", () => {
|
||||
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog.textContent).toContain("notes.txt");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
|
||||
"p1",
|
||||
"/host/notes.txt",
|
||||
"/workspace",
|
||||
true,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads nothing more on Skip", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the blanket answers only when files are queued behind this one", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
|
||||
await pending;
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
|
||||
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
|
||||
* the destructive answer has to be chosen, and because a dialog that is closed
|
||||
* rather than answered must not leave the batch waiting forever or throw away
|
||||
* the files behind it.
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab overwrite prompt dismissal", () => {
|
||||
/**
|
||||
* Drop two files where the first name is taken, and stop at the dialog. The
|
||||
* unsettled batch comes back wrapped — returning it bare from an `async`
|
||||
* helper would adopt it, and awaiting the helper would then wait for an
|
||||
* upload that cannot proceed until the helper has returned.
|
||||
*/
|
||||
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
return { batch };
|
||||
}
|
||||
|
||||
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
|
||||
function expectSkippedAndCarriedOn() {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
|
||||
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
|
||||
expect(screen.getByRole("status").textContent).toContain("skipped 1");
|
||||
}
|
||||
|
||||
it("counts Escape as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await batch;
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("counts the ✕ as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
await batch;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("counts a click on the backdrop as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
// The overlay is the dialog panel's parent — `Modal` only closes when the
|
||||
// click landed on the overlay itself, not on anything inside the panel.
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.click(overlay);
|
||||
await batch;
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("does not dismiss on a click inside the dialog", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await batch;
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +13,28 @@ interface Props {
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
/**
|
||||
* The project's file manager.
|
||||
* The project's file browser.
|
||||
*
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -42,18 +59,16 @@ export default function FilesTab({ project }: Props) {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
busy,
|
||||
completed,
|
||||
conflict,
|
||||
resolveConflict,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPaths,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
@@ -65,8 +80,6 @@ export default function FilesTab({ project }: Props) {
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** A host drag is currently over this pane. */
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
@@ -234,58 +247,6 @@ export default function FilesTab({ project }: Props) {
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
// Host → container drag and drop.
|
||||
//
|
||||
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
||||
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
||||
// native payload carries real file *paths*. The listener is window-wide, so
|
||||
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
|
||||
// is, plus the document-wide question a rect cannot answer: is a modal or a
|
||||
// blocking overlay on screen at all? That second half is deliberately not a
|
||||
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
|
||||
// wrong.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setDragOver(false);
|
||||
return;
|
||||
}
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
setDragOver(isDropTarget(paneRef.current, payload.position));
|
||||
return;
|
||||
}
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
||||
// the file just never appears in the listing.
|
||||
if (verdict === "blocked") {
|
||||
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
|
||||
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
const paths = payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
await uploadPaths(paths);
|
||||
});
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [running, uploadPaths]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
@@ -324,10 +285,10 @@ export default function FilesTab({ project }: Props) {
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
||||
* every completion notice used to go by in silence.
|
||||
* frequently not announced at all, which is how every completion notice used
|
||||
* to go by in silence.
|
||||
*/
|
||||
const liveText = busy ? busy : (completed ?? "");
|
||||
const liveText = completed ?? "";
|
||||
|
||||
return (
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
@@ -361,8 +322,15 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
<Button onClick={uploadFile} className="ml-1">
|
||||
Upload file
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
@@ -372,9 +340,9 @@ export default function FilesTab({ project }: Props) {
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — upload, rename, mkdir,
|
||||
save-to-host — goes to `ToastHost` instead, which is above
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
behind. Every *transient* failure — rename, new folder — goes to
|
||||
`ToastHost` instead, which is above the file viewer's overlay and
|
||||
does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -560,16 +528,32 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host… — ${entry.name}`}
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPaths.has(entry.path)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Save to host…
|
||||
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -594,34 +578,11 @@ export default function FilesTab({ project }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop hint. Purely decorative — the native listener is what accepts the
|
||||
drop, so this must never intercept pointer events. */}
|
||||
{dragOver && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Drop files into {currentPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<OverwriteConfirmModal
|
||||
name={conflict.name}
|
||||
directory={conflict.directory}
|
||||
remaining={conflict.remaining}
|
||||
onChoose={resolveConflict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
entry={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
onSaveToHost={downloadFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Project } from "../../../lib/types";
|
||||
import NotesPanel from "../../notes/NotesPanel";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes as a Project Home sub-tab.
|
||||
*
|
||||
* The same panel the dock shows. This is the roomy view for writing; the dock
|
||||
* is the one that stays visible while the agent works.
|
||||
*/
|
||||
export default function NotesTab({ project }: Props) {
|
||||
return (
|
||||
<div className="h-full min-h-0">
|
||||
<NotesPanel projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { OverwriteChoice } from "../../../lib/uploadErrors";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
|
||||
interface Props {
|
||||
/** Bare name of the file that is already there. */
|
||||
name: string;
|
||||
/** Container directory it is going into. */
|
||||
directory: string;
|
||||
/** How many more files are queued behind this one. */
|
||||
remaining: number;
|
||||
onChoose: (choice: OverwriteChoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That name is taken — replace it?"
|
||||
*
|
||||
* This exists because the backend stopped overwriting silently, and a raw
|
||||
* error string would have been a worse answer than the old silent clobber: it
|
||||
* tells the user their drop failed without telling them it *can* succeed. The
|
||||
* dialog names the file and the directory, because a drop is aimed with a
|
||||
* mouse and "notes.txt" alone does not say which `notes.txt`.
|
||||
*
|
||||
* The blanket answers only appear when there is something to apply them to — a
|
||||
* single-file drop with "Replace all" on it invites the reflex of clicking the
|
||||
* widest button for no benefit.
|
||||
*
|
||||
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
|
||||
* destructive answer has to be chosen explicitly.
|
||||
*/
|
||||
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
|
||||
const footer = (
|
||||
<>
|
||||
{remaining > 0 && (
|
||||
<>
|
||||
<Button size="md" onClick={() => onChoose("skip-all")}>
|
||||
Skip all
|
||||
</Button>
|
||||
<Button size="md" onClick={() => onChoose("replace-all")}>
|
||||
Replace all
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="md" onClick={() => onChoose("skip")}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
|
||||
Replace
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="A file with that name is already there"
|
||||
description={directory}
|
||||
onClose={() => onChoose("skip")}
|
||||
footer={footer}
|
||||
widthClassName="w-[30rem]"
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">{name}</span> already exists in{" "}
|
||||
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
|
||||
copy, and that cannot be undone from here.
|
||||
</p>
|
||||
{remaining > 0 && (
|
||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">
|
||||
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -17,7 +18,9 @@ import AutomationTab from "./AutomationTab";
|
||||
import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import NotesTab from "./NotesTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -26,6 +29,7 @@ const TABS = [
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "files", label: "Files" },
|
||||
{ id: "browser", label: "Browser" },
|
||||
{ id: "notes", label: "Notes" },
|
||||
] as const;
|
||||
|
||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||
@@ -253,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
{tab === "browser" && (
|
||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||
)}
|
||||
{tab === "notes" && <NotesTab project={project} />}
|
||||
</div>
|
||||
|
||||
{showMigration && (
|
||||
@@ -282,7 +287,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
const report = await remove(project.id);
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
const verb = leftoverVerb(report);
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
|
||||
@@ -109,7 +109,16 @@ export default function RuntimeSection({
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
|
||||
description={
|
||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||
"also needs the base image updated first: those four are cleared by removing a " +
|
||||
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||
}
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope="project"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import WorkspaceSection from "./WorkspaceSection";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
|
||||
// The Browse button is the OS folder picker.
|
||||
const open = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: (...args: unknown[]) => open(...args),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<WorkspaceSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every folder list this component has sent to `update_project`. */
|
||||
function savedLists() {
|
||||
return save.mock.calls
|
||||
.filter(([patch]) => "paths" in patch)
|
||||
.map(([patch]) => patch.paths);
|
||||
}
|
||||
|
||||
describe("WorkspaceSection — the blank row is never stored", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* The bug this file exists for. `create_container` mounts every stored row
|
||||
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
|
||||
* container with `field Source must not be empty` — the project can never be
|
||||
* started or recreated again. Click "+ Add folder", blur a field, and it is
|
||||
* bricked.
|
||||
*/
|
||||
it("drops the placeholder row when a real edit is saved", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when Browse fills a different row in", async () => {
|
||||
open.mockResolvedValueOnce("/src/api-v2");
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
// The picker is awaited inside the handler, so the state update that
|
||||
// follows it lands outside the click.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
|
||||
});
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when a row is removed", () => {
|
||||
renderSection({
|
||||
paths: [
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("never sends a row with an empty host path, whatever the route", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
for (const list of savedLists()) {
|
||||
for (const row of list) {
|
||||
expect(row.host_path).not.toBe("");
|
||||
expect(row.mount_name).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceSection — what a blur is allowed to save", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty — which `update_project` refuses,
|
||||
* turning an ordinary keystroke into an error toast.
|
||||
*/
|
||||
it("holds a half-filled row back until it is complete", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const newHostPath = screen.getByLabelText("Folder 2 host path");
|
||||
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
|
||||
fireEvent.blur(newHostPath);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
const newMountName = screen.getByLabelText("Folder 2 mount name");
|
||||
fireEvent.change(newMountName, { target: { value: "web" } });
|
||||
fireEvent.blur(newMountName);
|
||||
expect(savedLists()[0]).toEqual([
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blurring out of an untouched field is not an edit. Saving anyway would
|
||||
* round-trip the filtered list through `project` and take the empty row away
|
||||
* while the user was still filling it in.
|
||||
*/
|
||||
it("saves nothing when the blur changed nothing", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still saves a rename, which does not go through the folder list", () => {
|
||||
renderSection();
|
||||
const name = screen.getByDisplayValue("api-server");
|
||||
fireEvent.change(name, { target: { value: "api-v2" } });
|
||||
fireEvent.blur(name);
|
||||
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Whether two folder lists are the same rows in the same order. */
|
||||
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
/**
|
||||
* Persist a folder list, minus the rows that are only in it because the UI
|
||||
* put them there.
|
||||
*
|
||||
* **The blank row must never reach the store.** "+ Add folder" inserts
|
||||
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
|
||||
* mounts every stored row unfiltered — a stored blank one becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
|
||||
* `field Source must not be empty`. The project then cannot be started or
|
||||
* recreated at all, from a click and a blur. `AddProjectDialog` has always
|
||||
* filtered this; this section computed the filtered list and then saved the
|
||||
* unfiltered one.
|
||||
*
|
||||
* Every save goes through here for that reason — Browse and Remove write the
|
||||
* list too, and either can be holding a blank row from an earlier click.
|
||||
*/
|
||||
const persist = (rows: ProjectPath[]) => {
|
||||
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
return save({ paths: filled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Save only when every row is fully filled in.
|
||||
*
|
||||
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
* a half-filled row is refused — so the unconditional save turned an ordinary
|
||||
* keystroke into an error toast. A blank row is *not* incomplete: the
|
||||
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
||||
*
|
||||
* A blur that changed nothing saves nothing, which is what keeps the blank
|
||||
* row on screen while it is being filled in: persisting the filtered list
|
||||
* would round-trip through `project` and take the empty row away under the
|
||||
* cursor.
|
||||
*/
|
||||
const saveIfComplete = () => {
|
||||
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
||||
if (halfFilled) return;
|
||||
return save({ paths });
|
||||
if (sameRows(filled, project.paths ?? [])) return;
|
||||
return persist(paths);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
mount_name: updated[i].mount_name || basename,
|
||||
};
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeLeftovers, leftoverVerb } from "./removalReport";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
function report(overrides: Partial<ProjectRemovalReport> = {}): ProjectRemovalReport {
|
||||
return {
|
||||
container: null,
|
||||
image: null,
|
||||
volumes: [],
|
||||
retry_scheduled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectRemovalIsClean", () => {
|
||||
it("is true only when nothing survived", () => {
|
||||
expect(projectRemovalIsClean(report())).toBe(true);
|
||||
expect(projectRemovalIsClean(report({ container: "triple-c-abc" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ image: "triple-c-snapshot-abc:latest" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ volumes: ["triple-c-home-abc"] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeLeftovers", () => {
|
||||
it("names each kind of leftover", () => {
|
||||
expect(describeLeftovers(report({ container: "triple-c-abc" }))).toBe("its container");
|
||||
expect(describeLeftovers(report({ image: "x" }))).toBe("its saved image");
|
||||
expect(describeLeftovers(report({ volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeLeftovers(report({ volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
});
|
||||
|
||||
it("joins multiple kinds together", () => {
|
||||
expect(
|
||||
describeLeftovers(report({ container: "triple-c-abc", image: "x", volumes: ["v1", "v2"] })),
|
||||
).toBe("its container, its saved image, 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leftoverVerb", () => {
|
||||
it("is singular for exactly one leftover of any kind", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ image: "x" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ volumes: ["v1"] }))).toBe("was");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived, including multiple volumes alone", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc", image: "x" }))).toBe("were");
|
||||
expect(leftoverVerb(report({ volumes: ["v1", "v2"] }))).toBe("were");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user