Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
168b61d632 | ||
|
|
47960e46df | ||
|
|
73dfaf5785 | ||
|
|
01fd38bc4b | ||
|
|
00128f9b1a | ||
|
|
39934299f9 | ||
|
|
c6086b0ab3 | ||
|
|
f7db4323be | ||
|
|
ed91423666 | ||
|
|
6a8972980d | ||
|
|
7bbb699e4e | ||
|
|
5df3e7996d | ||
|
|
5d4d5d37df | ||
|
|
bb1c7696f9 | ||
|
|
f2a84c18f9 | ||
|
|
b49dddab45 | ||
|
|
dcd2dfe5a3 | ||
|
|
6d27f924ff | ||
|
|
e70a40507c | ||
|
|
5926a52ff6 | ||
|
|
42ef1865cc | ||
|
|
4f6c012071 | ||
|
|
6b8d43414d | ||
|
|
1768240861 | ||
|
|
7e1f8df1ff | ||
|
|
a76f2c0a17 | ||
|
|
17f031a5d7 | ||
|
|
5fba7d6d35 | ||
|
|
433afa5a49 | ||
|
|
fcea506dce | ||
|
|
6abc7f27a4 | ||
|
|
2b6501d8e5 | ||
|
|
3329e07d3d | ||
|
|
092972fe92 | ||
|
|
ae3ca8cda4 | ||
|
|
d6f065a2b6 | ||
|
|
0003793abb | ||
|
|
2b9bf56f25 | ||
|
|
611f67cca7 | ||
|
|
77ef2291d7 | ||
|
|
1c834a0b08 | ||
|
|
0a022dfcf0 | ||
|
|
bb41275cea | ||
|
|
2ca86bb5d8 | ||
|
|
df6d2f1ca4 | ||
|
|
dacc1157ec | ||
|
|
dd2894cc60 | ||
|
|
22d142c70d | ||
|
|
15e05e2197 | ||
|
|
75cace7dde | ||
|
|
24590546e3 | ||
|
|
d971326e4e | ||
|
|
48d0c3249a | ||
|
|
5b96ad4823 | ||
|
|
dcb13d23ea | ||
|
|
7a8bbcbef7 | ||
|
|
3bd3caa101 | ||
|
|
5dd1ab5217 | ||
|
|
92d64cf252 | ||
|
|
ab2c75d0b2 | ||
|
|
00937745f7 | ||
|
|
01e72e4785 | ||
|
|
2b35aa8c16 | ||
|
|
65a3d4eb29 | ||
|
|
2b2d9da606 | ||
|
|
3741e0fef5 | ||
|
|
0e6566d903 | ||
|
|
84a67fcd0d | ||
|
|
f3cc1c4c17 | ||
|
|
7265f55f27 | ||
|
|
88f2e73474 | ||
|
|
fa4940dd7d | ||
|
|
9027fa9ad4 | ||
|
|
be37723c38 | ||
|
|
5f990dd28b | ||
|
|
4df59da2d8 | ||
|
|
a72406f0d8 | ||
|
|
9b2f4fe79f | ||
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b | ||
|
|
e379c58684 | ||
|
|
ab747ce53d | ||
|
|
85ea3956e8 | ||
|
|
5bd80a05bc | ||
|
|
f239fa1c82 | ||
|
|
5b18ce804f | ||
|
|
63f3c54b95 | ||
|
|
f68d9c5788 | ||
|
|
bd72781482 | ||
|
|
1207a21aae | ||
|
|
a41d93ea46 | ||
|
|
d73096c937 | ||
|
|
57b6b71772 | ||
|
|
77567ac2ae | ||
|
|
247f03b48c | ||
|
|
31c73adb13 | ||
|
|
4fdfed7955 | ||
|
|
a5bcc462a7 | ||
|
|
c3f92674b1 |
@@ -1,10 +1,78 @@
|
|||||||
name: Build App (Preview)
|
name: Build App (Preview)
|
||||||
|
|
||||||
# Builds the Tauri app for branches other than main and exposes the bundles as
|
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||||
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||||
# smoke-testing of feature branches before they merge.
|
# sync.
|
||||||
|
#
|
||||||
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||||
|
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||||
|
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||||
|
# unreachable set of bundles; it is now releases-only.
|
||||||
|
#
|
||||||
|
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
||||||
|
# touches `app/**` — so the workflow prunes its own, keeping the newest
|
||||||
|
# KEEP_PREVIEWS (see Lifecycle).
|
||||||
|
#
|
||||||
|
# ## Why not workflow artifacts
|
||||||
|
#
|
||||||
|
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||||
|
#
|
||||||
|
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||||
|
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
|
||||||
|
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
|
||||||
|
# throws before making a single request. act_runner sets that variable to this
|
||||||
|
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
|
||||||
|
# the whole Tauri build had been paid for (run #265).
|
||||||
|
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
|
||||||
|
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
|
||||||
|
# `total_count: 0` and the run page shows nothing (verified on run #267).
|
||||||
|
# A build nobody can find is not a build.
|
||||||
|
#
|
||||||
|
# So previews publish the same way every other workflow here does: curl to the
|
||||||
|
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
|
||||||
|
#
|
||||||
|
# ## Lifecycle
|
||||||
|
#
|
||||||
|
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
||||||
|
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
||||||
|
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
||||||
|
# release list, and a manual cleanup sweeps any this workflow missed.
|
||||||
|
#
|
||||||
|
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
|
||||||
|
# pushed commit accumulates faster than anyone runs it. So the last job here
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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 }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
# How many preview releases survive a run, newest first — including the one
|
||||||
|
# just published.
|
||||||
|
KEEP_PREVIEWS: "2"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# Every push to an open PR: this *is* the branch's build check — it compiles
|
||||||
|
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
||||||
|
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
|
- ".gitea/workflows/build-app-preview.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,24 +80,150 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
- name: Compute preview version
|
- name: Compute preview version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
# From the checkout, not from `gitea.sha`: on a pull_request event
|
||||||
|
# that variable can be the merge ref, which is not the commit anyone
|
||||||
|
# is testing and not something to hang a tag on.
|
||||||
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
SUFFIX="preview.${SHORT_SHA}"
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
echo "Computed preview version: ${VERSION}"
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
|
# One release, created once. The three build jobs run concurrently, so
|
||||||
|
# get-or-create in each of them would race on the same tag: whoever loses gets
|
||||||
|
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
|
||||||
|
# still reported success. Creating it in a job they all depend on removes the
|
||||||
|
# race rather than handling it.
|
||||||
|
create-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
||||||
|
tag: ${{ steps.release.outputs.TAG }}
|
||||||
|
steps:
|
||||||
|
- name: Create the preview release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
|
SHA: ${{ needs.compute-version.outputs.sha }}
|
||||||
|
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="preview-${VERSION##*.}"
|
||||||
|
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Idempotent: re-dispatching the same commit must update the existing
|
||||||
|
# release rather than fail on the duplicate tag.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200) echo "Release ${TAG} already exists, reusing" ;;
|
||||||
|
404)
|
||||||
|
echo "Creating release ${TAG}"
|
||||||
|
# prerelease: true keeps it off "latest" — this is a branch build,
|
||||||
|
# not something anyone should install by accident.
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "Failed to parse release id; response was:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -105,8 +299,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# 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
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -116,29 +336,74 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
working-directory: ./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: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.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/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
uses: actions/upload-artifact@v4
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
with:
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
path: artifacts/
|
- name: Upload Linux bundles to the preview release
|
||||||
if-no-files-found: error
|
shell: bash
|
||||||
retention-days: 14
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No Linux bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -187,8 +452,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# 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
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -198,6 +465,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app (universal)
|
- name: Build Tauri app (universal)
|
||||||
working-directory: ./app
|
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: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build --target universal-apple-darwin
|
npx tauri build --target universal-apple-darwin
|
||||||
@@ -209,17 +479,47 @@ jobs:
|
|||||||
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
uses: actions/upload-artifact@v4
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
with:
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
path: artifacts/
|
- name: Upload macOS bundles to the preview release
|
||||||
if-no-files-found: error
|
shell: bash
|
||||||
retention-days: 14
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(artifacts/*)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No macOS bundles were produced" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Replacing existing asset ${filename}"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -296,6 +596,8 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
env:
|
env:
|
||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
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: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
cargo tauri build
|
||||||
@@ -308,10 +610,78 @@ jobs:
|
|||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
# PowerShell, because this job's default shell is cmd. Same
|
||||||
uses: actions/upload-artifact@v4
|
# delete-then-upload shape as the other two.
|
||||||
with:
|
- name: Upload Windows bundles to the preview release
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
shell: powershell
|
||||||
path: artifacts/
|
env:
|
||||||
if-no-files-found: error
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
retention-days: 14
|
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
|
$files = @(Get-ChildItem -File -Path artifacts\*)
|
||||||
|
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
||||||
|
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Replacing existing asset $name"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the preview list short. Runs after the builds and only if all three
|
||||||
|
# succeeded: a half-published run must not be what evicts a good older build.
|
||||||
|
prune-previews:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [create-release, build-linux, build-macos, build-windows]
|
||||||
|
steps:
|
||||||
|
- name: Delete all but the newest preview releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
||||||
|
|
||||||
|
# Newest first by creation time, `preview-` only, and never the one
|
||||||
|
# this run just published — a clock skew must not delete it.
|
||||||
|
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
||||||
|
previews = [r for r in json.load(open("releases.json"))
|
||||||
|
if r["tag_name"].startswith("preview-")]
|
||||||
|
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
|
for r in previews[keep:]:
|
||||||
|
if r["tag_name"] != keep_tag:
|
||||||
|
print(r["id"], r["tag_name"])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "${DOOMED}" ]; then
|
||||||
|
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${DOOMED}" | while read -r ID TAG; do
|
||||||
|
[ -z "${ID}" ] && continue
|
||||||
|
echo "Deleting ${TAG} (id ${ID})"
|
||||||
|
# Best effort: a preview someone deleted by hand mid-run is not a
|
||||||
|
# reason to fail a build that otherwise succeeded.
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
||||||
|
done
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ on:
|
|||||||
- "app/**"
|
- "app/**"
|
||||||
- "VERSION"
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "app/**"
|
|
||||||
- "VERSION"
|
|
||||||
- ".gitea/workflows/build-app.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||||
|
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||||
|
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||||
|
# the same three and publishes them. Six OS builds per push, one set of which
|
||||||
|
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
@@ -39,16 +39,55 @@ jobs:
|
|||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||||
|
|
||||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
# The patch number is **one past the highest patch already used**, and
|
||||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
# never a distance.
|
||||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
#
|
||||||
|
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
||||||
|
# not a counter at all: it measures how far HEAD has drifted from
|
||||||
|
# whichever tag sorts highest, and that resets to zero every time a
|
||||||
|
# tag is cut. The published history is the proof — each of these is
|
||||||
|
# exactly what the old formula returned at the time:
|
||||||
|
#
|
||||||
|
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
||||||
|
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
||||||
|
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
||||||
|
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
||||||
|
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
||||||
|
#
|
||||||
|
# Reusing a version is worse than failing to publish one: the macOS
|
||||||
|
# and Windows steps replace assets in place, so a duplicate silently
|
||||||
|
# rewrote a release that had been public for three days. Monotonic
|
||||||
|
# numbering is what stops that at the source.
|
||||||
|
#
|
||||||
|
# Suffixed tags count too. `create-tag` is skipped when any platform
|
||||||
|
# job fails, so a run can publish v0.4.7-mac and never create the
|
||||||
|
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
||||||
|
# same number out twice.
|
||||||
|
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)
|
||||||
|
|
||||||
if [ -n "$LATEST_TAG" ]; then
|
# A re-run of a commit that already released must not mint a new
|
||||||
echo "Latest matching tag: ${LATEST_TAG}"
|
# version just because its own tag now exists.
|
||||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
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} — reusing it"
|
||||||
|
PATCH="${EXISTING}"
|
||||||
|
elif [ -n "$HIGHEST" ]; then
|
||||||
|
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||||
|
PATCH=$((HIGHEST + 1))
|
||||||
else
|
else
|
||||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||||
PATCH=$(git rev-list --count HEAD)
|
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||||
|
# old fallback here counted every commit in the repository, which
|
||||||
|
# would have made the first 0.4 build 0.4.234.
|
||||||
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
|
PATCH=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||||
@@ -133,8 +172,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# 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
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -146,42 +211,126 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
# The versioned AppImage only. The update channel's copy lives in
|
||||||
|
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||||
|
# it up and publish an 80 MB duplicate under a second name.
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.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/
|
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
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||||
# Create release
|
|
||||||
curl -s -X POST \
|
# Idempotent get-or-create, matching build-macos. This step used to
|
||||||
|
# POST /releases unconditionally: against a tag that already existed
|
||||||
|
# Gitea answered 409, the grep below found no id, and the run died
|
||||||
|
# with a bare "exitcode '1'" and not one line of output explaining
|
||||||
|
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
||||||
|
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
||||||
|
# pipefail so a failure cannot be stepped over.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200)
|
||||||
|
echo "Release ${TAG} already exists, reusing"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "Creating release ${TAG}"
|
||||||
|
curl -fsS -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
# Upload each artifact
|
|
||||||
|
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
||||||
|
# Versions are monotonic now (see compute-version), so this can only
|
||||||
|
# ever be replacing an asset from a failed run of this same commit —
|
||||||
|
# never one belonging to an already-published version.
|
||||||
for file in artifacts/*; do
|
for file in artifacts/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST \
|
curl -fsS --http1.1 \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||||
|
--max-time 600 \
|
||||||
|
-X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file}" \
|
--data-binary "@${file}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
done
|
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:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version]
|
||||||
@@ -237,8 +386,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# 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
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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
|
||||||
@@ -3,3 +3,13 @@ app/dist/
|
|||||||
app/src-tauri/target/
|
app/src-tauri/target/
|
||||||
Screenshot*.png
|
Screenshot*.png
|
||||||
code-review.md
|
code-review.md
|
||||||
|
|
||||||
|
# Windows NTFS alternate-data-stream artifacts, created when files arrive
|
||||||
|
# through the WSL/host bind mount.
|
||||||
|
*:Zone.Identifier
|
||||||
|
|
||||||
|
# Local bug-report screenshots, same spirit as Screenshot*.png above.
|
||||||
|
screenshot_for_fix/
|
||||||
|
|
||||||
|
# Package files pulled in by ad-hoc verification runs.
|
||||||
|
*.deb
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
||||||
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
||||||
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
||||||
|
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||||
|
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||||
|
`moveTab` deliberately does not activate what it moves.
|
||||||
|
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
||||||
|
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
||||||
|
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
|
||||||
|
default handler types into any text field the drag is released over.
|
||||||
|
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
|
||||||
|
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
||||||
|
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
||||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
@@ -68,7 +79,62 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
modals — see "UI conventions" below.
|
modals — see "UI conventions" below.
|
||||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
- **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
|
||||||
|
directory is a host-temp disk leak with a gesture attached unless its exit-clear and
|
||||||
|
startup-reap come back with it.
|
||||||
|
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth.
|
||||||
|
There is deliberately **no Disk panel** here. The disk survey and its reclaim / destroy /
|
||||||
|
compaction surface were held back for separate hardening and live on `hold/disk-and-dragout`;
|
||||||
|
one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it
|
||||||
|
returns whole, `generate_handler!` entries and typed confirmations included. The *prevention*
|
||||||
|
half stayed and is not disk-panel code: the pre-commit scrub in `docker/container.rs`, capped
|
||||||
|
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
|
||||||
|
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
|
||||||
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||||
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||||
@@ -84,8 +150,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
Use `--text-disabled` rather than `disabled:opacity-50`.
|
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||||
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
||||||
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
||||||
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump.
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
|
||||||
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
|
`Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
|
||||||
|
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
|
||||||
|
why tab-moving takes Shift.
|
||||||
|
|
||||||
### Backend Structure (`app/src-tauri/src/`)
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
@@ -104,6 +172,61 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
OAuth listener, wrong for remote control of a browser. Host ports are confined to
|
||||||
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
|
||||||
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
|
||||||
|
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
|
||||||
|
can be watched on another monitor or pinned on top while the main window is used for work.
|
||||||
|
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
|
||||||
|
not give it one; the app CSP does not apply, because it is a top-level document rather than a
|
||||||
|
frame, and the token gate is what protects the port in both cases; and the window is owned by
|
||||||
|
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
|
||||||
|
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||||
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||||
|
the browser.
|
||||||
|
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||||
|
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||||
|
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
|
||||||
|
container-side browser closes the loop with no host round trip and no auth bridge) and dev
|
||||||
|
servers on container loopback. **Verified: a second client cannot join a bound browser** —
|
||||||
|
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||||
|
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||||
|
is the only process that can drive, which is why the helper is resident and why live resize
|
||||||
|
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||||
|
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||||
|
no second listener — and a re-open with a helper already up *navigates* rather than
|
||||||
|
relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
|
||||||
|
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
|
||||||
|
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||||
|
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||||
|
continuously and each one costs a container exec.
|
||||||
|
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||||
|
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||||
|
container stopped, process exited.
|
||||||
|
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
|
||||||
|
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
|
||||||
|
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does
|
||||||
|
not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the
|
||||||
|
wrapper alone reports a current build as "predates `browser.bind()`".
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that binds,
|
||||||
|
but never `@playwright/cli`, which is the viewer. Never offer it as a setup route — only as
|
||||||
|
what binds sessions automatically once Playwright is present.
|
||||||
|
- **`install.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is
|
||||||
|
*not* a bind mount — project directories are mounted at `/workspace/{mount_name}` — so this
|
||||||
|
touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned),
|
||||||
|
and is on the module resolution path for scripts in the project. Browsers go to
|
||||||
|
`~/.cache/ms-playwright` as `claude`, i.e. the home volume.
|
||||||
|
- **Current base images ship Chromium's shared libraries; older ones do not** — and a project
|
||||||
|
keeps the base image it was first built from until it is migrated, so "older" is the normal
|
||||||
|
case. Without them `playwright install chromium` downloads a browser that cannot launch, which
|
||||||
|
is why installing Chrome via apt looks like a fix. `install.rs` asks
|
||||||
|
`install-deps --dry-run` first and skips the apt step when the answer is "all present",
|
||||||
|
*saying so* in the progress stream. Do not decide this by probing for library names: the
|
||||||
|
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
|
||||||
|
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
|
||||||
|
everything is installed and when Playwright has no list for the platform — match on its
|
||||||
|
output, not its exit code. Either way the action ends by *actually launching* the browser to
|
||||||
|
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
|
||||||
|
offered.
|
||||||
- **`docker/`** — Docker API layer using bollard:
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
@@ -134,7 +257,28 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
|
|
||||||
### Container (`container/`)
|
### Container (`container/`)
|
||||||
|
|
||||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
|
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||||
|
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||||
|
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||||
|
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||||
|
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||||
|
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||||
|
dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do
|
||||||
|
not replace it with a hand-written apt list without pinning the Playwright version you derived
|
||||||
|
it from — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
|
||||||
|
fonts) and nothing more, because `libgbm1` → `mesa-libgallium` → `libllvm20` is ~213 MiB that
|
||||||
|
no trimming removes.
|
||||||
|
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
|
||||||
|
platform Playwright's table does not cover, `install-deps` prints a warning and returns having
|
||||||
|
installed nothing **with exit status 0**. Without the assertion that ships a broken image
|
||||||
|
behind a clean build log.
|
||||||
|
- Baking the libraries but not the browsers is the whole point of the split. Browsers live in
|
||||||
|
`~/.cache/ms-playwright` (home volume) and already survive recreation *and* migration; a
|
||||||
|
runtime `apt-get install` of the libraries lands in the writable layer, is re-paid after every
|
||||||
|
Reset, and is **lost on base-image migration**, which replays apt from a manifest. The runtime
|
||||||
|
approach converges on the worst state: a 400 MB browser present with its libraries gone.
|
||||||
|
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
|
||||||
|
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
||||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
@@ -185,6 +329,90 @@ migration and Reset. Four things here are not obvious:
|
|||||||
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
||||||
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
|
### VPN support (`vpn_support_enabled`, `docker/container.rs`)
|
||||||
|
|
||||||
|
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
|
||||||
|
`vpn_host_config()` is the single definition of what that means, and it is unit-tested because a
|
||||||
|
container is created once by a very long function where a dropped capability is invisible.
|
||||||
|
|
||||||
|
- **All three pieces or none.** `CAP_NET_ADMIN` (Docker's default set has `net_raw` but *not*
|
||||||
|
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
|
||||||
|
entirely from a default container — nothing to open even with the capability), and
|
||||||
|
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
|
||||||
|
container, since `/proc/sys` is read-only, so handshake packets die to reverse-path filtering).
|
||||||
|
Any two without the third still presents as a connection that hangs to a timeout, which is why
|
||||||
|
the tests assert the whole set.
|
||||||
|
- **The device is passed through from the host, never `mknod`-ed inside.** The kernel's `tun`
|
||||||
|
module has to back it.
|
||||||
|
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
|
||||||
|
create --device /dev/does-not-exist` succeeds and prints an id; runc resolves the device (and
|
||||||
|
validates sysctls) only when it builds the container. So the guard belongs on the start path:
|
||||||
|
`explain_container_failure()` covers both and is called from `start_container`, where it has a
|
||||||
|
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
|
||||||
|
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
|
||||||
|
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
|
||||||
|
- **`NET_ADMIN` here is not user-namespaced.** Docker does not enable userns remapping by default,
|
||||||
|
so only the *network* namespace confines it: no reach onto host interfaces, but promiscuous
|
||||||
|
mode, arbitrary addresses/routes/NAT on the shared `docker0` segment (sibling containers, the
|
||||||
|
LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and
|
||||||
|
enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the
|
||||||
|
code comments honest about this — an earlier draft claimed it "confers no authority" outside the
|
||||||
|
container, which is too strong.
|
||||||
|
- **`triple-c.vpn-support` is written unconditionally, including `false`.** The usual
|
||||||
|
`docker commit` reason: a `true` stamped once would ride the snapshot image into every future
|
||||||
|
container and make the switch impossible to turn off.
|
||||||
|
- Off is byte-identical to a container created before the feature existed, and a missing label
|
||||||
|
reads as `false`, so no existing project is churned.
|
||||||
|
- **The toggle grants capability and stops there — it routes nothing.** `vpn_host_config()` returns
|
||||||
|
a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started
|
||||||
|
or restored. Users read the name as "turn the VPN on" and report the default network not routing
|
||||||
|
through it as a bug. It isn't, and the docs say so explicitly; keep it that way.
|
||||||
|
- **The tooling is baked, not installed at runtime.** `iproute2` and `wireguard-tools` are in
|
||||||
|
`container/Dockerfile` because a runtime install lands in the writable layer and is lost on
|
||||||
|
base-image migration — leaving a project holding the capability with nothing able to exercise it,
|
||||||
|
and no error that points at why. `iptables` is included and `nftables` deliberately is not; see
|
||||||
|
the Dockerfile comment for why that way round.
|
||||||
|
- **Anything built on this fails open.** The network namespace is rebuilt on every start and no
|
||||||
|
service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover
|
||||||
|
`/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
|
||||||
|
writable layer, so on a stop/start it is simply the same container's files, and on a recreation
|
||||||
|
`docker commit` has carried it into the snapshot. Traffic silently reverts to the real address.
|
||||||
|
Any future autostart or killswitch work starts here.
|
||||||
|
- **`/run` riding the snapshot means a VPN client's key material can end up in an image.** Verified:
|
||||||
|
a fresh container off the whp snapshot already contained the `wg.priv` a previous tunnel left in
|
||||||
|
`/run`. Anything writing key material there inherits the problem — the same `docker commit`
|
||||||
|
hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks
|
||||||
|
ephemeral and is not. A VPN client that does this should delete its key on teardown.
|
||||||
|
- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
|
||||||
|
nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
|
||||||
|
any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
|
||||||
|
the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
|
||||||
|
`nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
|
||||||
|
`xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment;
|
||||||
|
the kernel-config evidence is quoted there.
|
||||||
|
- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need
|
||||||
|
`xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every
|
||||||
|
provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it
|
||||||
|
breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in
|
||||||
|
systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route`
|
||||||
|
directly avoids both, which is what the skill does.
|
||||||
|
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
|
||||||
|
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
|
||||||
|
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
|
||||||
|
image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
|
||||||
|
The removal branch matters as much as the install: `~/.claude` is a persisted volume, so a skill
|
||||||
|
left behind after the toggle goes off would keep instructing an agent to use a capability the
|
||||||
|
container no longer has. Which is also why the variable is sent as `0` rather than omitted (see
|
||||||
|
`vpn_env_var`, tested), and why it is in `RESERVED_ENV_EXACT` — a custom env var of that name
|
||||||
|
could otherwise claim the skill without the capability behind it.
|
||||||
|
- **Both halves of that live in the base image, so neither reaches an existing project.** A
|
||||||
|
recreation builds from the project's *own snapshot*, which has no `/opt/triple-c-skills` and no
|
||||||
|
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
|
||||||
|
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so the
|
||||||
|
migration pre-flight lists it as missing. Worth knowing before adding anything else behind an
|
||||||
|
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.
|
||||||
|
|
||||||
### Container Lifecycle
|
### 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.
|
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.
|
||||||
@@ -297,6 +525,196 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
|||||||
`models/project.rs` for anything that should default to true.
|
`models/project.rs` for anything that should default to true.
|
||||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
|
## 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
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
- [The Interface](#the-interface)
|
- [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
|
## Prerequisites
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -128,8 +188,11 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||||
3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below.
|
3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below.
|
||||||
|
|
||||||
> If the login hangs after the browser step, the callback could not reach the container. Enable the
|
> If the login hangs after the browser step, the callback could not reach the container. Either
|
||||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project.
|
> click **In container** on the toast instead of **Open** — the callback then never has to leave the
|
||||||
|
> container at all — or turn on the
|
||||||
|
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) in the project's
|
||||||
|
> **Config → Runtime** section.
|
||||||
|
|
||||||
**AWS Bedrock:**
|
**AWS Bedrock:**
|
||||||
|
|
||||||
@@ -191,6 +254,12 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
|
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
|
||||||
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
||||||
a terminal.
|
a terminal.
|
||||||
|
|
||||||
|
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
|
||||||
|
Dropping does not change which tab you are looking at — so you can rearrange the strip without
|
||||||
|
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
|
||||||
|
*active* tab the same way without the mouse (they leave text fields alone, where that chord
|
||||||
|
still selects by word). The order is per-session: it is not saved when you quit.
|
||||||
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
||||||
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
||||||
the built-in help.
|
the built-in help.
|
||||||
@@ -211,7 +280,7 @@ for selecting a project and for two quick controls that appear on hover — star
|
|||||||
Claude terminal. Everything else about a project lives in Project Home.
|
Claude terminal. Everything else about a project lives in Project Home.
|
||||||
|
|
||||||
The header shows the project name, its status, how long the container has been up, and the action
|
The header shows the project name, its status, how long the container has been up, and the action
|
||||||
buttons. Below that are five tabs:
|
buttons. Below that are six tabs:
|
||||||
|
|
||||||
| Tab | What it's for |
|
| Tab | What it's for |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -219,7 +288,8 @@ buttons. Below that are five tabs:
|
|||||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
| **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) |
|
| **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) |
|
| **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
|
### Sessions
|
||||||
|
|
||||||
@@ -257,6 +327,61 @@ included, and each tile opens a list of what it found.
|
|||||||
|
|
||||||
The counts are only available while the container is running.
|
The counts are only available while the container is running.
|
||||||
|
|
||||||
|
### The Browser Tab
|
||||||
|
|
||||||
|
When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you
|
||||||
|
that browser live — and lets you take it over with your own mouse and keyboard.
|
||||||
|
|
||||||
|
It is **off by default and opted into per project**, and it never installs anything on its own.
|
||||||
|
Opening the tab only *probes* the container, so it can tell you what is missing before you ask for
|
||||||
|
a view; installing Playwright and downloading a browser are separate, labelled buttons that state
|
||||||
|
what they cost before you press them. See
|
||||||
|
[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not
|
||||||
|
pre-installed.
|
||||||
|
|
||||||
|
Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the
|
||||||
|
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||||
|
exposed off the machine.
|
||||||
|
|
||||||
|
#### Opening a page yourself
|
||||||
|
|
||||||
|
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
|
||||||
|
publishes it to this pane. Two uses:
|
||||||
|
|
||||||
|
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
|
||||||
|
a container-side browser completes the login without anything crossing to your host browser.
|
||||||
|
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
|
||||||
|
offers **In container**, which does the same thing in one click.
|
||||||
|
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
|
||||||
|
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
|
||||||
|
around it yourself.
|
||||||
|
|
||||||
|
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
|
||||||
|
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
|
||||||
|
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
|
||||||
|
|
||||||
|
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
|
||||||
|
be *watched* but not resized — a published browser admits only the client that launched it. Set
|
||||||
|
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
|
||||||
|
instead.
|
||||||
|
|
||||||
|
#### Watching it while you work
|
||||||
|
|
||||||
|
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
||||||
|
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||||
|
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
|
||||||
|
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
|
||||||
|
for the reason above. This is a window change only: the browser and the view keep running throughout, so
|
||||||
|
popping out and back costs nothing and interrupts nothing.
|
||||||
|
|
||||||
|
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||||
|
two viewers would both be able to *drive* the browser, and two cursors on one page is not useful.
|
||||||
|
**Put back in tab**, or just closing the window, brings it back.
|
||||||
|
|
||||||
|
The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and
|
||||||
|
stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes
|
||||||
|
it, because a window showing a viewer that no longer exists is worse than no window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Management
|
## Project Management
|
||||||
@@ -286,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 |
|
| **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 |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
||||||
@@ -409,6 +534,93 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
|||||||
|
|
||||||
> Toggling this requires stopping and restarting the container to take effect.
|
> Toggling this requires stopping and restarting the container to take effect.
|
||||||
|
|
||||||
|
### VPN Support
|
||||||
|
|
||||||
|
When enabled, the container is given the three things a VPN client needs to build a tunnel:
|
||||||
|
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
|
||||||
|
sysctl that WireGuard requires. This is **off by default**.
|
||||||
|
|
||||||
|
The `ip`, `wg` and `iptables` commands ship in the container image so there is something able to use
|
||||||
|
them. If your project's container was created from an older base image it will not have them, and
|
||||||
|
`wg` will simply not be found — **migrating the project onto the current base image** is what picks
|
||||||
|
them up. `sudo apt install iproute2 wireguard-tools iptables` works in the meantime, but lives in
|
||||||
|
the writable layer, so it is undone by a **Reset** and by a migration.
|
||||||
|
|
||||||
|
**This setting makes a tunnel possible; it does not make one.** Nothing is connected, no traffic is
|
||||||
|
redirected, and no tunnel is configured or started on your behalf. Enabling it and expecting the
|
||||||
|
container's traffic to start leaving through a VPN is the most common misreading of what it does —
|
||||||
|
configuring a tunnel and routing traffic into it remains yours to do.
|
||||||
|
|
||||||
|
To make that second half easier, enabling this also installs a **`pia-vpn` skill** into the
|
||||||
|
container's `~/.claude/skills/`, so Claude Code can bring up a Private Internet Access tunnel over
|
||||||
|
WireGuard for you — ask it to connect the VPN and it will. The skill carries the parts that are
|
||||||
|
easy to get wrong (see the DNS note below), and it is removed again when you turn the setting off.
|
||||||
|
It needs your PIA credentials in `~/pia-creds`, two lines, username then password. If you use a
|
||||||
|
different provider, ignore it and set up your own client; nothing else depends on it.
|
||||||
|
|
||||||
|
Like the VPN tooling above, the skill ships in the container image, so a project whose container
|
||||||
|
predates it will not get one by toggling the setting — **migrate the project** and it appears; the
|
||||||
|
migration pre-flight lists it among what you would gain.
|
||||||
|
|
||||||
|
With the setting **off**, a client such as PIA or OpenVPN installs and its daemon starts normally,
|
||||||
|
but the connection attempt **hangs until it times out** — a default container has no tun device to open
|
||||||
|
and no permission to add an interface or a route, and most clients report that as a generic timeout
|
||||||
|
rather than a permissions error.
|
||||||
|
|
||||||
|
Things worth knowing:
|
||||||
|
|
||||||
|
- Tailscale is the exception: in its `--tun=userspace-networking` mode it needs neither the
|
||||||
|
capability nor the device, so leave this off if that is all you want.
|
||||||
|
|
||||||
|
- `NET_ADMIN` applies to the container's **own** network namespace — it cannot touch the host's
|
||||||
|
interfaces. It is not nothing, though: within that namespace anything in the container can set
|
||||||
|
promiscuous mode and add arbitrary addresses, routes and firewall rules on the Docker bridge it
|
||||||
|
shares with your other containers, and it can flush firewall rules that sandbox mode relies on.
|
||||||
|
Grant it per project, to projects that need it.
|
||||||
|
- The **Docker host's** kernel must have the `tun` module available. With Docker Desktop that is
|
||||||
|
the Linux VM, not your own machine. If it is missing, the container is created but fails to
|
||||||
|
**start**, with an error naming `/dev/net/tun` and pointing back at this setting.
|
||||||
|
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the
|
||||||
|
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
|
||||||
|
- **No tunnel survives a restart.** The network namespace is built fresh every time the container
|
||||||
|
starts, and there is no service manager inside to reconnect anything. Leftover state under `/run`
|
||||||
|
makes it *look* like the tunnel is still configured — that directory is in the container's
|
||||||
|
writable layer, so it is simply still there after a stop/start, and `docker commit` carries it
|
||||||
|
into the snapshot that a recreation is built from. Either way the interface and its routes are
|
||||||
|
gone and traffic goes out your real address again, with no error and nothing visibly different.
|
||||||
|
Re-establish it after every start, and check rather than assume.
|
||||||
|
- **A full tunnel breaks DNS unless the client is told to leave private ranges alone.** Your
|
||||||
|
resolver is whatever `/etc/resolv.conf` says, and if that address is outside the container's own
|
||||||
|
subnet then a default route of `0.0.0.0/0` — or a `0.0.0.0/1` plus `128.0.0.0/1` pair — captures
|
||||||
|
it and sends every lookup into a tunnel that cannot carry it. Under Docker Desktop it is
|
||||||
|
`192.168.65.7`, which is exactly that case; on a user-defined Docker network it is `127.0.0.11`,
|
||||||
|
which is loopback and unaffected. Check yours rather than assuming. The symptom when it bites is
|
||||||
|
total: Claude Code reports it cannot connect, because it cannot resolve `api.anthropic.com`.
|
||||||
|
Route `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `169.254.0.0/16` via the original
|
||||||
|
gateway — and give the tunnel a resolver it can actually reach, normally the VPN provider's own,
|
||||||
|
or you have a tunnel that leaks every DNS query outside itself. Also pin the VPN endpoint's own
|
||||||
|
address via the original gateway, or the tunnel's encrypted packets try to route through the
|
||||||
|
tunnel. Note that a health check which fetches an IP literal such as `1.1.1.1` passes cleanly
|
||||||
|
while DNS is broken — resolve a name instead.
|
||||||
|
- **Delete a client's key material when you tear a tunnel down.** Anything written under `/run` is
|
||||||
|
in the container's writable layer, and recreating or migrating the project runs `docker commit`
|
||||||
|
over it — so a WireGuard private key left there gets baked into the project's snapshot image and
|
||||||
|
copied forward from then on. This is not hypothetical; it has already happened here.
|
||||||
|
- **Strip the `DNS =` line from a provider's `.conf` before `wg-quick up`.** Every commercial
|
||||||
|
provider ships one, and `wg-quick` hands it to `resolvconf`, which is not installed — so it fails
|
||||||
|
at `resolvconf: command not found` and deletes the interface again. This happens before any
|
||||||
|
routing, so it takes **split tunnels down too**. Set the resolver another way instead, or drive
|
||||||
|
`wg` and `ip route` directly rather than going through `wg-quick`.
|
||||||
|
- **`wg-quick` full tunnels additionally need `xt_CONNMARK` from the host kernel.** WSL2 kernels
|
||||||
|
before 6.6 do not have it and a container cannot load one — on Windows, `wsl --update` moves you
|
||||||
|
to a current kernel, which does. Failing that, add the routes yourself with `ip route`, which
|
||||||
|
needs no firewall backend on any platform. Note this is the *second* hurdle: clear the `DNS =`
|
||||||
|
one above first, or you will not reach this.
|
||||||
|
|
||||||
|
> This setting can only be changed when the container is stopped. Capabilities and devices are
|
||||||
|
> fixed when a container is created, so toggling it recreates the container on the next start.
|
||||||
|
> Recreation preserves the home and `.claude` volumes — it is not a Reset.
|
||||||
|
|
||||||
### Mission Control
|
### Mission Control
|
||||||
|
|
||||||
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
||||||
@@ -463,18 +675,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
|||||||
|
|
||||||
| Setting | What It Does |
|
| Setting | What It Does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
| **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 process as summaries during responses |
|
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||||
| **Session Recap** | Provides context when returning to a session after being away |
|
| **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 Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
| **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
|
### MCP Servers
|
||||||
|
|
||||||
@@ -509,6 +730,10 @@ This lives in the sidebar under **Settings → Claude Authentication**.
|
|||||||
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
||||||
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
||||||
|
|
||||||
|
The code is long and easy to truncate. If Anthropic refuses it, the dialog says so and lets you
|
||||||
|
paste another one without restarting the sign-in — the CLI is still waiting. After a few refusals
|
||||||
|
the flow gives up and reports it rather than sitting there.
|
||||||
|
|
||||||
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
||||||
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||||
and nothing is stored.
|
and nothing is stored.
|
||||||
@@ -636,6 +861,19 @@ web server they started on `localhost`. `claude login`, `aws sso login` and Conc
|
|||||||
|
|
||||||
The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**.
|
The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**.
|
||||||
|
|
||||||
|
### Where the switch is
|
||||||
|
|
||||||
|
Project Home → **Config** → **Runtime** → **Auth bridge**.
|
||||||
|
|
||||||
|
Unlike the rest of that tab, it is **not** greyed out while the container is running — it is a
|
||||||
|
host-side feature that recreates nothing, and the moment you want it is usually the moment a login
|
||||||
|
is already hanging in a running container. Switch it on, then retry the login.
|
||||||
|
|
||||||
|
Beside the switch is its live state: **Off**, **Watching** (on, nothing to bridge yet — normal,
|
||||||
|
there is only something to bridge while a login is waiting), **Bridging *n* ports**, **IPv4 only**,
|
||||||
|
or **Port conflict** with the port and the reason. A conflict means the host port was already taken
|
||||||
|
and the callback will not arrive; free the port, or use **In container** instead.
|
||||||
|
|
||||||
### What it does
|
### What it does
|
||||||
|
|
||||||
- Every couple of seconds it looks inside the container for programs listening on the container's
|
- Every couple of seconds it looks inside the container for programs listening on the container's
|
||||||
@@ -992,14 +1230,61 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
|||||||
|
|
||||||
### Files
|
### 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
|
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||||
- **Download** any file to your host machine via the **Download** button on each file entry
|
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||||
- **Upload file** from your host into the current container directory
|
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
|
- **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
|
### Terminal Rendering
|
||||||
|
|
||||||
@@ -1045,10 +1330,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
|
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||||
that was used.
|
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 quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
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
|
### Create a Recurring Task
|
||||||
|
|
||||||
@@ -1073,13 +1362,23 @@ triple-c-scheduler list # List all tasks
|
|||||||
triple-c-scheduler enable --id abc123 # Enable a task
|
triple-c-scheduler enable --id abc123 # Enable a task
|
||||||
triple-c-scheduler disable --id abc123 # Disable a task
|
triple-c-scheduler disable --id abc123 # Disable a task
|
||||||
triple-c-scheduler remove --id abc123 # Delete a task
|
triple-c-scheduler remove --id abc123 # Delete a task
|
||||||
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
|
||||||
|
triple-c-scheduler status # What is running right now, and for how long
|
||||||
|
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
|
||||||
triple-c-scheduler logs --id abc123 # View logs for a task
|
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||||
triple-c-scheduler notifications # View completion notifications
|
triple-c-scheduler notifications # View completion notifications
|
||||||
triple-c-scheduler notifications --clear # Clear notifications
|
triple-c-scheduler notifications --clear # Clear notifications
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`list` carries a status column, and the Automation tab marks a task **Running** with
|
||||||
|
its elapsed time, so a triggered run is visible rather than silent.
|
||||||
|
|
||||||
|
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
|
||||||
|
writes its answer in one go when it finishes, so a healthy run shows nothing but its
|
||||||
|
header for as long as it is thinking. `status` is what distinguishes a slow run from
|
||||||
|
a dead one — it reports the run only while the runner's process is genuinely alive.
|
||||||
|
|
||||||
### Cron Schedule Format
|
### Cron Schedule Format
|
||||||
|
|
||||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
@@ -1115,6 +1414,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
|||||||
| **Ctrl+Tab** | Switch to the next tab |
|
| **Ctrl+Tab** | Switch to the next tab |
|
||||||
| **Ctrl+Shift+Tab** | Switch to the previous tab |
|
| **Ctrl+Shift+Tab** | Switch to the previous tab |
|
||||||
| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab |
|
| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab |
|
||||||
|
| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) |
|
||||||
|
|
||||||
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
|
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
|
||||||
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it
|
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it
|
||||||
@@ -1129,9 +1429,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
|||||||
| **Ctrl+Shift+V** | Paste |
|
| **Ctrl+Shift+V** | Paste |
|
||||||
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
||||||
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
||||||
|
| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it |
|
||||||
|
| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down |
|
||||||
|
|
||||||
Everything else goes straight through to the program running in the container.
|
Everything else goes straight through to the program running in the container.
|
||||||
|
|
||||||
|
> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs
|
||||||
|
> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is
|
||||||
|
> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and
|
||||||
|
> Shift+Enter there submits the line as it always has.
|
||||||
|
>
|
||||||
|
> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+**
|
||||||
|
> key beside **Enter** on the mobile key row for devices with no Shift.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's Inside the Container
|
## What's Inside the Container
|
||||||
@@ -1148,6 +1458,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
|||||||
| ruff | Latest | Python linter/formatter |
|
| ruff | Latest | Python linter/formatter |
|
||||||
| Rust | Stable | Rust development (via rustup) |
|
| Rust | Stable | Rust development (via rustup) |
|
||||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||||
|
| iproute2, WireGuard tools, iptables | Latest | Building a tunnel (when VPN Support is enabled) |
|
||||||
| git | Latest | Version control |
|
| git | Latest | Version control |
|
||||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||||
| AWS CLI | v2 | AWS services and Bedrock |
|
| AWS CLI | v2 | AWS services and Bedrock |
|
||||||
@@ -1157,6 +1468,12 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
|||||||
|
|
||||||
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||||
|
|
||||||
|
It also ships the **system libraries a browser needs to run** (`libnss3`, `libgbm1`, `libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, fonts, and the rest of the set Playwright asks for). So `npx playwright install chromium` gives you a browser that actually starts. Before these were baked in, that download succeeded and the browser then died with *"Host system is missing dependencies: libnss3.so"*, which is why `sudo apt install google-chrome-stable` looked like the cure — apt was quietly installing the same libraries as Chrome's own dependencies.
|
||||||
|
|
||||||
|
The **browsers themselves are not pre-installed** — they are hundreds of megabytes and tied to the Playwright version you use. Install one with the Browser tab's setup buttons, or `npx playwright install chromium` in a terminal. They land in `~/.cache/ms-playwright`, which is on the home volume, so a browser survives container recreation and base-image migration and is only lost on a project **Reset**.
|
||||||
|
|
||||||
|
If your project's container was created from an older base image, it won't have the libraries — the Browser tab's install action detects that and installs them for you first, and says so while it does. That install lives in the container's writable layer, so it is undone by a **Reset** and by a base-image migration; migrating the project onto the current base image is what picks the libraries up for good.
|
||||||
|
|
||||||
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1231,8 +1548,18 @@ your machine (anything that isn't `http`/`https`).
|
|||||||
|
|
||||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||||
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
||||||
*container's*. Enable the
|
*container's*.
|
||||||
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
|
|
||||||
|
Two ways out, in order of least effort:
|
||||||
|
|
||||||
|
1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**.
|
||||||
|
The page opens in a browser *inside* the container, so the callback never has to cross to the
|
||||||
|
host. This needs no auth bridge — only a running container with Playwright installed (Project
|
||||||
|
Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button.
|
||||||
|
2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home →
|
||||||
|
**Config** → **Runtime** → **Auth bridge** — and try again. It can be switched on while the
|
||||||
|
container is running. Check the indicator beside it: **Port conflict** means the host port was
|
||||||
|
already taken and the callback still will not arrive.
|
||||||
|
|
||||||
For Claude specifically, the simpler answer is usually
|
For Claude specifically, the simpler answer is usually
|
||||||
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
||||||
@@ -1269,3 +1596,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.
|
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.
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||||
|
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||||
|
</picture>
|
||||||
|
|
||||||
# Triple-C (Claude-Code-Container)
|
# Triple-C (Claude-Code-Container)
|
||||||
|
|
||||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||||
|
|
||||||
|
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||||
|
|
||||||
|
| Document | For |
|
||||||
|
|---|---|
|
||||||
|
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||||
|
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||||
|
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||||
|
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||||
|
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||||
|
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||||
|
- [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, 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)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||||
@@ -29,6 +55,13 @@ two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a termi
|
|||||||
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
thing is current at a time.
|
thing is current at a time.
|
||||||
|
|
||||||
|
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||||
|
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||||
|
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||||
|
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||||
|
carries dropped *file paths*.
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||||
@@ -39,31 +72,47 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
|||||||
| `Ctrl+Shift+W` | Close the active tab |
|
| `Ctrl+Shift+W` | Close the active tab |
|
||||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||||
|
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||||
|
|
||||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||||
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
why moving a tab takes Shift as well.
|
||||||
|
|
||||||
|
Terminal-scoped keys are handled in `TerminalView.tsx`:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is |
|
||||||
|
| `Ctrl+Shift+M` | Toggle speech-to-text recording |
|
||||||
|
| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting |
|
||||||
|
| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked |
|
||||||
|
|
||||||
|
`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for
|
||||||
|
VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes
|
||||||
|
are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for
|
||||||
|
devices with no Shift.
|
||||||
|
|
||||||
### Project Home
|
### Project Home
|
||||||
|
|
||||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||||
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||||
Per-project configuration lives in the Config tab rather than in modals.
|
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
| **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) |
|
| **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
|
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||||
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
progress modal.
|
progress modal.
|
||||||
|
|
||||||
### Permission Modes
|
## Permission Modes
|
||||||
|
|
||||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||||
@@ -87,15 +136,196 @@ back into flags for its headless `claude -p` run. Because it travels as containe
|
|||||||
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
forces that).
|
forces that).
|
||||||
|
|
||||||
### Container Introspection (Capability Tiles)
|
## Containers
|
||||||
|
|
||||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
### Container Lifecycle
|
||||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
|
||||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
|
||||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
|
||||||
|
|
||||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||||
|
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||||
|
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||||
|
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||||
|
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||||
|
|
||||||
|
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||||
|
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||||
|
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||||
|
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||||
|
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||||
|
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||||
|
image.
|
||||||
|
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||||
|
|
||||||
|
### Base-Image Migration
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||||
|
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||||
|
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||||
|
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||||
|
|
||||||
|
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||||
|
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||||
|
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||||
|
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||||
|
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||||
|
"stale".
|
||||||
|
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||||
|
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||||
|
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||||
|
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||||
|
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||||
|
skills, transcripts and scheduler tasks simply re-attach.
|
||||||
|
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||||
|
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||||
|
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||||
|
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||||
|
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||||
|
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||||
|
report.
|
||||||
|
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||||
|
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||||
|
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||||
|
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||||
|
`$HOME` during a migrated session survives it.
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
| Target in Container | Source | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||||
|
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||||
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
|
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||||
|
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||||
|
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||||
|
removal, and by nothing else.
|
||||||
|
|
||||||
|
### Corporate CA Certificates
|
||||||
|
|
||||||
|
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||||
|
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||||
|
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||||
|
every start — so it survives recreation, migration and Reset.
|
||||||
|
|
||||||
|
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||||
|
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||||
|
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||||
|
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||||
|
sees a directory.
|
||||||
|
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||||
|
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||||
|
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||||
|
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||||
|
rather than failing the start.
|
||||||
|
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||||
|
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||||
|
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||||
|
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||||
|
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||||
|
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||||
|
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||||
|
so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
|
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||||
|
|
||||||
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
|
### Docker Socket Path
|
||||||
|
|
||||||
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
|
## Models and Authentication
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
Each project can independently use one of:
|
||||||
|
|
||||||
|
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||||
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
|
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||||
|
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||||
|
|
||||||
|
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||||
|
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||||
|
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||||
|
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||||
|
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||||
|
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||||
|
> they get first-class backends of their own rather than going through a translation layer.
|
||||||
|
|
||||||
|
#### Model alias variables
|
||||||
|
|
||||||
|
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||||
|
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||||
|
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||||
|
|
||||||
|
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||||
|
Triple-C therefore sets all four:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||||
|
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||||
|
|
||||||
|
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||||
|
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||||
|
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||||
|
|
||||||
|
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||||
|
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||||
|
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||||
|
|
||||||
|
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
|
|
||||||
|
### Model Gateway (LiteLLM sibling container)
|
||||||
|
|
||||||
|
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||||
|
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||||
|
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||||
|
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||||
|
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||||
|
lifecycle, including auto-start with the app.
|
||||||
|
|
||||||
|
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||||
|
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||||
|
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||||
|
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||||
|
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||||
|
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||||
|
**always** set, because LiteLLM without one accepts any key.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||||
|
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||||
|
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||||
|
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||||
|
anything resembling an `sk-ant-` secret.
|
||||||
|
|
||||||
|
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||||
|
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||||
|
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||||
|
|
||||||
|
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||||
|
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||||
|
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||||
|
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||||
|
it cleared.
|
||||||
|
|
||||||
|
## Bridges to the Host
|
||||||
|
|
||||||
### URL Relay (host browser)
|
### URL Relay (host browser)
|
||||||
|
|
||||||
@@ -175,96 +405,87 @@ recreates the container. The poller stops on its own when the container stops.
|
|||||||
unauthenticated service inside the container, so widening those addresses would publish container
|
unauthenticated service inside the container, so widening those addresses would publish container
|
||||||
internals to the LAN. Nothing else on the network can reach a bridged port.
|
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||||
|
|
||||||
### Shared Claude Authentication Token
|
### Browser View
|
||||||
|
|
||||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
per project.
|
||||||
anything resembling an `sk-ant-` secret.
|
|
||||||
|
|
||||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||||
|
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||||
|
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||||
|
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||||
|
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||||
|
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||||
|
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||||
|
iframe while popped out, and both viewers can drive the browser.
|
||||||
|
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||||
|
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||||
|
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||||
|
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||||
|
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||||
|
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||||
|
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||||
|
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||||
|
continuously and each event costs a container exec.
|
||||||
|
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||||
|
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||||
|
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||||
|
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||||
|
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||||
|
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||||
|
recreation *and* migration.
|
||||||
|
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||||
|
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||||
|
once Playwright is present — not a setup route.
|
||||||
|
|
||||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
### Host File Transfers
|
||||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
|
||||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
|
||||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
|
||||||
it cleared.
|
|
||||||
|
|
||||||
### Container Lifecycle
|
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`.
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||||
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
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.
|
||||||
|
|
||||||
### Mounts
|
## Inside a Project
|
||||||
|
|
||||||
| Target in Container | Source | Type | Notes |
|
### Container Introspection (Capability Tiles)
|
||||||
|---|---|---|---|
|
|
||||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
|
||||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
|
||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
|
||||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
|
||||||
|
|
||||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
removal, and by nothing else.
|
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||||
|
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||||
|
|
||||||
### Authentication Modes
|
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||||
|
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||||
Each project can independently use one of:
|
|
||||||
|
|
||||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
|
||||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
|
||||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
|
||||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
|
||||||
|
|
||||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
|
||||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
|
||||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
|
||||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
|
||||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
|
||||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
|
||||||
> they get first-class backends of their own rather than going through a translation layer.
|
|
||||||
|
|
||||||
#### Model alias variables
|
|
||||||
|
|
||||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
|
||||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
|
||||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
|
||||||
|
|
||||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
|
||||||
Triple-C therefore sets all four:
|
|
||||||
|
|
||||||
| Variable | Value |
|
|
||||||
|---|---|
|
|
||||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
|
||||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
|
||||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
|
||||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
|
||||||
|
|
||||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
|
||||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
|
||||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
|
||||||
|
|
||||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
|
||||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
|
||||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
|
||||||
|
|
||||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
|
||||||
|
|
||||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
|
||||||
|
|
||||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
|
||||||
|
|
||||||
### Mission Control Integration
|
### Mission Control Integration
|
||||||
|
|
||||||
@@ -289,87 +510,117 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||||
- **Port**: Default `9876` (configurable)
|
- **Port**: Default `9876` (configurable)
|
||||||
|
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||||
- **Language**: Optional language hint for transcription
|
- **Language**: Optional language hint for transcription
|
||||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||||
|
|
||||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||||
|
|
||||||
### Docker Socket Path
|
|
||||||
|
|
||||||
The socket path is OS-aware:
|
|
||||||
- **Linux/macOS**: `/var/run/docker.sock`
|
|
||||||
- **Windows**: `//./pipe/docker_engine`
|
|
||||||
|
|
||||||
Users can override this in Settings via the global `docker_socket_path` option.
|
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
|
### Frontend — layout and projects
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
| `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) |
|
| `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/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, Jump to Current, 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/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/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
|
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||||
|
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||||
|
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||||
|
|
||||||
|
### Frontend — Project Home
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||||
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||||
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
| `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/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 |
|
||||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
|
||||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
|
||||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
### Frontend — settings, terminal and hooks
|
||||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||||
|
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||||
|
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||||
|
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
|
||||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||||
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
| `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/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||||
|
| `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/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
| `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 |
|
||||||
|
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
| 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/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/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 |
|
||||||
|
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||||
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||||
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||||
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||||
|
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
| `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_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/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/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
| `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) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||||
|
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||||
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||||
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||||
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
|
||||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
### Container and packaging
|
||||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
|
||||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
| File | Purpose |
|
||||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
|---|---|
|
||||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
|
||||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||||
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||||
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
|
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||||
|
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||||
|
|
||||||
## CSS / Styling Notes
|
## CSS / Styling Notes
|
||||||
|
|
||||||
@@ -382,8 +633,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
|
|
||||||
**Base**: Ubuntu 24.04
|
**Base**: Ubuntu 24.04
|
||||||
|
|
||||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||||
|
|
||||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||||
|
|
||||||
|
**Browser runtime libraries**: the shared libraries Chromium links against (`libnss3`, `libgbm1`,
|
||||||
|
`libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, … plus fonts) are baked in, via
|
||||||
|
`npx playwright install-deps chromium` at build time. Without them `playwright install chromium`
|
||||||
|
downloads a browser that then dies at launch with *"Host system is missing dependencies:
|
||||||
|
libnss3.so"* — which is why installing `google-chrome-stable` used to look like the fix (apt was
|
||||||
|
pulling the libraries in as *its* dependencies). Measured cost of the layer: +99 packages,
|
||||||
|
**+334 MiB unpacked / +119 MiB compressed** (2950 → 3284 MiB unpacked, 759 → 878 MiB compressed).
|
||||||
|
Two thirds of that is not avoidable by trimming — `libgbm1`, which Chromium needs, depends on
|
||||||
|
`mesa-libgallium`, which depends on `libllvm20`. The list is taken from Playwright rather than
|
||||||
|
hand-written so it cannot rot against Ubuntu 24.04's `t64` renames or a future Chromium dependency,
|
||||||
|
and the `install-deps --dry-run` that follows it is a build-time assertion: on a platform
|
||||||
|
Playwright has no list for, `install-deps` installs nothing and still exits 0.
|
||||||
|
|
||||||
|
**Browser binaries are deliberately not baked.** They are large, they are version-coupled to
|
||||||
|
whatever Playwright the user installs, and they already persist: `~/.cache/ms-playwright` is inside
|
||||||
|
the home volume, so a downloaded browser survives container recreation *and* base-image migration.
|
||||||
|
The libraries are the opposite — a runtime `apt-get install` lands in the container's writable
|
||||||
|
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
||||||
|
against the new base). Baking one and not the other puts each half where it already persists.
|
||||||
|
|
||||||
|
**`/home/claude` in the image is seed-only.** It is the mount point of the `triple-c-home-{projectId}`
|
||||||
|
volume, so after a project's *first* start the image's copy of that directory is masked permanently.
|
||||||
|
A change made under `/home/claude` in the Dockerfile reaches **new projects only** — with or without
|
||||||
|
a base-image migration. Anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or
|
||||||
|
must be seeded by `entrypoint.sh` on every start.
|
||||||
|
|
||||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
|
|||||||
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
|
|||||||
|
|
||||||
## Current coverage (v0.3.0)
|
## Current coverage (v0.3.0)
|
||||||
|
|
||||||
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
|
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
|
||||||
|
|
||||||
| Key | Surfaced as |
|
| Key | Surfaced as |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tui` | TUI Mode select (`fullscreen`) |
|
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||||
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||||
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||||
| `focusMode` | Focus Mode toggle |
|
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||||
| `showThinkingSummaries` | Thinking Summaries toggle |
|
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
|
||||||
|
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
|
||||||
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||||
|
|
||||||
|
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
|
||||||
|
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
|
||||||
|
merges into it, so a key merely omitted when its control goes off left the previous
|
||||||
|
on-value in place forever.
|
||||||
|
|
||||||
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||||
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||||
`CLAUDE_CODE_*` vars via the Env Vars modal.
|
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
|
||||||
|
create *including* their off value, because `docker commit` bakes a container's env into
|
||||||
|
the snapshot image: a value written once would otherwise ride that snapshot into every
|
||||||
|
future container. That also makes them Triple-C's to own, so all four are reserved names
|
||||||
|
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
|
||||||
|
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
|
||||||
|
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
|
||||||
|
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
|
||||||
|
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
|
||||||
|
choice it never asked about.
|
||||||
|
|
||||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
|
|||||||
@@ -245,9 +245,31 @@ minutes.
|
|||||||
|
|
||||||
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||||
frontend, never written to a log, and no command accepts or returns it.
|
frontend, never written to a log, and no command accepts or returns it.
|
||||||
|
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||||
|
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
|
||||||
|
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
|
||||||
|
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
|
||||||
|
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||||
|
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||||
|
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||||
|
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||||
|
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||||
|
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||||
|
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||||
|
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||||
|
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
|
||||||
|
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
|
||||||
|
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
|
||||||
|
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||||
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||||
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||||
could still grow into a secret across a chunk boundary.
|
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||||
|
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||||
|
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||||
|
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||||
|
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||||
|
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||||
|
token onto the next frame's first word.
|
||||||
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||||
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||||
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
@@ -390,13 +412,12 @@ triple-c/
|
|||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||||
│ ├── build-app-preview.yml # Preview builds
|
│ ├── build-app-preview.yml # Preview builds
|
||||||
│ ├── build.yml # Build container image (multi-arch)
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
│ ├── build-stt.yml # Build the STT image
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
|
||||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
│ └── cleanup-releases.yml # Prune old releases
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
@@ -414,7 +435,7 @@ triple-c/
|
|||||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
│ │ ├── useFileManager.ts # File browser operations
|
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||||
@@ -442,7 +463,7 @@ triple-c/
|
|||||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
│ │ │ ├── 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
|
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||||
@@ -482,7 +503,7 @@ triple-c/
|
|||||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── 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
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Triple-C</title>
|
<title>Triple-C</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.18",
|
"@xterm/addon-webgl": "^0.18",
|
||||||
@@ -2001,15 +2000,6 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-store": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.18",
|
"@xterm/addon-webgl": "^0.18",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||||
|
<title>Triple-C application icon, small-size variant</title>
|
||||||
|
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||||
|
survive being resampled down to 16 px. See build-icons.py. -->
|
||||||
|
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||||
|
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||||
|
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||||
|
stroke="#58A6FF" stroke-width="14"/>
|
||||||
|
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 812 B |
@@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -310,6 +363,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -569,6 +631,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1135,7 +1218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3394,7 +3525,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3743,7 +3874,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4654,22 +4785,6 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-plugin-store"
|
|
||||||
version = "2.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
|
||||||
dependencies = [
|
|
||||||
"dunce",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-plugin",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -4782,7 +4897,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5163,8 +5278,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bollard",
|
"bollard",
|
||||||
@@ -5187,10 +5304,10 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5304,6 +5421,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5689,7 +5816,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -37,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
@@ -2473,180 +2473,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:default",
|
|
||||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the clear command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-clear",
|
|
||||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the delete command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-delete",
|
|
||||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the entries command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-entries",
|
|
||||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-get",
|
|
||||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-get-store",
|
|
||||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the has command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-has",
|
|
||||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the keys command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-keys",
|
|
||||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the length command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-length",
|
|
||||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the load command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-load",
|
|
||||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reload command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-reload",
|
|
||||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reset command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-reset",
|
|
||||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the save command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-save",
|
|
||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the set command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-set",
|
|
||||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the values command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-values",
|
|
||||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the clear command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-clear",
|
|
||||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the delete command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-delete",
|
|
||||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the entries command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-entries",
|
|
||||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-get",
|
|
||||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-get-store",
|
|
||||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the has command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-has",
|
|
||||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the keys command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-keys",
|
|
||||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the length command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-length",
|
|
||||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the load command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-load",
|
|
||||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reload command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-reload",
|
|
||||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reset command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-reset",
|
|
||||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the save command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-save",
|
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the set command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-set",
|
|
||||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the values command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-values",
|
|
||||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2473,180 +2473,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:default",
|
|
||||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the clear command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-clear",
|
|
||||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the delete command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-delete",
|
|
||||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the entries command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-entries",
|
|
||||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-get",
|
|
||||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the get_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-get-store",
|
|
||||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the has command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-has",
|
|
||||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the keys command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-keys",
|
|
||||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the length command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-length",
|
|
||||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the load command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-load",
|
|
||||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reload command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-reload",
|
|
||||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reset command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-reset",
|
|
||||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the save command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-save",
|
|
||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the set command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-set",
|
|
||||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the values command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-values",
|
|
||||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the clear command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-clear",
|
|
||||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the delete command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-delete",
|
|
||||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the entries command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-entries",
|
|
||||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-get",
|
|
||||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the get_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-get-store",
|
|
||||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the has command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-has",
|
|
||||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the keys command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-keys",
|
|
||||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the length command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-length",
|
|
||||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the load command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-load",
|
|
||||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reload command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-reload",
|
|
||||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reset command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-reset",
|
|
||||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the save command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-save",
|
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the set command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-set",
|
|
||||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the values command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-values",
|
|
||||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 16 KiB |
@@ -82,6 +82,10 @@ pub struct BridgedPort {
|
|||||||
pub family: PortFamily,
|
pub family: PortFamily,
|
||||||
/// RFC 3339 timestamp of when the host listener was bound.
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
pub bridged_at: String,
|
pub bridged_at: String,
|
||||||
|
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||||
|
/// port still works, but not for a client that insists on `::1` — see
|
||||||
|
/// [`tunnel::PortForward::ipv6_warning`].
|
||||||
|
pub ipv6_warning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A loopback listener that was discovered but could not be bridged.
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
|||||||
port: f.port,
|
port: f.port,
|
||||||
family: f.family,
|
family: f.family,
|
||||||
bridged_at: f.bridged_at.clone(),
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
ipv6_warning: f.ipv6_warning.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
conflicts: self
|
conflicts: self
|
||||||
@@ -329,7 +334,10 @@ async fn poll_loop(
|
|||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
exec_failures = 0;
|
exec_failures = 0;
|
||||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||||
let skip = skipped_ports(&project);
|
// Re-read every tick: a project can gain a port mapping and the
|
||||||
|
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||||
|
// bridge is running, and a stale reservation set is a hole.
|
||||||
|
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
emit_status(&app, &project_id, &state, true).await;
|
emit_status(&app, &project_id, &state, true).await;
|
||||||
}
|
}
|
||||||
@@ -372,24 +380,101 @@ async fn poll_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ports Docker already handles for this project. A container port that is
|
/// Every port this project's bridge must not take.
|
||||||
/// explicitly published has a host-side path already, and the mapping's host
|
|
||||||
/// port is a binding we must not fight over.
|
|
||||||
///
|
///
|
||||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||||
/// loopback listeners another feature owns and exposes on its own,
|
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||||
/// authenticated terms.
|
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
/// enumerated here. Four sources:
|
||||||
|
///
|
||||||
|
/// 1. **This project's own published ports** — a container port that Docker
|
||||||
|
/// already publishes has a host-side path, and the mapping's host port is a
|
||||||
|
/// binding we must not fight over.
|
||||||
|
/// 2. **Every other project's published host ports.** The container names the
|
||||||
|
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||||
|
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||||
|
/// the host end of another project's mapping is reserved: its container end
|
||||||
|
/// is a number inside a different network namespace and means nothing here.
|
||||||
|
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||||
|
/// and the web terminal. All three are off by default and bind on demand, so
|
||||||
|
/// first-come would win: a container that binds container-loopback 4000
|
||||||
|
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||||
|
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||||
|
/// anything on the host dialling 4000 — including *other project
|
||||||
|
/// containers*, which reach the gateway by host address — is talking to the
|
||||||
|
/// squatting container instead. The web terminal is the worst of the three,
|
||||||
|
/// because its access token travels in the URL query. Both the *configured*
|
||||||
|
/// port and the shipped default are reserved: the configured one is what the
|
||||||
|
/// service will bind next, and the default is what it falls back to for a
|
||||||
|
/// fresh profile or a settings file that failed to parse.
|
||||||
|
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||||
|
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||||
|
/// terms.
|
||||||
|
///
|
||||||
|
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||||
|
/// policy is unit-testable without a store, a container or an app handle.
|
||||||
|
fn skipped_ports(
|
||||||
|
project: &crate::models::Project,
|
||||||
|
all_projects: &[crate::models::Project],
|
||||||
|
settings: &crate::models::AppSettings,
|
||||||
|
) -> HashSet<u16> {
|
||||||
let mut skip: HashSet<u16> = project
|
let mut skip: HashSet<u16> = project
|
||||||
.port_mappings
|
.port_mappings
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| [m.container_port, m.host_port])
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Other projects: host end only.
|
||||||
|
skip.extend(
|
||||||
|
all_projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.id != project.id)
|
||||||
|
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||||
|
);
|
||||||
|
|
||||||
|
skip.extend(app_service_host_ports(settings));
|
||||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||||
skip
|
skip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current app settings, or defaults if the state is not reachable.
|
||||||
|
///
|
||||||
|
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||||
|
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||||
|
/// worse than one that falls back to the shipped port numbers — which are what
|
||||||
|
/// the services use anyway until someone changes them.
|
||||||
|
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||||
|
use tauri::Manager;
|
||||||
|
app.try_state::<crate::AppState>()
|
||||||
|
.map(|state| state.settings_store.get())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host ports this app's own sibling services bind, configured value and
|
||||||
|
/// shipped default alike.
|
||||||
|
///
|
||||||
|
/// Read off the settings models rather than restated as literals here: a
|
||||||
|
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||||
|
/// and the failure mode of drift is a reservation that no longer covers the
|
||||||
|
/// service it was written for.
|
||||||
|
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||||
|
use crate::models::{SttSettings, WebTerminalSettings};
|
||||||
|
|
||||||
|
vec![
|
||||||
|
// LiteLLM gateway (`docker/gateway.rs`).
|
||||||
|
settings.gateway.port,
|
||||||
|
crate::models::default_gateway_port(),
|
||||||
|
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||||
|
settings.stt.port,
|
||||||
|
SttSettings::default().port,
|
||||||
|
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||||
|
// its access token is in the URL query.
|
||||||
|
settings.web_terminal.port,
|
||||||
|
WebTerminalSettings::default().port,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Reservations
|
// Reservations
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -586,7 +671,7 @@ async fn emit_status(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{PortMapping, Project, ProjectPath};
|
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
let mut p = Project::new(
|
let mut p = Project::new(
|
||||||
@@ -607,9 +692,14 @@ mod tests {
|
|||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The common case: one project, no siblings, stock settings.
|
||||||
|
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||||
|
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ports_already_published_by_docker_are_skipped() {
|
fn ports_already_published_by_docker_are_skipped() {
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||||
assert!(skip.contains(&3000));
|
assert!(skip.contains(&3000));
|
||||||
// Both ends of an asymmetric mapping are off limits: the container port
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
// is already reachable, and the host port is Docker's binding.
|
// is already reachable, and the host port is Docker's binding.
|
||||||
@@ -619,20 +709,96 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
let settings = AppSettings::default();
|
||||||
|
let project = project_with_mappings(vec![]);
|
||||||
|
let skip = skip_for(&project);
|
||||||
|
|
||||||
|
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||||
|
expected.extend(RESERVED_HOST_PORTS);
|
||||||
|
expected.extend(app_service_host_ports(&settings));
|
||||||
|
assert_eq!(skip, expected);
|
||||||
|
|
||||||
|
// The ranges and the service ports are disjoint, so nothing above is
|
||||||
|
// accidentally counting the same port twice.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
skip.len(),
|
skip.len(),
|
||||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
RESERVED_CONTAINER_PORTS.clone().count()
|
||||||
|
+ RESERVED_HOST_PORTS.clone().count()
|
||||||
|
+ 3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_apps_own_host_services_are_never_taken() {
|
||||||
|
// The bug this guards: the reserved set used to cover only the
|
||||||
|
// browser-view ranges and this project's own mappings, so a container
|
||||||
|
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||||
|
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||||
|
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||||
|
// door before they could bind it.
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
|
|
||||||
|
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||||
|
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||||
|
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||||
|
|
||||||
|
// The shipped defaults, spelled out once so a change to any of them is
|
||||||
|
// a change to this assertion and not a silent narrowing.
|
||||||
|
assert!(skip.contains(&4000));
|
||||||
|
assert!(skip.contains(&9876));
|
||||||
|
assert!(skip.contains(&7681));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.gateway.port = 4321;
|
||||||
|
settings.stt.port = 9000;
|
||||||
|
settings.web_terminal.port = 8443;
|
||||||
|
let project = project_with_mappings(vec![]);
|
||||||
|
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||||
|
|
||||||
|
for port in [4321, 9000, 8443] {
|
||||||
|
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
// The default stays reserved too: it is what the service falls back to
|
||||||
|
// for a fresh profile or an unparseable settings file, so leaving it
|
||||||
|
// open is leaving the same squat available one restart later.
|
||||||
|
for port in [4000, 9876, 7681] {
|
||||||
|
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_projects_published_host_port_is_not_stolen() {
|
||||||
|
// The container names the *host* port. Without this, project A's
|
||||||
|
// container listening on 8080 takes the host 8080 that project B
|
||||||
|
// publishes on — the bridge wins the race whenever B's container is not
|
||||||
|
// running yet.
|
||||||
|
let mine = project_with_mappings(vec![]);
|
||||||
|
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||||
|
theirs.id = format!("{}-other", mine.id);
|
||||||
|
|
||||||
|
let skip = skipped_ports(
|
||||||
|
&mine,
|
||||||
|
&[mine.clone(), theirs.clone()],
|
||||||
|
&AppSettings::default(),
|
||||||
|
);
|
||||||
|
assert!(skip.contains(&8080), "another project's host port");
|
||||||
|
// …but not the other project's *container* port: that number lives in a
|
||||||
|
// different network namespace and means nothing on this host, and
|
||||||
|
// reserving it would refuse a legitimate login callback for no reason.
|
||||||
|
assert!(!skip.contains(&3000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_browser_views_host_ports_are_never_taken() {
|
fn the_browser_views_host_ports_are_never_taken() {
|
||||||
// The bridge binds *host* ports chosen by the container, so without
|
// The bridge binds *host* ports chosen by the container, so without
|
||||||
// this it can take the port the browser-view proxy will want later —
|
// this it can take the port the browser-view proxy will want later —
|
||||||
// that pane binds on demand, so first-come would win.
|
// that pane binds on demand, so first-come would win.
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
for port in RESERVED_HOST_PORTS {
|
for port in RESERVED_HOST_PORTS {
|
||||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||||
}
|
}
|
||||||
@@ -688,14 +854,14 @@ mod tests {
|
|||||||
// Mirroring these would publish an ungated second door to the
|
// Mirroring these would publish an ungated second door to the
|
||||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||||
// token-checking listener.
|
// token-checking listener.
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
let skip = skip_for(&project_with_mappings(vec![]));
|
||||||
for port in RESERVED_CONTAINER_PORTS {
|
for port in RESERVED_CONTAINER_PORTS {
|
||||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||||
}
|
}
|
||||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||||
|
|
||||||
// Reservations coexist with Docker's own published ports.
|
// Reservations coexist with Docker's own published ports.
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||||
assert!(skip.contains(&3000));
|
assert!(skip.contains(&3000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,48 @@
|
|||||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
//! interactive terminal sessions are built on.
|
//! interactive terminal sessions are built on.
|
||||||
|
//!
|
||||||
|
//! ## What the host listener is, and is not
|
||||||
|
//!
|
||||||
|
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||||
|
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||||
|
//! nothing in that chain can be taught to present a token — so there is no path
|
||||||
|
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||||
|
//! the container-side listener. That includes **any web page the user has open**,
|
||||||
|
//! which can port-scan loopback from script.
|
||||||
|
//!
|
||||||
|
//! Two things narrow that, and neither is a substitute for the other:
|
||||||
|
//!
|
||||||
|
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||||
|
//! ports while its container is running.
|
||||||
|
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||||
|
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||||
|
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||||
|
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||||
|
//! what an OAuth redirect is.
|
||||||
|
//!
|
||||||
|
//! The residual risk, stated plainly rather than papered over: a client that
|
||||||
|
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||||
|
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||||
|
//! probe must all still work) but also any browser predating fetch metadata
|
||||||
|
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||||
|
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||||
|
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||||
|
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||||
|
//! port: if the container has something more interesting than a throwaway OAuth
|
||||||
|
//! listener on loopback, a same-machine caller reaches it.
|
||||||
|
//!
|
||||||
|
//! ## Bounds
|
||||||
|
//!
|
||||||
|
//! Every accepted connection costs a `docker exec`, and the number of
|
||||||
|
//! connections is decided by whoever can reach the port. So each forward caps
|
||||||
|
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||||
|
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||||
|
//! exec is created), and drops a connection the container has gone quiet on
|
||||||
|
//! ([`IDLE_TIMEOUT`]).
|
||||||
|
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bollard::container::LogOutput;
|
use bollard::container::LogOutput;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
|||||||
/// only needs to not be pathological.
|
/// only needs to not be pathological.
|
||||||
const PUMP_BUF: usize = 16 * 1024;
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Concurrent connections one forwarded port will carry.
|
||||||
|
///
|
||||||
|
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||||
|
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||||
|
/// connection, occasionally a handful; this is generous for that and still a
|
||||||
|
/// bound the engine will not notice.
|
||||||
|
const MAX_CONNECTIONS: usize = 16;
|
||||||
|
|
||||||
|
/// How long an accepted connection has to send its first byte before it is
|
||||||
|
/// dropped, *without* a `docker exec` ever being created for it.
|
||||||
|
///
|
||||||
|
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||||
|
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||||
|
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||||
|
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||||
|
/// that is the trade, and it is the only protocol shape affected.
|
||||||
|
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long a live connection may go with nothing coming back from the container
|
||||||
|
/// before it is torn down. Generous, because a bridged port is not always a
|
||||||
|
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||||
|
/// exec forever.
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||||
|
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||||
|
const MAX_HEAD: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// How long the rest of a request head has, once the first line has identified
|
||||||
|
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||||
|
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
/// child running.
|
/// child running.
|
||||||
struct AbortOnDrop(JoinHandle<()>);
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
@@ -52,6 +124,15 @@ pub struct PortForward {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub family: PortFamily,
|
pub family: PortFamily,
|
||||||
pub bridged_at: String,
|
pub bridged_at: String,
|
||||||
|
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||||
|
///
|
||||||
|
/// A half-bound forward is the one failure mode that looks like a success:
|
||||||
|
/// the status says the port is bridged, and a browser that resolves
|
||||||
|
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||||
|
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||||
|
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||||
|
/// so, rather than being logged at debug where nobody sees it.
|
||||||
|
pub ipv6_warning: Option<String>,
|
||||||
task: JoinHandle<()>,
|
task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +167,31 @@ impl PortForward {
|
|||||||
// first, so a v4-only host listener would miss those callbacks. This is
|
// first, so a v4-only host listener would miss those callbacks. This is
|
||||||
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||||
// the v4 listener alone still works, so it is not treated as a conflict.
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||||
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
let (v6, ipv6_warning) =
|
||||||
Ok(l) => Some(l),
|
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => (Some(l), None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!(
|
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
// not about whether to say anything: on a host where
|
||||||
|
// `localhost` resolves to `::1` and the client does not
|
||||||
|
// fall back to IPv4, the callback is refused while the
|
||||||
|
// bridge reports itself healthy — a silent failure with no
|
||||||
|
// thread back to this line.
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
|
||||||
|
a client that resolves localhost to ::1 without falling back will not reach it",
|
||||||
port,
|
port,
|
||||||
port,
|
port,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
None
|
(
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||||
|
localhost to ::1 without falling back will not reach this port.",
|
||||||
|
port, e
|
||||||
|
)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +202,7 @@ impl PortForward {
|
|||||||
port,
|
port,
|
||||||
family,
|
family,
|
||||||
bridged_at: chrono::Utc::now().to_rfc3339(),
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
ipv6_warning,
|
||||||
task,
|
task,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,6 +239,22 @@ async fn accept_loop(
|
|||||||
|
|
||||||
match accepted {
|
match accepted {
|
||||||
Ok((stream, peer)) => {
|
Ok((stream, peer)) => {
|
||||||
|
// Reap first, so the cap counts *live* connections rather than
|
||||||
|
// every one this listener has ever accepted.
|
||||||
|
while conns.try_join_next().is_some() {}
|
||||||
|
if conns.len() >= MAX_CONNECTIONS {
|
||||||
|
// Dropping the stream closes it. Better than queueing: the
|
||||||
|
// client side is whatever can dial loopback, so a queue is
|
||||||
|
// just a slower way to run out of execs.
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refusing connection from {} to bridged port {} — \
|
||||||
|
{} concurrent connections already open on it",
|
||||||
|
peer,
|
||||||
|
port,
|
||||||
|
MAX_CONNECTIONS
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
conns.spawn(tunnel_connection(
|
conns.spawn(tunnel_connection(
|
||||||
@@ -170,9 +283,224 @@ async fn accept_optional(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Carry one accepted host connection into the container over `socat`.
|
/// Carry one accepted host connection into the container over `socat`, after
|
||||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
/// deciding it is not a web page reaching into loopback.
|
||||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
///
|
||||||
|
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||||
|
/// reaches the container at all — not even a `docker exec`.
|
||||||
|
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||||
|
let head = match read_leading_bytes(&mut stream).await {
|
||||||
|
Ok(head) => head,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: dropping connection to bridged port {} before forwarding: {}",
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let LeadingBytes::HttpRequest { buffer, head_len } = &head {
|
||||||
|
// Authorize against the head slice only. Parsing past the blank line is
|
||||||
|
// how a request *body* gets read as headers — a cross-site `fetch` with
|
||||||
|
// a `text/plain` body is not preflighted, so it can put any line it
|
||||||
|
// likes in there.
|
||||||
|
let head_text = String::from_utf8_lossy(&buffer[..*head_len]);
|
||||||
|
if web_request_verdict(&head_text) == Verdict::RefuseCrossSite {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refused a cross-site sub-resource request to bridged port {} — \
|
||||||
|
a web page, not a login redirect",
|
||||||
|
port
|
||||||
|
);
|
||||||
|
let _ = refuse(&mut stream).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||||
|
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the first bytes of an accepted connection turned out to be.
|
||||||
|
enum LeadingBytes {
|
||||||
|
/// An HTTP request whose head we have in full. `head_len` is one past the
|
||||||
|
/// blank line; `buffer` may hold pipelined body bytes beyond it.
|
||||||
|
HttpRequest { buffer: Vec<u8>, head_len: usize },
|
||||||
|
/// Not HTTP, or HTTP we gave up on reading. Forwarded verbatim, ungated.
|
||||||
|
Opaque(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeadingBytes {
|
||||||
|
fn into_buffer(self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
LeadingBytes::HttpRequest { buffer, .. } => buffer,
|
||||||
|
LeadingBytes::Opaque(buffer) => buffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read just enough of the connection to classify it, without consuming
|
||||||
|
/// anything the caller cannot replay.
|
||||||
|
///
|
||||||
|
/// Bails out to [`LeadingBytes::Opaque`] the moment the first line proves this
|
||||||
|
/// is not HTTP, so a non-HTTP protocol pays one line of latency and no more.
|
||||||
|
/// The only hard failure is silence: a client that sends nothing within
|
||||||
|
/// [`FIRST_BYTE_TIMEOUT`] is dropped before an exec is spent on it.
|
||||||
|
async fn read_leading_bytes(stream: &mut TcpStream) -> Result<LeadingBytes, String> {
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
let mut deadline = tokio::time::Instant::now() + FIRST_BYTE_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = match tokio::time::timeout_at(deadline, stream.read(&mut chunk)).await {
|
||||||
|
Ok(Ok(0)) if buf.is_empty() => {
|
||||||
|
return Err("closed before sending anything".to_string())
|
||||||
|
}
|
||||||
|
// A half-close after some bytes is legitimate; forward what we have.
|
||||||
|
Ok(Ok(0)) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
Ok(Ok(n)) => n,
|
||||||
|
Ok(Err(e)) => return Err(format!("read failed: {}", e)),
|
||||||
|
Err(_) if buf.is_empty() => {
|
||||||
|
return Err(format!(
|
||||||
|
"sent nothing within {}s",
|
||||||
|
FIRST_BYTE_TIMEOUT.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Bytes arrived but the head never finished. Fail open: this is a
|
||||||
|
// gate on top of the bridge, not the bridge's reason to exist.
|
||||||
|
Err(_) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
};
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
|
||||||
|
// Once the first line is complete we know whether to keep reading.
|
||||||
|
if let Some(eol) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
if !is_http_request_line(&buf[..eol]) {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
deadline = deadline.max(tokio::time::Instant::now() + HEAD_TIMEOUT);
|
||||||
|
} else if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(head_len) = find_head_end(&buf) {
|
||||||
|
return Ok(LeadingBytes::HttpRequest {
|
||||||
|
buffer: buf,
|
||||||
|
head_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a first line looks like `METHOD target HTTP/1.x`.
|
||||||
|
fn is_http_request_line(line: &[u8]) -> bool {
|
||||||
|
let line = String::from_utf8_lossy(line);
|
||||||
|
let line = line.trim_end_matches(['\r', '\n']);
|
||||||
|
let mut parts = line.split(' ');
|
||||||
|
let (Some(method), Some(target), Some(version), None) =
|
||||||
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!method.is_empty()
|
||||||
|
&& method.chars().all(|c| c.is_ascii_uppercase())
|
||||||
|
&& !target.is_empty()
|
||||||
|
&& (version == "HTTP/1.1" || version == "HTTP/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index just past the blank line terminating an HTTP head, if it has arrived.
|
||||||
|
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||||
|
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||||
|
buf.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.map(|i| i + 4)
|
||||||
|
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell a refused caller why, then close. Plain text and `Connection: close` —
|
||||||
|
/// there is no session here to keep alive.
|
||||||
|
async fn refuse(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||||
|
const BODY: &str = "This port is bridged from a container by Triple-C for a sign-in \
|
||||||
|
callback. It is not an API for web pages to call.\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 403 Forbidden\r\n\
|
||||||
|
Content-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\n\
|
||||||
|
Cache-Control: no-store\r\n\
|
||||||
|
Connection: close\r\n\r\n{}",
|
||||||
|
BODY.len(),
|
||||||
|
BODY
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.shutdown().await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The gate — pure, so it can be tested without sockets
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Verdict {
|
||||||
|
/// Forward it. Either it is not a browser, or the browser says this is a
|
||||||
|
/// navigation or a same-origin request.
|
||||||
|
Allow,
|
||||||
|
/// Fetch metadata says a document on another site pulled this in as a
|
||||||
|
/// sub-resource. No login flow looks like that.
|
||||||
|
RefuseCrossSite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether an HTTP request head arriving on a bridged port may be
|
||||||
|
/// forwarded into the container.
|
||||||
|
///
|
||||||
|
/// Deliberately fail-open — see the module docs for exactly what that leaves
|
||||||
|
/// uncovered. The only refusal is the case with no innocent reading:
|
||||||
|
/// `Sec-Fetch-Site` says another site, and `Sec-Fetch-Mode` says this is not a
|
||||||
|
/// navigation. `Sec-Fetch-*` are forbidden header names, so page script cannot
|
||||||
|
/// set or clear them.
|
||||||
|
pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||||
|
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||||
|
// Skip the request line.
|
||||||
|
if lines.next().is_none() {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut site: Option<&str> = None;
|
||||||
|
let mut mode: Option<&str> = None;
|
||||||
|
for line in lines {
|
||||||
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
// A duplicate of either header is header smuggling, not a client.
|
||||||
|
// Refuse rather than pick a winner: last-occurrence-wins is what
|
||||||
|
// turns a smuggling primitive into a bypass.
|
||||||
|
"sec-fetch-site" if site.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-mode" if mode.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-site" => site = Some(value),
|
||||||
|
"sec-fetch-mode" => mode = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(site) = site else {
|
||||||
|
// No fetch metadata: a CLI, `curl`, or a browser old enough not to send
|
||||||
|
// it. Not something this gate can judge.
|
||||||
|
return Verdict::Allow;
|
||||||
|
};
|
||||||
|
if site.eq_ignore_ascii_case("same-origin") || site.eq_ignore_ascii_case("none") {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
// `navigate` is precisely the OAuth redirect: the provider sends the browser
|
||||||
|
// to `http://localhost:<port>/callback`, cross-site, as a document load.
|
||||||
|
// Refusing it would refuse the feature.
|
||||||
|
if mode.is_none_or(|m| m.eq_ignore_ascii_case("navigate")) {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
Verdict::RefuseCrossSite
|
||||||
}
|
}
|
||||||
|
|
||||||
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||||
@@ -225,21 +553,36 @@ pub async fn tunnel_connection_with_prelude(
|
|||||||
}
|
}
|
||||||
let mut buf = vec![0u8; PUMP_BUF];
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
loop {
|
loop {
|
||||||
match host_rx.read(&mut buf).await {
|
// Idle-bounded. Without this a client that connects, sends a
|
||||||
Ok(0) => break,
|
// request and then never speaks or closes holds the exec open for
|
||||||
Ok(n) => {
|
// as long as the container runs.
|
||||||
|
match tokio::time::timeout(IDLE_TIMEOUT, host_rx.read(&mut buf)).await {
|
||||||
|
Ok(Ok(0)) | Err(_) => break,
|
||||||
|
Ok(Ok(n)) => {
|
||||||
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Ok(Err(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Container → host. This direction is authoritative: when the exec's output
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
// stream ends, socat has exited and the connection is over.
|
// stream ends, socat has exited and the connection is over. It is also the
|
||||||
while let Some(chunk) = output.next().await {
|
// one that decides the connection is dead: nothing back from the container
|
||||||
|
// for `IDLE_TIMEOUT` tears the whole thing down, exec included.
|
||||||
|
while let Some(chunk) = match tokio::time::timeout(IDLE_TIMEOUT, output.next()).await {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bridged port {} idle for {}s — closing the tunnel",
|
||||||
|
port,
|
||||||
|
IDLE_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} {
|
||||||
match chunk {
|
match chunk {
|
||||||
// Only stdout is payload. The exec is created with tty = false
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
@@ -268,3 +611,218 @@ pub async fn tunnel_connection_with_prelude(
|
|||||||
// Explicit: stop reading from the host now that the container side is gone.
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
drop(upstream);
|
drop(upstream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn head(lines: &[&str]) -> String {
|
||||||
|
format!("{}\r\n\r\n", lines.join("\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cli_callback_with_no_fetch_metadata_is_forwarded() {
|
||||||
|
// The overwhelmingly common case, and the reason the gate fails open:
|
||||||
|
// `curl`, a CLI's own probe, and anything not a browser send none of
|
||||||
|
// these headers, and none of them can be judged from the wire.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"User-Agent: curl/8.5.0",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_oauth_redirect_is_forwarded_even_though_it_is_cross_site() {
|
||||||
|
// This is the feature. The provider bounces the browser to
|
||||||
|
// `http://localhost:<port>/callback`, which is cross-site and a
|
||||||
|
// navigation. Refusing it would refuse every login the bridge exists
|
||||||
|
// for.
|
||||||
|
for site in ["cross-site", "same-site"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc&state=xyz HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
&format!("Sec-Fetch-Site: {}", site),
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
"Sec-Fetch-Dest: document",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow, "site={}", site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_form_post_callback_is_forwarded() {
|
||||||
|
// `response_mode=form_post` providers POST the callback as a
|
||||||
|
// navigation. Still a navigation, still allowed.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"POST /callback HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Origin: https://login.microsoftonline.com",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cross_site_subresource_from_a_web_page_is_refused() {
|
||||||
|
// The case the gate exists for: a page the user happens to have open
|
||||||
|
// scanning loopback and poking whatever answers.
|
||||||
|
for mode in ["cors", "no-cors", "same-origin", "websocket"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /admin HTTP/1.1",
|
||||||
|
"Host: 127.0.0.1:41733",
|
||||||
|
"Origin: https://evil.example",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
&format!("Sec-Fetch-Mode: {}", mode),
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite, "mode={}", mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_containers_own_same_origin_requests_are_forwarded() {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /style.css HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
"Sec-Fetch-Mode: no-cors",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
// `none` is a user-initiated load — typed URL, bookmark.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET / HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: none",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicated_fetch_metadata_is_refused_rather_than_resolved() {
|
||||||
|
// Last-occurrence-wins is what turns any header-smuggling primitive
|
||||||
|
// into a bypass, and no real client sends two.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /x HTTP/1.1",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: cors",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_head_is_ever_judged() {
|
||||||
|
// A cross-site `text/plain` POST is not preflighted, so its *body* is
|
||||||
|
// fully attacker-chosen. `tunnel_connection` slices at the blank line
|
||||||
|
// before calling in; this pins that the slice is what gets judged.
|
||||||
|
let raw = "POST /x HTTP/1.1\r\n\
|
||||||
|
Sec-Fetch-Site: cross-site\r\n\
|
||||||
|
Sec-Fetch-Mode: cors\r\n\
|
||||||
|
Content-Type: text/plain\r\n\r\n\
|
||||||
|
Sec-Fetch-Site: same-origin\r\n";
|
||||||
|
let head_len = find_head_end(raw.as_bytes()).expect("head terminator");
|
||||||
|
let head = &raw[..head_len];
|
||||||
|
assert!(!head.contains("same-origin"), "the forged line must be past the slice");
|
||||||
|
assert_eq!(web_request_verdict(head), Verdict::RefuseCrossSite);
|
||||||
|
|
||||||
|
// And if the slice were ever got wrong, the duplicate rule is the
|
||||||
|
// backstop: a forged `Sec-Fetch-*` line is by construction a second
|
||||||
|
// copy of one the browser already sent, which is refused outright
|
||||||
|
// rather than resolved in the forgery's favour.
|
||||||
|
assert_eq!(web_request_verdict(raw), Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_http_first_line_is_never_treated_as_a_request() {
|
||||||
|
// Bridged ports are not all HTTP. Anything whose first line is not a
|
||||||
|
// request line is forwarded verbatim rather than parsed.
|
||||||
|
assert!(!is_http_request_line(b"\x16\x03\x01\x02\x00\x01"));
|
||||||
|
assert!(!is_http_request_line(b"*1\r"));
|
||||||
|
assert!(!is_http_request_line(b"SSH-2.0-OpenSSH_9.6"));
|
||||||
|
assert!(!is_http_request_line(b"GET /x HTTP/2.0"));
|
||||||
|
assert!(!is_http_request_line(b"get /x HTTP/1.1"));
|
||||||
|
assert!(is_http_request_line(b"GET /x HTTP/1.1\r"));
|
||||||
|
assert!(is_http_request_line(b"POST /callback?code=a%20b HTTP/1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_end_is_found_for_both_terminators() {
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nBODY"), Some(18));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\nBODY"), Some(16));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_client_that_says_nothing_never_costs_a_container_exec() {
|
||||||
|
// Every accepted connection would otherwise spawn a `docker exec`
|
||||||
|
// immediately, so silence was free for the caller and expensive here.
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let result = accept.await.expect("join");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "silence should not be forwarded");
|
||||||
|
assert!(
|
||||||
|
started.elapsed() >= FIRST_BYTE_TIMEOUT,
|
||||||
|
"should have waited out the first-byte grace period"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_non_http_client_is_classified_from_its_first_line_alone() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
// Verbatim, and without waiting for a head terminator that will never
|
||||||
|
// come — the whole buffer is replayed into the tunnel.
|
||||||
|
assert!(matches!(result, LeadingBytes::Opaque(_)));
|
||||||
|
assert_eq!(result.into_buffer(), b"SSH-2.0-OpenSSH_9.6\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_http_head_is_read_whole_and_replayed_whole() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let raw = b"POST /callback HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ncode";
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(raw).await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
match &result {
|
||||||
|
LeadingBytes::HttpRequest { buffer, head_len } => {
|
||||||
|
assert_eq!(&buffer[*head_len..], b"code", "body must survive the peek");
|
||||||
|
assert!(!buffer[..*head_len].ends_with(b"code"));
|
||||||
|
}
|
||||||
|
LeadingBytes::Opaque(_) => panic!("should have been recognised as HTTP"),
|
||||||
|
}
|
||||||
|
assert_eq!(result.into_buffer(), raw.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
use crate::browser_view::{manager, BrowserViewStatus};
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||||
|
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Turn the pane on or off for a project.
|
/// Turn the pane on or off for a project.
|
||||||
@@ -27,20 +28,7 @@ pub async fn set_browser_view_enabled(
|
|||||||
return Ok(manager().status(&project_id).await);
|
return Ok(manager().status(&project_id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
let project = state
|
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||||
.projects_store
|
|
||||||
.get(&project_id)
|
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
||||||
|
|
||||||
let Some(container_id) = project.container_id.clone() else {
|
|
||||||
return Err("Start the container before opening the browser view.".to_string());
|
|
||||||
};
|
|
||||||
if !crate::docker::container::is_container_running(&container_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Err("Start the container before opening the browser view.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
manager()
|
manager()
|
||||||
.start(
|
.start(
|
||||||
@@ -61,18 +49,298 @@ pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewSt
|
|||||||
/// Probe the container for Playwright without starting anything.
|
/// Probe the container for Playwright without starting anything.
|
||||||
///
|
///
|
||||||
/// Lets the pane say "install this" before the user asks for a view, and lets
|
/// Lets the pane say "install this" before the user asks for a view, and lets
|
||||||
/// them re-check after installing without toggling the feature.
|
/// them re-check after installing without toggling the feature. Read-only: it
|
||||||
|
/// runs one `node -e` and changes nothing.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_browser_view_support(
|
pub async fn check_browser_view_support(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||||
let project = state
|
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||||
.projects_store
|
|
||||||
.get(&project_id)
|
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
||||||
let container_id = project
|
|
||||||
.container_id
|
|
||||||
.ok_or_else(|| "Start the container to check for Playwright.".to_string())?;
|
|
||||||
crate::browser_view::detect::detect(&container_id).await
|
crate::browser_view::detect::detect(&container_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install `playwright` and `@playwright/cli` into the container.
|
||||||
|
///
|
||||||
|
/// **This mutates the container**, so it is a command of its own and is only
|
||||||
|
/// ever reached by the user pressing the button — nothing here runs on tab
|
||||||
|
/// open. Progress streams on `container-progress`; the outcome carries a fresh
|
||||||
|
/// probe so the pane updates itself.
|
||||||
|
///
|
||||||
|
/// Browsers are *not* fetched here. They are hundreds of megabytes and get
|
||||||
|
/// their own action, with the size stated before the click.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_browser_view_support(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<BrowserSetupOutcome, String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
||||||
|
install::install_packages(&app_handle, &project_id, &container_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
||||||
|
/// call `chromium.launch()`) or `chrome` (the Google Chrome channel that
|
||||||
|
/// `@playwright/mcp` asks for) — along with the system libraries it needs, and
|
||||||
|
/// verify that it actually starts.
|
||||||
|
///
|
||||||
|
/// Also a mutation, also user-initiated only.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_browser_view_browser(
|
||||||
|
project_id: String,
|
||||||
|
browser: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<BrowserSetupOutcome, String> {
|
||||||
|
let target = install::BrowserTarget::parse(&browser)?;
|
||||||
|
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
||||||
|
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the view into a window of its own, or raise the one already open.
|
||||||
|
///
|
||||||
|
/// Host-side and window-only: the viewer keeps running exactly as it was, and
|
||||||
|
/// this touches neither the container nor the proxy. Requires a *live* view,
|
||||||
|
/// because a window with nothing behind it is not worth opening — the pane
|
||||||
|
/// only offers the button in that state, and this enforces it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_browser_view_popout(
|
||||||
|
project_id: String,
|
||||||
|
always_on_top: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let status = manager().status(&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."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "Triple-C".to_string());
|
||||||
|
|
||||||
|
popout::open(&app_handle, &project_id, &name, url, always_on_top)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
||||||
|
///
|
||||||
|
/// Propagates a failed close rather than reporting success: the pane restores
|
||||||
|
/// its iframe on success, and doing that with the window still up puts two
|
||||||
|
/// viewers on one browser.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_browser_view_popout(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::close(&app_handle, &project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pop-out is open, and whether it is pinned on top.
|
||||||
|
///
|
||||||
|
/// Read on every pane mount: the window outlives the pane — which is unmounted
|
||||||
|
/// whenever another Project Home sub-tab is selected — so neither fact can be
|
||||||
|
/// carried in component state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_popout_state(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<popout::PopoutState, String> {
|
||||||
|
Ok(popout::state(&app_handle, &project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the pop-out above other windows, so it can be watched while working in
|
||||||
|
/// the main one.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_popout_always_on_top(
|
||||||
|
project_id: String,
|
||||||
|
on_top: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a URL in a browser *inside* the container, published so the pane shows
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// Two uses, one action: an auth URL — where the OAuth callback listener is in
|
||||||
|
/// the container too, so the loop closes without the host being involved at all
|
||||||
|
/// — and a dev server on container loopback, which is how you watch a UI Claude
|
||||||
|
/// is building.
|
||||||
|
///
|
||||||
|
/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this
|
||||||
|
/// can never be talked into opening `file:` on the container's filesystem.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_page_in_container_browser(
|
||||||
|
project_id: String,
|
||||||
|
url: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
show_window: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||||
|
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||||
|
}
|
||||||
|
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Checking the container for Playwright…",
|
||||||
|
);
|
||||||
|
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||||
|
let opened = page::open(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
&container_id,
|
||||||
|
&detection,
|
||||||
|
trimmed,
|
||||||
|
page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// A page nobody can see is not an opened page. Opening one used to leave
|
||||||
|
// 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;
|
||||||
|
if status.state != BrowserViewState::Running {
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Starting the viewer…",
|
||||||
|
);
|
||||||
|
manager()
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
if let Some(url) = status.url.as_deref() {
|
||||||
|
let name = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "Triple-C".to_string());
|
||||||
|
popout::open(&app_handle, &project_id, &name, url, false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the page this opened. The pop-out's "match window" mode calls this on
|
||||||
|
/// every settled resize, so it is deliberately cheap: one control-file write.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_container_page_viewport(
|
||||||
|
project_id: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_container_page_state(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||||
|
return Ok(page::PageState::default());
|
||||||
|
};
|
||||||
|
Ok(page::state(&container_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the page this opened, leaving the view itself running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_container_page(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||||
|
page::close(&container_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the page track the pop-out window's size as it is dragged.
|
||||||
|
///
|
||||||
|
/// Only affects a page **this app opened**: a bound browser admits no second
|
||||||
|
/// client, so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||||
|
/// Turning it on applies the window's current size immediately, so the toggle
|
||||||
|
/// has a visible effect without waiting for a drag.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_match_window(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_match_window(&project_id, enabled);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||||
|
/// pop-out's state — the pane is unmounted whenever another sub-tab is shown.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, String> {
|
||||||
|
Ok(popout::match_window(&project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// to be able to fail somewhere further in with a Docker error instead. The
|
||||||
|
/// `action` is folded into the message so "start the container first" arrives
|
||||||
|
/// attached to what the user was trying to do.
|
||||||
|
async fn running_container(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
project_id: &str,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let Some(container_id) = project.container_id.clone() else {
|
||||||
|
return Err(format!(
|
||||||
|
"This project has no container yet. Start it before {}.",
|
||||||
|
action
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"The container for “{}” isn't running. Start it before {}.",
|
||||||
|
project.name, action
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(container_id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
//! Discovery of published browsers is local-filesystem based (a cache directory
|
//! Discovery of published browsers is local-filesystem based (a cache directory
|
||||||
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
|
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
|
||||||
//! has to run *in the container* next to the browsers rather than on the host.
|
//! has to run *in the container* next to the browsers rather than on the host.
|
||||||
|
//!
|
||||||
|
//! ## Where a Playwright can legitimately be
|
||||||
|
//!
|
||||||
|
//! `node_modules` is not the only answer, and assuming it was is what made this
|
||||||
|
//! probe lie. `claude mcp add … npx @playwright/mcp@latest` — the way most
|
||||||
|
//! people end up with Playwright in the container — installs nothing into any
|
||||||
|
//! `node_modules`: npx unpacks the tree into `~/.npm/_npx/<hash>/node_modules`
|
||||||
|
//! and runs it from there. So that cache is searched too, every entry of it,
|
||||||
|
//! and [`PlaywrightDetection::searched`] echoes back every root actually
|
||||||
|
//! consulted so a "not found" is checkable rather than merely asserted.
|
||||||
|
//!
|
||||||
|
//! Note what that npx route can and cannot do: `@playwright/mcp` bundles a
|
||||||
|
//! `playwright-core` new enough to `bind()`, so it can satisfy points 1 and 2 —
|
||||||
|
//! but it never ships `@playwright/cli`, so it can never satisfy point 3 on its
|
||||||
|
//! own. Any message that offers it as a way to *set up* this pane is sending
|
||||||
|
//! the user down a dead end; see [`PlaywrightDetection::blocker`].
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -39,6 +55,14 @@ pub struct PlaywrightDetection {
|
|||||||
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub playwright_path: Option<String>,
|
pub playwright_path: Option<String>,
|
||||||
|
/// Absolute path of the resolved Playwright's own CLI entry (`cli.js`).
|
||||||
|
///
|
||||||
|
/// Both `playwright` and `playwright-core` declare one, and it is the thing
|
||||||
|
/// that installs browsers and their system libraries. Driving *that* file
|
||||||
|
/// with `node` — rather than whatever `playwright` happens to be on `PATH` —
|
||||||
|
/// is what keeps the browser install pinned to the copy this pane found.
|
||||||
|
#[serde(default)]
|
||||||
|
pub playwright_cli: Option<String>,
|
||||||
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_bind: bool,
|
pub has_bind: bool,
|
||||||
@@ -50,6 +74,51 @@ pub struct PlaywrightDetection {
|
|||||||
/// we can signal.
|
/// we can signal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cli_entry: Option<String>,
|
pub cli_entry: Option<String>,
|
||||||
|
/// Browser bundles present in the Playwright browser cache
|
||||||
|
/// (`~/.cache/ms-playwright`), e.g. `chromium-1200`. `ffmpeg-*` is excluded
|
||||||
|
/// — it is not a browser and its presence must not read as one.
|
||||||
|
///
|
||||||
|
/// Not part of [`PlaywrightDetection::is_usable`]: the viewer serves
|
||||||
|
/// whatever has been published to it, and a browser could in principle be
|
||||||
|
/// remote. It is here because "installed but no browser to drive" is a real
|
||||||
|
/// state the pane has to be able to say out loud.
|
||||||
|
#[serde(default)]
|
||||||
|
pub browsers: Vec<String>,
|
||||||
|
/// Path to Google Chrome, if the `chrome` *channel* is installed.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::browsers`] because it is not in Playwright's cache
|
||||||
|
/// at all — the channel is an apt package. It is tracked because
|
||||||
|
/// `@playwright/mcp` asks for `channel: 'chrome'` specifically, so a
|
||||||
|
/// container with the bundled Chromium and no Chrome is set up for the
|
||||||
|
/// user's own scripts and not for the MCP plugin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chrome_channel: Option<String>,
|
||||||
|
/// The Chromium binary the *resolved* Playwright would launch, asked of the
|
||||||
|
/// build itself rather than derived from the cache listing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable: Option<String>,
|
||||||
|
/// Whether that binary is actually on disk.
|
||||||
|
///
|
||||||
|
/// False with a non-empty [`Self::browsers`] is the revision-skew case: two
|
||||||
|
/// Playwright copies in one container pin different revisions, so the cache
|
||||||
|
/// can be full of browsers and every launch still fail.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable_exists: bool,
|
||||||
|
/// The version a *script's* `require("playwright")` resolves to.
|
||||||
|
///
|
||||||
|
/// Tracked separately from [`Self::playwright_version`] because they are
|
||||||
|
/// routinely different in one directory: `@playwright/cli` pins its own
|
||||||
|
/// `playwright-core`, npm hoists that, and a separately-installed
|
||||||
|
/// `playwright` then nests a second core beside it. The viewer uses one,
|
||||||
|
/// Claude's scripts use the other.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_playwright_version: Option<String>,
|
||||||
|
/// The Chromium that copy would launch, and whether it is there. This is
|
||||||
|
/// the pair that decides whether a script Claude writes actually runs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable_exists: bool,
|
||||||
/// Where the probe looked, echoed back for the "not found" message.
|
/// Where the probe looked, echoed back for the "not found" message.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub searched: Vec<String>,
|
pub searched: Vec<String>,
|
||||||
@@ -63,6 +132,13 @@ impl PlaywrightDetection {
|
|||||||
|
|
||||||
/// A specific, actionable explanation of what is missing. `None` when the
|
/// A specific, actionable explanation of what is missing. `None` when the
|
||||||
/// container is ready.
|
/// container is ready.
|
||||||
|
///
|
||||||
|
/// Every branch names the *package* that is missing and points at this
|
||||||
|
/// pane's install action, because assembling npm commands by hand is the
|
||||||
|
/// thing that went wrong for real users. `@playwright/mcp` is named only in
|
||||||
|
/// the role it actually plays — it binds sessions automatically once
|
||||||
|
/// Playwright is present — and never as a route through setup, because it
|
||||||
|
/// does not ship `@playwright/cli` and so can never make the viewer work.
|
||||||
pub fn blocker(&self) -> Option<String> {
|
pub fn blocker(&self) -> Option<String> {
|
||||||
if self.node_version.is_none() {
|
if self.node_version.is_none() {
|
||||||
return Some(
|
return Some(
|
||||||
@@ -72,41 +148,135 @@ impl PlaywrightDetection {
|
|||||||
}
|
}
|
||||||
if self.playwright_version.is_none() {
|
if self.playwright_version.is_none() {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"Playwright isn't installed in this container. Install it with \
|
"Playwright isn't installed in this container. Two packages are needed: \
|
||||||
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \
|
`playwright` (for the `browser.bind()` live-dashboard API) and \
|
||||||
`await browser.bind('claude')` after launching a browser — or use \
|
`@playwright/cli` (the viewer UI this pane embeds). Use “Set up Playwright” \
|
||||||
`@playwright/mcp`, which binds automatically. Looked in: {}.",
|
below to install both into the container. Installing `@playwright/mcp` on \
|
||||||
|
its own is not enough — it binds sessions for you once Playwright is there, \
|
||||||
|
but it never provides the viewer. Looked in: {}.",
|
||||||
|
self.searched_text()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !self.has_bind {
|
||||||
|
return Some(format!(
|
||||||
|
"Playwright {} is installed{}, but it predates the live-dashboard API \
|
||||||
|
(`browser.bind()`). Use “Set up Playwright” below to upgrade to the latest \
|
||||||
|
`playwright`, then restart the browser Claude is driving.",
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
match self.playwright_path.as_deref() {
|
||||||
|
Some(p) => format!(" at {}", p),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.cli_entry.is_none() {
|
||||||
|
return Some(format!(
|
||||||
|
"Playwright {} is installed, but `@playwright/cli` — the package that serves \
|
||||||
|
the viewer UI — isn't, and nothing else provides it (`@playwright/mcp` does \
|
||||||
|
not). Use “Set up Playwright” below to install it. Looked in: {}.",
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.searched_text()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The revision-skew sentence, for the pane's browser step.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::blocker`] because it does not block the *viewer* —
|
||||||
|
/// the dashboard runs fine; it is the browser that cannot start. Names both
|
||||||
|
/// halves, because "install a browser" over a cache that visibly already
|
||||||
|
/// has one reads as nonsense without them.
|
||||||
|
pub fn skew_message(&self) -> Option<String> {
|
||||||
|
if !self.revision_skew() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Which half is broken changes what the user sees, so say the one that
|
||||||
|
// is. The scripts case is the one that looks like a lie: the pane is
|
||||||
|
// green, the viewer works, and every script Claude writes dies.
|
||||||
|
if self.scripts_cannot_launch() {
|
||||||
|
return Some(format!(
|
||||||
|
"This container has {}, and the viewer works — but `require(\"playwright\")` \
|
||||||
|
resolves Playwright {}, which launches {}. That file isn't there, so every \
|
||||||
|
script Claude writes fails with “Executable doesn't exist”. Two copies ended \
|
||||||
|
up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \
|
||||||
|
separately-installed `playwright` nests a second one beside it. “Set up \
|
||||||
|
Playwright” below reinstalls them as one consistent set.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.script_playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.script_chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"This container has {}, but Playwright {} launches {} — which isn't there, so \
|
||||||
|
every `chromium.launch()` fails with “Executable doesn't exist”. That happens \
|
||||||
|
when two Playwright copies share a container (typically an npx `@playwright/mcp` \
|
||||||
|
alongside this one); each pins its own browser revision. “Install Chromium” below \
|
||||||
|
fetches the revision this build needs — it runs that build's own installer, so it \
|
||||||
|
cannot pick the wrong one again.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether Playwright is present but has no browser at all to drive —
|
||||||
|
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||||
|
/// still runs, it just has nothing to show until a browser is bound.
|
||||||
|
pub fn needs_browser(&self) -> bool {
|
||||||
|
self.playwright_version.is_some()
|
||||||
|
&& self.chrome_channel.is_none()
|
||||||
|
&& (self.browsers.is_empty() || self.revision_skew())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browsers are installed, but not the revision this Playwright launches.
|
||||||
|
///
|
||||||
|
/// The container looks equipped and every `chromium.launch()` fails with
|
||||||
|
/// "Executable doesn't exist". It happens whenever two Playwright copies
|
||||||
|
/// share a container — the npx `@playwright/mcp` one and a `/workspace`
|
||||||
|
/// one — because each pins its own revision and installs into the same
|
||||||
|
/// cache. The install action fixes it: it runs the *resolved* build's own
|
||||||
|
/// CLI, so it fetches exactly the revision that was missing.
|
||||||
|
///
|
||||||
|
/// Requires the probe to have answered: an older container image, or a
|
||||||
|
/// Playwright too broken to `require`, leaves `chromium_executable` unset,
|
||||||
|
/// and "didn't answer" must not read as "skewed".
|
||||||
|
pub fn revision_skew(&self) -> bool {
|
||||||
|
!self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The copy serving the viewer would not find its browser.
|
||||||
|
fn viewer_cannot_launch(&self) -> bool {
|
||||||
|
self.chromium_executable.is_some() && !self.chromium_executable_exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `require("playwright")` — what every script Claude writes uses — would
|
||||||
|
/// not find its browser. Independent of the above, and the more common of
|
||||||
|
/// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and
|
||||||
|
/// a separately-installed `playwright` nests a second one that no browser
|
||||||
|
/// was ever downloaded for.
|
||||||
|
fn scripts_cannot_launch(&self) -> bool {
|
||||||
|
self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The searched roots as prose, so a message never trails off into "Looked
|
||||||
|
/// in: ." when the probe couldn't build a root list at all.
|
||||||
|
fn searched_text(&self) -> String {
|
||||||
if self.searched.is_empty() {
|
if self.searched.is_empty() {
|
||||||
"the container's default module paths".to_string()
|
"the container's default module paths".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.searched.join(", ")
|
self.searched.join(", ")
|
||||||
}
|
}
|
||||||
));
|
|
||||||
}
|
|
||||||
if !self.has_bind {
|
|
||||||
return Some(format!(
|
|
||||||
"Playwright {} is installed, but it predates the live-dashboard API \
|
|
||||||
(`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \
|
|
||||||
the browser Claude is driving.",
|
|
||||||
self.playwright_version.as_deref().unwrap_or("?")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.cli_entry.is_none() {
|
|
||||||
return Some(
|
|
||||||
"Playwright is installed, but the viewer UI package isn't. Install it with \
|
|
||||||
`npm i -D @playwright/cli`, then reopen this tab."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One `node -e` probe, run as `claude` inside the container.
|
/// One `node -e` probe, run as `claude` inside the container.
|
||||||
///
|
///
|
||||||
/// No shell quoting is involved: the script is a single `argv` element. The
|
/// No shell quoting is involved: the script is a single `argv` element. The
|
||||||
/// script finds the global `node_modules` root itself, so a Playwright installed
|
/// script finds the global `node_modules` root and the npx cache itself, so a
|
||||||
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`.
|
/// Playwright installed with `npm i -g`, or merely *run* once through
|
||||||
|
/// `npx @playwright/mcp`, is found as readily as one in
|
||||||
|
/// `/workspace/node_modules`.
|
||||||
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||||
let output = exec_oneshot(
|
let output = exec_oneshot(
|
||||||
container_id,
|
container_id,
|
||||||
@@ -151,15 +321,44 @@ pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, St
|
|||||||
/// produces "detection failed".
|
/// produces "detection failed".
|
||||||
const PROBE: &str = concat!(
|
const PROBE: &str = concat!(
|
||||||
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
||||||
r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#,
|
r#"const out={node_version:process.versions.node,searched:[],has_bind:false,browsers:[]};"#,
|
||||||
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
||||||
// is cheap enough to pay for once per pane open.
|
// is cheap enough to pay for once per pane open.
|
||||||
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
||||||
r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#,
|
r#"const home=process.env.HOME||null;"#,
|
||||||
|
// The npx cache. `npm config get cache` would be authoritative but costs a
|
||||||
|
// second npm start-up; npm exports its resolved config into the
|
||||||
|
// environment of anything it runs, so `npm_config_cache` covers the
|
||||||
|
// overridden case and `~/.npm` covers the default.
|
||||||
|
r#"const cache=process.env.npm_config_cache||(home?path.join(home,".npm"):null);"#,
|
||||||
|
// Every `_npx/<hash>` is a separate tree — `@playwright/mcp` and any other
|
||||||
|
// npx-run package each get their own — so all of them are searched, in a
|
||||||
|
// stable order, and all of them are reported in `searched`.
|
||||||
|
r#"const npx=[];if(cache){try{for(const d of fs.readdirSync(path.join(cache,"_npx")).sort()){"#,
|
||||||
|
r#"const p=path.join(cache,"_npx",d,"node_modules");"#,
|
||||||
|
r#"try{if(fs.statSync(p).isDirectory())npx.push(p);}catch(e){}}}catch(e){}}"#,
|
||||||
|
r#"const roots=[...new Set(["/workspace",process.cwd(),home?path.join(home,"node_modules"):null,g,...npx].filter(Boolean))];"#,
|
||||||
r#"out.searched=roots;"#,
|
r#"out.searched=roots;"#,
|
||||||
r#"const res=(s)=>{for(const r of roots){try{return require.resolve(s,{paths:[r]});}catch(e){}}return null;};"#,
|
r#"const at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#,
|
||||||
r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#,
|
r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#,
|
||||||
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#,
|
// One `bin` reader for both packages: `bin` is a string for some manifests
|
||||||
|
// and an object for others, and getting that wrong on either one loses the
|
||||||
|
// entry point silently.
|
||||||
|
r#"const bin=(m,j)=>{const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});"#,
|
||||||
|
r#"const k=Object.keys(b)[0];return k?path.resolve(path.dirname(m),b[k]):null;};"#,
|
||||||
|
// `playwright-core` is what carries the typings and the browser registry, but
|
||||||
|
// it is frequently *nested*: verified against a real `npm i -g playwright
|
||||||
|
// @playwright/cli`, npm does not hoist for global installs, so the global
|
||||||
|
// root holds `playwright/` and `@playwright/cli/` and no top-level
|
||||||
|
// `playwright-core/`. Resolving only the outer `playwright` would then read
|
||||||
|
// a package that ships no `types/types.d.ts` at all and report a perfectly
|
||||||
|
// current build as "predates browser.bind()". So: hop from the wrapper to
|
||||||
|
// its own `playwright-core`, and only fall back to the wrapper's manifest.
|
||||||
|
r#"let core=res("playwright-core/package.json");"#,
|
||||||
|
r#"if(!core){const pw=res("playwright/package.json");"#,
|
||||||
|
r#"if(pw)core=at("playwright-core/package.json",path.dirname(pw))||pw;}"#,
|
||||||
|
r#"if(core){try{out.playwright_path=core;const j=JSON.parse(fs.readFileSync(core,"utf8"));"#,
|
||||||
|
r#"out.playwright_version=j.version;out.playwright_cli=bin(core,j);}catch(e){}"#,
|
||||||
// `bind`/`unbind` are checked against the shipped type definitions rather
|
// `bind`/`unbind` are checked against the shipped type definitions rather
|
||||||
// than by loading the module: it is a static read, needs no browser, and
|
// than by loading the module: it is a static read, needs no browser, and
|
||||||
// cannot be tripped up by a package that fails to import.
|
// cannot be tripped up by a package that fails to import.
|
||||||
@@ -167,8 +366,40 @@ const PROBE: &str = concat!(
|
|||||||
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
|
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
|
||||||
r#"const cli=res("@playwright/cli/package.json");"#,
|
r#"const cli=res("@playwright/cli/package.json");"#,
|
||||||
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
|
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
|
||||||
r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#,
|
r#"out.cli_entry=bin(cli,j);}catch(e){}}"#,
|
||||||
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}catch(e){}}"#,
|
// Browser bundles. `ffmpeg-*` lives in the same directory and is filtered
|
||||||
|
// out: it is not something that can be driven, and counting it would let
|
||||||
|
// the pane claim a browser is present when none is.
|
||||||
|
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
|
||||||
|
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
|
||||||
|
// What this Playwright would *actually launch*, and whether it is there.
|
||||||
|
//
|
||||||
|
// A cache listing is not the same question. Two Playwright copies in one
|
||||||
|
// container — the npx `@playwright/mcp` one and a `/workspace` one — pin
|
||||||
|
// different browser revisions, and each installs its own. So the cache can
|
||||||
|
// hold `chromium-1237` while the resolved build wants `chromium-1234` and
|
||||||
|
// every `chromium.launch()` dies with "Executable doesn't exist", *while
|
||||||
|
// the pane reports a browser installed*. Asking the build itself sidesteps
|
||||||
|
// revision arithmetic entirely: this is the path a launch would use.
|
||||||
|
r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#,
|
||||||
|
r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#,
|
||||||
|
r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#,
|
||||||
|
r#"if(core){const r=exe(path.dirname(core));"#,
|
||||||
|
r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#,
|
||||||
|
// And separately: what a *script* gets. `require("playwright")` is what
|
||||||
|
// every Playwright example writes, and it resolves the wrapper — which
|
||||||
|
// carries its own nested `playwright-core` whenever npm could not settle on
|
||||||
|
// one version. That copy can want a different browser revision than the one
|
||||||
|
// the viewer's copy installed, so it is asked its own question.
|
||||||
|
r#"try{const w=res("playwright/package.json");"#,
|
||||||
|
r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#,
|
||||||
|
r#"const wc=at("playwright-core/package.json",path.dirname(w));"#,
|
||||||
|
r#"const r=exe(path.dirname(wc||w));"#,
|
||||||
|
r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}catch(e){}"#,
|
||||||
|
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||||
|
// is looked for where apt puts it.
|
||||||
|
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
||||||
|
r#"if(fs.existsSync(p)){out.chrome_channel=p;break;}}}catch(e){}"#,
|
||||||
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
|
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,27 +432,218 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_playwright_is_reported_with_where_we_looked() {
|
fn a_missing_playwright_names_both_packages_and_where_we_looked() {
|
||||||
let d = parse_probe_output(&payload(
|
let d = parse_probe_output(&payload(
|
||||||
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#,
|
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/a1/node_modules"]}"#,
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!d.is_usable());
|
assert!(!d.is_usable());
|
||||||
let msg = d.blocker().unwrap();
|
let msg = d.blocker().unwrap();
|
||||||
assert!(msg.contains("npm i -D playwright"), "{}", msg);
|
// The two packages that actually have to be there, by name.
|
||||||
|
assert!(msg.contains("`playwright`"), "{}", msg);
|
||||||
|
assert!(msg.contains("`@playwright/cli`"), "{}", msg);
|
||||||
assert!(msg.contains("browser.bind"), "{}", msg);
|
assert!(msg.contains("browser.bind"), "{}", msg);
|
||||||
|
// Every root consulted, including the npx cache, so the claim is checkable.
|
||||||
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
|
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
|
||||||
|
assert!(msg.contains("/home/claude/.npm/_npx/a1/node_modules"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_message_offers_playwright_mcp_as_a_way_through_setup() {
|
||||||
|
// It bundles a playwright-core new enough to bind, but never ships the
|
||||||
|
// viewer — so proposing it as an install route is a dead end, which is
|
||||||
|
// exactly what a user hit. It may only be named for what it does do.
|
||||||
|
for json in [
|
||||||
|
r#"{"node_version":"22.11.0","searched":["/workspace"]}"#,
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false}"#,
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
|
||||||
|
] {
|
||||||
|
let msg = parse_probe_output(&payload(json)).unwrap().blocker().unwrap();
|
||||||
|
let offers_install = msg.contains("install `@playwright/mcp`")
|
||||||
|
|| msg.contains("or use `@playwright/mcp`")
|
||||||
|
|| msg.contains("npm i -D @playwright/mcp")
|
||||||
|
|| msg.contains("npm i -g @playwright/mcp");
|
||||||
|
assert!(!offers_install, "{}", msg);
|
||||||
|
// And every message points at the one action that does work.
|
||||||
|
assert!(msg.contains("Set up Playwright"), "{}", msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
||||||
let d = parse_probe_output(&payload(
|
let d = parse_probe_output(&payload(
|
||||||
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#,
|
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","playwright_path":"/workspace/node_modules/playwright/package.json","has_bind":false,"cli_entry":"/x/cli.js"}"#,
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg = d.blocker().unwrap();
|
let msg = d.blocker().unwrap();
|
||||||
assert!(msg.contains("1.44.0"), "{}", msg);
|
assert!(msg.contains("1.44.0"), "{}", msg);
|
||||||
assert!(msg.contains("playwright@latest"), "{}", msg);
|
assert!(msg.contains("/workspace/node_modules/playwright"), "{}", msg);
|
||||||
|
assert!(msg.contains("Set up Playwright"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_npx_cached_playwright_counts_as_installed() {
|
||||||
|
// What `claude mcp add … npx @playwright/mcp@latest` leaves behind: a
|
||||||
|
// real playwright-core, in no `node_modules` the old probe looked at.
|
||||||
|
// It satisfies bind — and nothing else, because npx never brings the
|
||||||
|
// viewer with it.
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","#,
|
||||||
|
r#""playwright_path":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/package.json","#,
|
||||||
|
r#""playwright_cli":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js","#,
|
||||||
|
r#""has_bind":true,"#,
|
||||||
|
r#""searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/9f/node_modules"]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(d.playwright_version.as_deref(), Some("1.62.1"));
|
||||||
|
assert!(d.has_bind);
|
||||||
|
assert_eq!(
|
||||||
|
d.playwright_cli.as_deref(),
|
||||||
|
Some("/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js")
|
||||||
|
);
|
||||||
|
// Still not usable, and the message says why: the viewer is missing.
|
||||||
|
assert!(!d.is_usable());
|
||||||
|
let msg = d.blocker().unwrap();
|
||||||
|
assert!(msg.contains("@playwright/cli"), "{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_searches_the_npx_cache_as_well_as_the_module_roots() {
|
||||||
|
// The roots are built inside the probe, so this is the only place the
|
||||||
|
// set can be asserted without a container. Each fragment is load-bearing:
|
||||||
|
// dropping any one of them is how an install becomes invisible.
|
||||||
|
assert!(PROBE.contains(r#""/workspace""#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("process.cwd()"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains(r#"path.join(home,"node_modules")"#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("npm root -g"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains(r#"path.join(cache,"_npx")"#), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("npm_config_cache"), "{}", PROBE);
|
||||||
|
// Every one of them, not just the first hit, and all of them reported.
|
||||||
|
assert!(PROBE.contains("...npx"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("out.searched=roots"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partial_tree_still_answers_rather_than_failing() {
|
||||||
|
// Playwright resolved, but its manifest unreadable and no viewer: the
|
||||||
|
// probe's guards must still produce a parseable payload carrying what
|
||||||
|
// it did learn, because that is what the message is built from.
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
r#"{"node_version":"22.11.0","has_bind":false,"searched":["/workspace"],"browsers":["chromium-1200"]}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
|
||||||
|
assert_eq!(d.browsers, vec!["chromium-1200".to_string()]);
|
||||||
|
assert!(d.blocker().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_playwright_with_no_browser_bundle_is_flagged_without_blocking() {
|
||||||
|
let d = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
// Serving the viewer is possible; there is just nothing to drive yet.
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
assert!(d.needs_browser());
|
||||||
|
|
||||||
|
let with_browser = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1200"]}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!with_browser.needs_browser());
|
||||||
|
|
||||||
|
// The Chrome channel counts too — it is an apt package rather than a
|
||||||
|
// Playwright download, so it never appears in `browsers`, and
|
||||||
|
// `@playwright/mcp` is the caller that asks for it.
|
||||||
|
let chrome_only = parse_probe_output(&payload(
|
||||||
|
concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[],"#,
|
||||||
|
r#""chrome_channel":"/usr/bin/google-chrome-stable"}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!chrome_only.needs_browser());
|
||||||
|
assert_eq!(
|
||||||
|
chrome_only.chrome_channel.as_deref(),
|
||||||
|
Some("/usr/bin/google-chrome-stable")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_looks_for_the_chrome_channel_where_apt_puts_it() {
|
||||||
|
assert!(PROBE.contains("google-chrome-stable"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_asks_playwright_what_it_would_launch() {
|
||||||
|
// Not derived from the cache listing — asked of the build, because the
|
||||||
|
// cache can hold a browser this build will never launch.
|
||||||
|
assert!(PROBE.contains("executablePath"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container carrying browsers from a *different* Playwright copy.
|
||||||
|
fn skewed() -> PlaywrightDetection {
|
||||||
|
parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"],"#,
|
||||||
|
r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#,
|
||||||
|
r#""chromium_executable_exists":false}"#,
|
||||||
|
)))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() {
|
||||||
|
let d = skewed();
|
||||||
|
// The viewer still serves — it is the browser that cannot start.
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
assert!(d.revision_skew());
|
||||||
|
assert!(d.needs_browser(), "a browser that cannot launch is not a browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_skew_message_names_both_revisions_and_the_way_out() {
|
||||||
|
let msg = skewed().skew_message().unwrap();
|
||||||
|
assert!(msg.contains("chromium-1237"), "{}", msg); // what is there
|
||||||
|
assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants
|
||||||
|
assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_chrome_channel_covers_a_skewed_cache() {
|
||||||
|
// The channel is an apt binary at a fixed path, so a revision mismatch
|
||||||
|
// cannot affect it: there is still something to drive.
|
||||||
|
let mut d = skewed();
|
||||||
|
d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_probe_that_could_not_answer_is_not_reported_as_skew() {
|
||||||
|
// Older container, or a Playwright too broken to `require`: unset is
|
||||||
|
// "unknown", and unknown must never render as "your browsers are wrong".
|
||||||
|
let d = parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"]}"#,
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!d.revision_skew());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
assert_eq!(d.skew_message(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -252,6 +674,19 @@ mod tests {
|
|||||||
assert!(err.contains("no output"), "{}", err);
|
assert!(err.contains("no output"), "{}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_reads_bind_from_the_nested_core_of_a_wrapper_install() {
|
||||||
|
// `npm i -g playwright` leaves `playwright-core` under
|
||||||
|
// `playwright/node_modules`, and the wrapper ships no
|
||||||
|
// `types/types.d.ts` — so without this hop a current build reports
|
||||||
|
// `has_bind: false`. Verified against a real global install.
|
||||||
|
assert!(
|
||||||
|
PROBE.contains(r#"at("playwright-core/package.json",path.dirname(pw))"#),
|
||||||
|
"{}",
|
||||||
|
PROBE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
|
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
|
||||||
// It is passed straight to `node -e`; a stray single quote would only
|
// It is passed straight to `node -e`; a stray single quote would only
|
||||||
|
|||||||
@@ -63,6 +63,9 @@
|
|||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
|
pub mod install;
|
||||||
|
pub mod page;
|
||||||
|
pub mod popout;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -301,21 +304,7 @@ impl BrowserViewManager {
|
|||||||
// a container with no dashboard makes this a no-op.
|
// a container with no dashboard makes this a no-op.
|
||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
let container_port = pick_viewer_port(&container_id).await?;
|
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||||
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
|
||||||
|
|
||||||
// Wait for it to actually answer, and learn the entry URL while we're
|
|
||||||
// there — see `probe_entry_path` for why that matters. This, not the
|
|
||||||
// launcher's stdout, is the readiness signal: verified that the
|
|
||||||
// "Listening on …" line is printed only on the very first start.
|
|
||||||
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
|
||||||
Ok(path) => path,
|
|
||||||
Err(e) => {
|
|
||||||
let log = read_viewer_log(&container_id).await;
|
|
||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
|
||||||
return Err(explain_start_failure(&e, &log));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let token = generate_token();
|
let token = generate_token();
|
||||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||||
@@ -466,12 +455,33 @@ async fn supervise(
|
|||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
// Deregister, unless a newer session has already taken this project's slot.
|
// Deregister, unless a newer session has already taken this project's slot.
|
||||||
{
|
let superseded = {
|
||||||
let mut map = sessions.lock().await;
|
let mut map = sessions.lock().await;
|
||||||
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
|
match map.get(&project_id) {
|
||||||
|
Some(session) if session.epoch == epoch => {
|
||||||
map.remove(&project_id);
|
map.remove(&project_id);
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
// Someone else owns this project now: `stop` removes the session
|
||||||
|
// from the map *before* awaiting this task, and teardown below is
|
||||||
|
// seconds of Docker work, so a restart in that window is ordinary.
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything past here speaks for the project as a whole, so a superseded
|
||||||
|
// supervisor must say nothing: closing the pop-out would destroy the *new*
|
||||||
|
// session's window, and the off-status would report a running view as
|
||||||
|
// stopped.
|
||||||
|
if superseded {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pop-out outlives the tab, so nothing else would take it down: the
|
||||||
|
// window would sit there showing a frozen last frame of a viewer that no
|
||||||
|
// 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;
|
let enabled = manager().is_enabled(&project_id).await;
|
||||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||||
@@ -577,12 +587,91 @@ async fn read_viewer_log(container_id: &str) -> String {
|
|||||||
// Readiness, ports, URLs
|
// Readiness, ports, URLs
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
/// How many free ports a start will try before giving up.
|
||||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
///
|
||||||
|
/// More than one because port choice is a check-then-bind: the free list comes
|
||||||
|
/// from a snapshot of the container's `/proc/net/tcp`, and anything in the
|
||||||
|
/// container may bind the port we picked before the dashboard gets to it. One
|
||||||
|
/// retry per lost race is the recovery; the cap is what stops a container that
|
||||||
|
/// binds every candidate from holding a start open for
|
||||||
|
/// `MAX_PORT_ATTEMPTS × READY_TIMEOUT`.
|
||||||
|
const MAX_PORT_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
|
/// Get a viewer listening inside the container and return the port it is on
|
||||||
|
/// plus the path the pane should load.
|
||||||
|
///
|
||||||
|
/// ## The check/bind race
|
||||||
|
///
|
||||||
|
/// [`pick_viewer_port`] reads a *snapshot* of container listeners; the dashboard
|
||||||
|
/// binds some milliseconds later. Nothing here can make that atomic — the bind
|
||||||
|
/// happens in another process, in another namespace, and `playwright-cli show`
|
||||||
|
/// reports the port it actually took only on a first-ever start (see
|
||||||
|
/// [`wait_until_ready`]). What is possible is to stop treating the first
|
||||||
|
/// candidate as the only one: if the port we picked does not come up, walk to
|
||||||
|
/// the next free candidate rather than failing the whole start.
|
||||||
|
///
|
||||||
|
/// Residual, stated rather than glossed: a container-side process that binds the
|
||||||
|
/// candidate port *and answers HTTP* is indistinguishable from the dashboard at
|
||||||
|
/// this layer, and the pane would then front it. What contains that is
|
||||||
|
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||||
|
/// iframe is sandboxed — not this function.
|
||||||
|
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||||
|
let mut tried: Vec<u16> = Vec::new();
|
||||||
|
let mut last: Option<String> = None;
|
||||||
|
|
||||||
|
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||||
|
// Re-read the listener snapshot each attempt: the port that was free a
|
||||||
|
// moment ago is exactly the one we may have just lost.
|
||||||
|
let port = match pick_viewer_port(container_id, &tried).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
// Report why the *attempts* failed, not just "nothing free":
|
||||||
|
// the exhausted range is the symptom, the last start failure is
|
||||||
|
// the thing the user can act on.
|
||||||
|
return Err(match last {
|
||||||
|
Some(prev) => format!("{} ({})", e, prev),
|
||||||
|
None => e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tried.push(port);
|
||||||
|
|
||||||
|
launch_viewer(container_id, cli_entry, port).await?;
|
||||||
|
|
||||||
|
// Wait for it to actually answer, and learn the entry URL while we're
|
||||||
|
// there — see `probe_entry_path` for why that matters. This, not the
|
||||||
|
// launcher's stdout, is the readiness signal: verified that the
|
||||||
|
// "Listening on …" line is printed only on the very first start.
|
||||||
|
match wait_until_ready(container_id, port).await {
|
||||||
|
Ok(path) => return Ok((port, path)),
|
||||||
|
Err(e) => {
|
||||||
|
let log = read_viewer_log(container_id).await;
|
||||||
|
// Always kill before retrying: the dashboard is a singleton, so
|
||||||
|
// a launcher that came up on some *other* port would otherwise
|
||||||
|
// make every further attempt a no-op that silently ignores the
|
||||||
|
// port we asked for.
|
||||||
|
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||||
|
last = Some(explain_start_failure(&e, &log));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last.unwrap_or_else(|| "The Playwright viewer did not start.".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||||
|
/// and that this start has not already tried.
|
||||||
|
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||||
let text = exec_oneshot(
|
let text = exec_oneshot(
|
||||||
container_id,
|
container_id,
|
||||||
vec![
|
vec![
|
||||||
"cat".to_string(),
|
// Absolute path, deliberately, for the same reason the auth bridge
|
||||||
|
// uses one: `container/Dockerfile` puts a container-writable
|
||||||
|
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||||
|
// can rebind to a shim. A shimmed listener list is a shimmed answer
|
||||||
|
// to "which port is free" — i.e. the container choosing which port
|
||||||
|
// the viewer, and therefore the host-side proxy, ends up on.
|
||||||
|
"/usr/bin/cat".to_string(),
|
||||||
"/proc/net/tcp".to_string(),
|
"/proc/net/tcp".to_string(),
|
||||||
"/proc/net/tcp6".to_string(),
|
"/proc/net/tcp6".to_string(),
|
||||||
],
|
],
|
||||||
@@ -592,7 +681,7 @@ async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
|||||||
let taken = proc_net::parse_loopback_listeners(&text);
|
let taken = proc_net::parse_loopback_listeners(&text);
|
||||||
VIEWER_PORTS
|
VIEWER_PORTS
|
||||||
.clone()
|
.clone()
|
||||||
.find(|p| !taken.contains_key(p))
|
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! Open a page in the container's browser, and resize it while it runs.
|
||||||
|
//!
|
||||||
|
//! The pane [watches](super) browsers something else published. This opens one:
|
||||||
|
//! the user hands it a URL, it launches a browser inside the container,
|
||||||
|
//! publishes it with `browser.bind()` so the pane picks it up, and holds the
|
||||||
|
//! handle so the page can be navigated and **resized** afterwards.
|
||||||
|
//!
|
||||||
|
//! ## Why the handle has to be held
|
||||||
|
//!
|
||||||
|
//! Verified against a real bound browser: a second client cannot join one.
|
||||||
|
//! `chromium.connect()` against the published endpoint times out in every URL
|
||||||
|
//! form — the descriptor's socket speaks the dashboard's own transport, not the
|
||||||
|
//! public connect protocol. So whoever launches the browser is the only process
|
||||||
|
//! that can ever drive it. That is the whole reason this helper is a resident
|
||||||
|
//! process rather than a one-shot `node -e` that exits.
|
||||||
|
//!
|
||||||
|
//! It also draws the line for the feature: pages *this* opens can be resized
|
||||||
|
//! live; a browser `@playwright/mcp` launched can only be watched, and its size
|
||||||
|
//! is whatever `--viewport-size` it was given.
|
||||||
|
//!
|
||||||
|
//! ## Control channel
|
||||||
|
//!
|
||||||
|
//! A JSON file in `/tmp`, polled by the helper. No port, no second listener, no
|
||||||
|
//! addition to the proxy's attack surface — and it composes with the one exec
|
||||||
|
//! path this codebase already has. Writes go through `node -e` rather than
|
||||||
|
//! shell redirection so a URL never touches a shell.
|
||||||
|
//!
|
||||||
|
//! ## Viewport, and why it is the interesting part
|
||||||
|
//!
|
||||||
|
//! `page.setViewportSize()` genuinely reflows: measured on a page carrying a
|
||||||
|
//! `@media (max-width: 900px)` rule, the rule fires at 800×600 and clears at
|
||||||
|
//! 1440×900. Resizing the *window* the pane lives in does nothing of the sort —
|
||||||
|
//! the viewer is a CDP screencast, so a bigger window is the same pixels drawn
|
||||||
|
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::commands::project_commands::emit_progress;
|
||||||
|
use crate::docker::exec::exec_oneshot_as;
|
||||||
|
|
||||||
|
use super::detect::PlaywrightDetection;
|
||||||
|
|
||||||
|
/// Control file the helper polls, and the state file it writes back.
|
||||||
|
const CONTROL_PATH: &str = "/tmp/triple-c-page-control.json";
|
||||||
|
const STATE_PATH: &str = "/tmp/triple-c-page-state.json";
|
||||||
|
/// Where the detached helper's own output goes, so a failed start has a trail.
|
||||||
|
const HELPER_LOG: &str = "/tmp/triple-c-page.log";
|
||||||
|
|
||||||
|
/// How long to wait for the helper to report that the page is up.
|
||||||
|
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||||
|
/// Navigating a browser that is already up. One page load, not a cold start.
|
||||||
|
const REUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35);
|
||||||
|
const READY_POLL: std::time::Duration = std::time::Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// A viewport, in CSS pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Viewport {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
/// Clamped to something a browser will accept. A window dragged to nothing
|
||||||
|
/// must not ask Chromium for a zero-width page.
|
||||||
|
pub fn sane(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
width: width.clamp(200, 7680),
|
||||||
|
height: height.clamp(200, 4320),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the helper reports about itself.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct PageState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ready: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewport: Option<Viewport>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `url` in a freshly launched, bound browser.
|
||||||
|
///
|
||||||
|
/// Replaces any page this opened before: one helper per container, because the
|
||||||
|
/// pane shows one browser and a second would just compete for the pane.
|
||||||
|
pub async fn open(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
container_id: &str,
|
||||||
|
detection: &PlaywrightDetection,
|
||||||
|
url: &str,
|
||||||
|
viewport: Viewport,
|
||||||
|
) -> Result<PageState, String> {
|
||||||
|
let core = detection.playwright_path.as_deref().ok_or_else(|| {
|
||||||
|
"Playwright isn't installed in this container — set it up from the Browser tab first."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
// The directory of the resolved manifest is what `require()` wants.
|
||||||
|
let core_dir = core.trim_end_matches("/package.json");
|
||||||
|
|
||||||
|
// The executable is passed explicitly rather than left to Playwright's
|
||||||
|
// revision lookup: a container can hold browsers a given copy will not
|
||||||
|
// launch (see `detect::revision_skew`), and this is the one place we know
|
||||||
|
// which binary is actually on disk.
|
||||||
|
let executable = detection
|
||||||
|
.chromium_executable
|
||||||
|
.as_deref()
|
||||||
|
.filter(|_| detection.chromium_executable_exists);
|
||||||
|
|
||||||
|
// Reuse a helper that is already up. Relaunching would throw away the
|
||||||
|
// browser's cookies and storage — which for the auth case means signing in
|
||||||
|
// again to reach the second page, having just signed in on the first.
|
||||||
|
if state(container_id).await.ready {
|
||||||
|
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||||
|
set_viewport(container_id, viewport).await?;
|
||||||
|
navigate(container_id, url).await?;
|
||||||
|
if let Some(state) = wait_for_url(container_id, url).await {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
// It stopped answering; fall through and start a fresh one.
|
||||||
|
}
|
||||||
|
|
||||||
|
close(container_id).await;
|
||||||
|
// Cold start: a browser launch plus a page load, which is the several
|
||||||
|
// seconds the user would otherwise spend wondering whether the click
|
||||||
|
// registered.
|
||||||
|
emit_progress(app, project_id, "Launching a browser in the container…");
|
||||||
|
|
||||||
|
let config = serde_json::json!({
|
||||||
|
"core": core_dir,
|
||||||
|
"executable": executable,
|
||||||
|
"url": url,
|
||||||
|
"viewport": viewport,
|
||||||
|
"control": CONTROL_PATH,
|
||||||
|
"state": STATE_PATH,
|
||||||
|
});
|
||||||
|
let script = format!("const CFG={};{}", config, HELPER);
|
||||||
|
|
||||||
|
// Detached, for the same reason the viewer is: the process has to outlive
|
||||||
|
// the exec that started it, or the page closes the moment we return.
|
||||||
|
let launcher = format!(
|
||||||
|
"cd /workspace 2>/dev/null || true; rm -f {} {}; nohup node -e {} >{} 2>&1 &",
|
||||||
|
STATE_PATH,
|
||||||
|
CONTROL_PATH,
|
||||||
|
shell_quote(&script),
|
||||||
|
HELPER_LOG
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||||
|
|
||||||
|
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||||
|
wait_until_ready(container_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||||
|
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||||
|
write_control(
|
||||||
|
container_id,
|
||||||
|
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate the open page without relaunching the browser.
|
||||||
|
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||||
|
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||||
|
/// normal case, and the caller is usually about to start one anyway.
|
||||||
|
pub async fn close(container_id: &str) {
|
||||||
|
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state, or a default when no helper has ever run here.
|
||||||
|
pub async fn state(container_id: &str) -> PageState {
|
||||||
|
let script = format!(
|
||||||
|
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||||
|
STATE_PATH
|
||||||
|
);
|
||||||
|
let Ok((out, _)) = exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return PageState::default();
|
||||||
|
};
|
||||||
|
serde_json::from_str(out.trim()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||||
|
/// is never interpreted by `sh`.
|
||||||
|
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||||
|
let script = format!(
|
||||||
|
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||||
|
CONTROL_PATH
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||||
|
///
|
||||||
|
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||||
|
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||||
|
/// a fresh helper rather than reporting a page that isn't there.
|
||||||
|
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||||
|
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if state.ready && state.url.as_deref() == Some(url) {
|
||||||
|
return Some(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the state file until the helper says the page is up, or says why not.
|
||||||
|
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||||
|
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if let Some(error) = state.error.clone() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if state.ready {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"The browser didn't come up within {}s. Its log is at {} inside the container.",
|
||||||
|
READY_TIMEOUT.as_secs(),
|
||||||
|
HELPER_LOG
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-quote for `sh`, the same way [`super`] does for the viewer's paths.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resident helper, appended to a `const CFG={…};` prelude.
|
||||||
|
///
|
||||||
|
/// Deliberately one string passed as a single `argv` element — no shell parsing
|
||||||
|
/// of any part of it, exactly like `detect`'s probe. It launches, binds, and
|
||||||
|
/// then polls the control file; every failure path writes the state file, so a
|
||||||
|
/// helper that dies during startup is reported rather than waited out.
|
||||||
|
const HELPER: &str = concat!(
|
||||||
|
r#"const fs=require('fs');"#,
|
||||||
|
r#"const {chromium}=require(CFG.core);"#,
|
||||||
|
r#"const write=(o)=>{try{fs.writeFileSync(CFG.state,JSON.stringify(o));}catch(e){}};"#,
|
||||||
|
r#"const fail=(e)=>{write({ready:false,error:String(e&&e.message||e)});process.exit(1);};"#,
|
||||||
|
r#"process.on('unhandledRejection',fail);"#,
|
||||||
|
r#"(async()=>{"#,
|
||||||
|
// `chromiumSandbox:false` because the container has no user namespaces to
|
||||||
|
// give Chromium; headless because there is no display, which is also the
|
||||||
|
// only mode the dashboard can screencast anyway.
|
||||||
|
r#"const opts={headless:true,chromiumSandbox:false};"#,
|
||||||
|
r#"if(CFG.executable)opts.executablePath=CFG.executable;"#,
|
||||||
|
r#"const browser=await chromium.launch(opts);"#,
|
||||||
|
r#"const ctx=await browser.newContext({viewport:CFG.viewport});"#,
|
||||||
|
r#"const page=await ctx.newPage();"#,
|
||||||
|
// Bind before navigating: the pane should show the page loading rather than
|
||||||
|
// appearing once it is done.
|
||||||
|
r#"await browser.bind('claude',{metadata:{source:'triple-c'}});"#,
|
||||||
|
r#"let current=CFG.url,viewport=CFG.viewport;"#,
|
||||||
|
r#"const report=()=>write({ready:true,url:current,viewport});"#,
|
||||||
|
r#"try{await page.goto(CFG.url,{waitUntil:'domcontentloaded',timeout:30000});}catch(e){}"#,
|
||||||
|
r#"report();"#,
|
||||||
|
// The control loop. A poll, not a watcher: `fs.watch` misses writes on some
|
||||||
|
// filesystems and this costs nothing at 4 Hz.
|
||||||
|
r#"setInterval(async()=>{let c;try{c=JSON.parse(fs.readFileSync(CFG.control,'utf8'));}catch(e){return;}"#,
|
||||||
|
r#"try{fs.unlinkSync(CFG.control);}catch(e){}"#,
|
||||||
|
r#"if(c.close){await browser.close().catch(()=>{});write({ready:false});process.exit(0);}"#,
|
||||||
|
r#"if(c.viewport){viewport=c.viewport;await page.setViewportSize(c.viewport).catch(()=>{});}"#,
|
||||||
|
r#"if(c.url&&c.url!==current){current=c.url;await page.goto(c.url,{waitUntil:'domcontentloaded',timeout:30000}).catch(()=>{});}"#,
|
||||||
|
r#"report();},250);"#,
|
||||||
|
// A browser that dies (crash, or the user closing the last page) must not
|
||||||
|
// leave a helper claiming a live page.
|
||||||
|
r#"browser.on('disconnected',()=>{write({ready:false});process.exit(0);});"#,
|
||||||
|
r#"})().catch(fail);"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_is_one_argv_element_with_no_shell_hazards() {
|
||||||
|
// Same rule as the detect probe: it is passed as a single argument, so
|
||||||
|
// it must contain neither a newline nor a single quote that would end
|
||||||
|
// the quoting `open` wraps it in.
|
||||||
|
assert!(!HELPER.contains('\n'), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("chromium.launch"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_binds_so_the_pane_can_see_the_page() {
|
||||||
|
// Without this the page opens and the pane shows nothing — the whole
|
||||||
|
// feature hinges on the browser being published.
|
||||||
|
assert!(HELPER.contains("browser.bind('claude'"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_reports_startup_failures_instead_of_hanging() {
|
||||||
|
// `wait_until_ready` polls the state file; a helper that dies silently
|
||||||
|
// would turn every failure into a 45-second timeout.
|
||||||
|
assert!(HELPER.contains("unhandledRejection"), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("error:String"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_viewport_is_clamped_to_something_a_browser_accepts() {
|
||||||
|
assert_eq!(Viewport::sane(0, 0), Viewport { width: 200, height: 200 });
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(99_999, 99_999),
|
||||||
|
Viewport { width: 7680, height: 4320 }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(1440, 900),
|
||||||
|
Viewport { width: 1440, height: 900 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_is_never_parsed_by_a_shell() {
|
||||||
|
// The launcher runs through `sh -c`, so the script is quoted with the
|
||||||
|
// POSIX close-escape-reopen form: the embedded quote becomes `'\''`,
|
||||||
|
// which leaves the `;rm` inside the string rather than starting a new
|
||||||
|
// command. (A naive "the output must not contain ';rm'" check fails
|
||||||
|
// here and would be wrong — that substring is *inside* the quoting.)
|
||||||
|
assert_eq!(
|
||||||
|
shell_quote("http://x/?a=1&b=2';rm -rf /"),
|
||||||
|
r"'http://x/?a=1&b=2'\'';rm -rf /'"
|
||||||
|
);
|
||||||
|
// The control channel doesn't go near a shell at all: the JSON travels
|
||||||
|
// as an argv element to `node`.
|
||||||
|
assert!(!HELPER.contains("exec("), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_defaults_to_not_ready_rather_than_failing() {
|
||||||
|
// An empty/absent state file is the normal case before anything runs.
|
||||||
|
let s: PageState = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(!s.ready);
|
||||||
|
assert!(s.error.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! The browser view in a window of its own.
|
||||||
|
//!
|
||||||
|
//! Watching a browser and working in a terminal are the same task done at the
|
||||||
|
//! same time, and a tab can only be one of them. So the pane can be detached
|
||||||
|
//! into a second OS window — put on the other monitor, or pinned on top of
|
||||||
|
//! whatever else is in front.
|
||||||
|
//!
|
||||||
|
//! ## Why this is a native window and not a second iframe
|
||||||
|
//!
|
||||||
|
//! The window loads the *same* token-bearing loopback URL the pane's iframe
|
||||||
|
//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level
|
||||||
|
//! document. That has two consequences worth stating:
|
||||||
|
//!
|
||||||
|
//! - It is a **remote-origin** webview. No capability lists this window, so it
|
||||||
|
//! has no IPC surface at all — `invoke` is not reachable from it, which is
|
||||||
|
//! exactly right for a page served out of a container. Do not add one.
|
||||||
|
//! - The app CSP does not apply, and does not need to: `frame-src` exists to
|
||||||
|
//! constrain what the *app's* document may embed, and this is not embedded.
|
||||||
|
//! The port is still confined to [`crate::browser_view::proxy`]'s range and
|
||||||
|
//! still gated by the session token, which is what actually protects it.
|
||||||
|
//!
|
||||||
|
//! ## Lifetime
|
||||||
|
//!
|
||||||
|
//! The window is owned by the session, not by the user's patience: when a view
|
||||||
|
//! stops — the user pressed Stop, the container went away, the viewer died —
|
||||||
|
//! the supervisor's teardown calls [`close`], because a window left showing a
|
||||||
|
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||||
|
//! window leaves the view running, and the pane takes it back into the tab.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
|
/// Emitted when a pop-out opens or closes. Payload: [`PopoutState`] plus the
|
||||||
|
/// project id.
|
||||||
|
///
|
||||||
|
/// The window can close without the app asking it to — the user hits its X, or
|
||||||
|
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||||
|
/// about everything else here, by listening.
|
||||||
|
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
||||||
|
|
||||||
|
/// What the pane needs to render its pop-out controls.
|
||||||
|
///
|
||||||
|
/// Both fields are read from the window itself rather than remembered on either
|
||||||
|
/// side: the pane is unmounted whenever another Project Home sub-tab is
|
||||||
|
/// selected, so anything it merely *remembers* about the window is gone by the
|
||||||
|
/// time the user comes back, while the window is still there.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
pub struct PopoutState {
|
||||||
|
pub open: bool,
|
||||||
|
pub always_on_top: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopoutState {
|
||||||
|
const CLOSED: Self = Self {
|
||||||
|
open: false,
|
||||||
|
always_on_top: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
|
||||||
|
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||||
|
/// cannot produce a label Tauri rejects at build time.
|
||||||
|
pub fn window_label(project_id: &str) -> String {
|
||||||
|
let id: String = project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
format!("browser-view-{}", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the pop-out, or raise it if it is already open.
|
||||||
|
///
|
||||||
|
/// `url` is the live session's URL; the caller has already established that the
|
||||||
|
/// view is running, because there is nothing to show otherwise.
|
||||||
|
pub fn open(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
project_name: &str,
|
||||||
|
url: &str,
|
||||||
|
always_on_top: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let label = window_label(project_id);
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
// Asking twice means "I can't see it", not "open another".
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
let _ = window.set_always_on_top(always_on_top);
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("The browser view's address is not a URL: {}", e))?;
|
||||||
|
|
||||||
|
let project_id_owned = project_id.to_string();
|
||||||
|
let app_for_event = app.clone();
|
||||||
|
|
||||||
|
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
|
||||||
|
.title(format!("{} — browser", project_name))
|
||||||
|
.inner_size(1100.0, 820.0)
|
||||||
|
.min_inner_size(480.0, 360.0)
|
||||||
|
.always_on_top(always_on_top)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||||
|
|
||||||
|
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||||
|
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||||
|
// see `set_match_window`.
|
||||||
|
window.on_window_event(move |event| match event {
|
||||||
|
WindowEvent::Destroyed => {
|
||||||
|
set_match_window(&project_id_owned, false);
|
||||||
|
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
on_resized(&app_for_event, &project_id_owned, size.width, size.height);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("Browser view: popped out for project {}", project_id);
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the pop-out if there is one. Safe to call when there isn't.
|
||||||
|
///
|
||||||
|
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||||
|
/// window-event handler treats that as a request to quit for the main window.
|
||||||
|
/// Nothing here should ever be able to be mistaken for that.
|
||||||
|
///
|
||||||
|
/// A failure is **returned, not logged and forgotten**. The pane puts its
|
||||||
|
/// iframe back the moment it believes the window is gone, so reporting a close
|
||||||
|
/// that did not happen is how you end up with two viewers driving one browser —
|
||||||
|
/// the exact state the iframe is dropped to prevent.
|
||||||
|
pub fn close(app: &AppHandle, project_id: &str) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
||||||
|
window.destroy().map_err(|e| {
|
||||||
|
log::warn!(
|
||||||
|
"Browser view: could not close the pop-out for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
format!("Could not close the browser window: {}", e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
// `Destroyed` covers the normal path; a window that was already gone still
|
||||||
|
// owes the pane an answer.
|
||||||
|
emit(app, project_id, PopoutState::CLOSED);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the window exists and how it is stacked, read from the window.
|
||||||
|
pub fn state(app: &AppHandle, project_id: &str) -> PopoutState {
|
||||||
|
match app.get_webview_window(&window_label(project_id)) {
|
||||||
|
Some(window) => PopoutState {
|
||||||
|
open: true,
|
||||||
|
// A window that cannot answer is not a reason to fail the call; the
|
||||||
|
// pin is a preference, and "not pinned" is the safe reading.
|
||||||
|
always_on_top: window.is_always_on_top().unwrap_or(false),
|
||||||
|
},
|
||||||
|
None => PopoutState::CLOSED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||||
|
pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> {
|
||||||
|
let Some(window) = app.get_webview_window(&window_label(project_id)) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.set_always_on_top(on_top)
|
||||||
|
.map_err(|e| format!("Could not change the window's stacking: {}", e))?;
|
||||||
|
emit(app, project_id, state(app, project_id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Match-window mode
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Projects whose pop-out is driving the page's viewport, and the generation of
|
||||||
|
/// the latest resize for each — the debounce is "did anything else arrive while
|
||||||
|
/// I slept?", which needs no timer to cancel.
|
||||||
|
static MATCH_WINDOW: OnceLock<Mutex<HashMap<String, (bool, u64)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// How long the window has to stop moving before the page is resized.
|
||||||
|
///
|
||||||
|
/// A drag emits `Resized` continuously; each one costs a container exec, and
|
||||||
|
/// Chromium relayouts the page. Settling first turns a drag into one resize.
|
||||||
|
const RESIZE_SETTLE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
fn match_window_map() -> &'static Mutex<HashMap<String, (bool, u64)>> {
|
||||||
|
MATCH_WINDOW.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn match-window mode on or off for a project.
|
||||||
|
///
|
||||||
|
/// Only ever affects a page **Triple-C opened** — a bound browser cannot be
|
||||||
|
/// joined by a second client, so a page `@playwright/mcp` launched keeps
|
||||||
|
/// whatever viewport it was given. See [`super::page`].
|
||||||
|
pub fn set_match_window(project_id: &str, enabled: bool) {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let entry = map.entry(project_id.to_string()).or_insert((false, 0));
|
||||||
|
entry.0 = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_window(project_id: &str) -> bool {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(project_id)
|
||||||
|
.map(|(on, _)| *on)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pop-out's current inner size, for applying match-window immediately
|
||||||
|
/// rather than only on the next drag.
|
||||||
|
pub fn inner_size(app: &AppHandle, project_id: &str) -> Option<(u32, u32)> {
|
||||||
|
let window = app.get_webview_window(&window_label(project_id))?;
|
||||||
|
let size = window.inner_size().ok()?;
|
||||||
|
Some((size.width, size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounce a resize, then push the settled size into the page's viewport.
|
||||||
|
fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||||
|
let generation = {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let Some(entry) = map.get_mut(project_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !entry.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.1 += 1;
|
||||||
|
entry.1
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = app.clone();
|
||||||
|
let project_id = project_id.to_string();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(RESIZE_SETTLE).await;
|
||||||
|
// Superseded by a later resize: that one will do the work.
|
||||||
|
{
|
||||||
|
let map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match map.get(&project_id) {
|
||||||
|
Some((true, latest)) if *latest == generation => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = app.state::<crate::AppState>();
|
||||||
|
let Some(container_id) = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.and_then(|p| p.container_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = super::page::set_viewport(
|
||||||
|
&container_id,
|
||||||
|
super::page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!("Browser view: could not match the page to the window: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||||
|
let _ = app.emit(
|
||||||
|
POPOUT_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"open": state.open,
|
||||||
|
"always_on_top": state.always_on_top,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_are_derived_from_the_project_and_are_tauri_safe() {
|
||||||
|
assert_eq!(
|
||||||
|
window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"),
|
||||||
|
"browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"
|
||||||
|
);
|
||||||
|
assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_projects_get_distinct_windows() {
|
||||||
|
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_window_is_off_until_asked_for_and_is_per_project() {
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
set_match_window("mw-a", true);
|
||||||
|
assert!(match_window("mw-a"));
|
||||||
|
// Another project's window must not start driving its page too.
|
||||||
|
assert!(!match_window("mw-b"));
|
||||||
|
set_match_window("mw-a", false);
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resize_supersedes_the_one_before_it() {
|
||||||
|
// The debounce is a generation counter, not a cancellable timer: only
|
||||||
|
// the newest resize of a drag survives to touch the container.
|
||||||
|
set_match_window("mw-gen", true);
|
||||||
|
let read = || {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get("mw-gen")
|
||||||
|
.map(|(_, g)| *g)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let before = read();
|
||||||
|
{
|
||||||
|
let mut map = match_window_map().lock().unwrap();
|
||||||
|
let entry = map.get_mut("mw-gen").unwrap();
|
||||||
|
entry.1 += 1;
|
||||||
|
}
|
||||||
|
assert!(read() > before);
|
||||||
|
set_match_window("mw-gen", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
|||||||
docker::get_container_info(&project).await
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
|
|||||||
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||||
/// expressions are not evaluated here.
|
/// expressions are not evaluated here.
|
||||||
pub next_run: Option<String>,
|
pub next_run: Option<String>,
|
||||||
|
/// Whether a run is in flight right now, from the runner's state file in
|
||||||
|
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
|
||||||
|
pub running: bool,
|
||||||
|
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
|
||||||
|
pub running_since: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
||||||
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
|
|||||||
set -u
|
set -u
|
||||||
TASKS="$HOME/.claude/scheduler/tasks"
|
TASKS="$HOME/.claude/scheduler/tasks"
|
||||||
LOGS="$HOME/.claude/scheduler/logs"
|
LOGS="$HOME/.claude/scheduler/logs"
|
||||||
|
RUNNING="$HOME/.claude/scheduler/running"
|
||||||
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
||||||
for f in "$TASKS"/*.json; do
|
for f in "$TASKS"/*.json; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
id=$(jq -r '.id // ""' "$f") || continue
|
id=$(jq -r '.id // ""' "$f") || continue
|
||||||
[ -n "$id" ] || id=$(basename "$f" .json)
|
[ -n "$id" ] || id=$(basename "$f" .json)
|
||||||
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
||||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{
|
# Live-run state. The pid is checked, not trusted: a container stopped
|
||||||
|
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
|
||||||
|
# "running" forever is a worse lie than showing nothing.
|
||||||
|
started=""
|
||||||
|
state="$RUNNING/$id.json"
|
||||||
|
if [ -f "$state" ]; then
|
||||||
|
pid=$(jq -r '.pid // empty' "$state")
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
started=$(jq -r '.started_epoch // empty' "$state")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
|
||||||
id: (if (.id // "") == "" then $fallback_id else .id end),
|
id: (if (.id // "") == "" then $fallback_id else .id end),
|
||||||
name: (.name // ""),
|
name: (.name // ""),
|
||||||
prompt: (.prompt // ""),
|
prompt: (.prompt // ""),
|
||||||
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
|
|||||||
enabled: (.enabled == true),
|
enabled: (.enabled == true),
|
||||||
working_dir: (.working_dir // "/workspace"),
|
working_dir: (.working_dir // "/workspace"),
|
||||||
created_at: (.created_at // null),
|
created_at: (.created_at // null),
|
||||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end)
|
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
|
||||||
|
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
|
||||||
}' "$f"
|
}' "$f"
|
||||||
done | jq -s 'sort_by(.name, .id)'
|
done | jq -s 'sort_by(.name, .id)'
|
||||||
"#;
|
"#;
|
||||||
@@ -673,6 +691,7 @@ struct RawScheduledTask {
|
|||||||
working_dir: String,
|
working_dir: String,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
last_run_epoch: Option<i64>,
|
last_run_epoch: Option<i64>,
|
||||||
|
running_since_epoch: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
|
|||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
last_run: t.last_run_epoch.map(epoch_to_iso),
|
last_run: t.last_run_epoch.map(epoch_to_iso),
|
||||||
next_run,
|
next_run,
|
||||||
|
running: t.running_since_epoch.is_some(),
|
||||||
|
running_since: t.running_since_epoch.map(epoch_to_iso),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -1179,9 +1200,14 @@ pub async fn add_scheduled_task(
|
|||||||
/// * **In that order**, so a rejected `add` leaves the original untouched
|
/// * **In that order**, so a rejected `add` leaves the original untouched
|
||||||
/// rather than deleting a prompt the user cannot get back. The cost is a
|
/// rather than deleting a prompt the user cannot get back. The cost is a
|
||||||
/// sub-second window in which both tasks are in the crontab.
|
/// sub-second window in which both tasks are in the crontab.
|
||||||
/// * The task therefore gets a **new id**. Its old log directory
|
/// * The task therefore gets a **new id**, and its old log directory
|
||||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||||
/// UI warns about this before saving.
|
/// scheduler reaps a task's logs when the task stops existing, because
|
||||||
|
/// nothing can name that id again afterwards. The UI warns before saving.
|
||||||
|
/// (A project still running an older base image carries the older
|
||||||
|
/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
|
||||||
|
/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
|
||||||
|
/// copy is deliberately written for the case that loses data.)
|
||||||
/// * `enabled` is carried over explicitly, because `add` always creates an
|
/// * `enabled` is carried over explicitly, because `add` always creates an
|
||||||
/// enabled task and silently re-enabling a task the user had switched off
|
/// enabled task and silently re-enabling a task the user had switched off
|
||||||
/// would schedule a run they did not ask for.
|
/// would schedule a run they did not ask for.
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ use crate::AppState;
|
|||||||
/// Report how far behind the current base image a project's container is, and
|
/// Report how far behind the current base image a project's container is, and
|
||||||
/// what migrating it would actually carry across.
|
/// what migrating it would actually carry across.
|
||||||
///
|
///
|
||||||
|
/// Choose the recorded lineage from the two places it can be written, most
|
||||||
|
/// authoritative first: the live container's label, then the snapshot image's.
|
||||||
|
///
|
||||||
|
/// **An empty label is absence, not an answer.** `create_container` always
|
||||||
|
/// writes `triple-c.base-image-id`, even when the value is unknown — that is
|
||||||
|
/// deliberate, because Docker merges an image's labels into a container's and
|
||||||
|
/// an inherited value would otherwise ride a snapshot forever. The consequence
|
||||||
|
/// is that `Some("")` is the *common* reading from a container whose lineage
|
||||||
|
/// was never established, so treating it as an answer silently skips the
|
||||||
|
/// snapshot, which may well have recorded a real one.
|
||||||
|
fn pick_recorded_lineage(
|
||||||
|
from_container: Option<String>,
|
||||||
|
from_snapshot: Option<String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
from_container
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||||
/// be called on demand, not polled.
|
/// be called on demand, not polled.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -98,20 +117,24 @@ pub async fn get_container_staleness(
|
|||||||
// Lineage, most authoritative source first: the live container's label,
|
// Lineage, most authoritative source first: the live container's label,
|
||||||
// then the snapshot image's. Both are written by `create_container` and
|
// then the snapshot image's. Both are written by `create_container` and
|
||||||
// propagated onto the snapshot by `docker commit`.
|
// propagated onto the snapshot by `docker commit`.
|
||||||
|
// Each source is filtered for emptiness *before* it is allowed to satisfy
|
||||||
|
// the lookup. `create_container` always writes this label, even when the
|
||||||
|
// value is unknown — deliberately, so an inherited image label cannot ride
|
||||||
|
// a snapshot forever — which means the container's copy is very often
|
||||||
|
// `Some("")`. Filtering only the final result let that empty string count
|
||||||
|
// as an answer and skip the snapshot entirely, so a snapshot that *did*
|
||||||
|
// record a lineage was never consulted and the project reported "unknown"
|
||||||
|
// with the information sitting one lookup away.
|
||||||
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
|
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
|
||||||
let recorded = match &container_id {
|
let from_container = match &container_id {
|
||||||
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
|
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
|
||||||
None => None,
|
None => None,
|
||||||
}
|
};
|
||||||
.or_else(|| None);
|
let from_snapshot = mig::image_labels(&snapshot_image)
|
||||||
let recorded = match recorded {
|
|
||||||
Some(v) => Some(v),
|
|
||||||
None => mig::image_labels(&snapshot_image)
|
|
||||||
.await
|
.await
|
||||||
.get(mig::LABEL_BASE_IMAGE_ID)
|
.get(mig::LABEL_BASE_IMAGE_ID)
|
||||||
.cloned(),
|
.cloned();
|
||||||
}
|
let recorded = pick_recorded_lineage(from_container, from_snapshot);
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
|
|
||||||
out.base_image_id = recorded.clone();
|
out.base_image_id = recorded.clone();
|
||||||
out.known = recorded.is_some();
|
out.known = recorded.is_some();
|
||||||
@@ -204,58 +227,62 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
|
|||||||
// Migrate
|
// Migrate
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Project ids with a migration running **in this process right now**.
|
|
||||||
///
|
|
||||||
/// Two things need it. `reconcile_project_statuses` is callable from the
|
|
||||||
/// frontend at any time, not only at startup, and a live migration looks
|
|
||||||
/// exactly like a crashed one from the outside (state file says `in-progress`,
|
|
||||||
/// container carries the label) — without this guard a reconcile mid-run would
|
|
||||||
/// rewrite the phase to `interrupted` underneath a migration that is fine.
|
|
||||||
/// It also makes a second concurrent `migrate_project_to_base` for the same
|
|
||||||
/// project impossible.
|
|
||||||
static ACTIVE_MIGRATIONS: std::sync::OnceLock<
|
|
||||||
std::sync::Mutex<std::collections::HashSet<String>>,
|
|
||||||
> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
fn active_migrations() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
|
||||||
ACTIVE_MIGRATIONS.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a migration for this project is running **in this process right
|
/// Whether a migration for this project is running **in this process right
|
||||||
/// now**. Every command that stops, removes or recreates the project's
|
/// now**. Every command that stops, removes or recreates the project's
|
||||||
/// container has to consult it: the window between `remove_container` and the
|
/// container has to consult it: the window between `remove_container` and the
|
||||||
/// create that follows looks exactly like "no container", and an ordinary
|
/// create that follows looks exactly like "no container", and an ordinary
|
||||||
/// Start landing in it creates a second container under the same name.
|
/// Start landing in it creates a second container under the same name.
|
||||||
|
///
|
||||||
|
/// **This is now a view onto [`crate::project_lock`], not a set of its own.**
|
||||||
|
/// It used to be the app's only mutual-exclusion primitive, and it was one-way:
|
||||||
|
/// a migration claimed a project, everything else merely polled this once at
|
||||||
|
/// entry and never claimed anything. Two non-migration writers of
|
||||||
|
/// `triple-c-snapshot-{id}:latest` — a compaction and a recreate — could not
|
||||||
|
/// see each other at all. Folding the set into the shared registry means there
|
||||||
|
/// is exactly one answer to "is something happening to this project", and this
|
||||||
|
/// function is the specialisation of it that reconcile still needs: a *live*
|
||||||
|
/// migration is indistinguishable from a crashed one from the outside, and only
|
||||||
|
/// this process knows which it is looking at.
|
||||||
|
///
|
||||||
|
/// No production caller on this branch: the Disk panel's survey was the last
|
||||||
|
/// one, and it went to `hold/disk-and-dragout`. Kept — and still exercised by
|
||||||
|
/// `a_live_migration_is_distinguishable_from_a_crashed_one` — because it is the
|
||||||
|
/// one named answer to that question and re-inventing it is how the two
|
||||||
|
/// disagreeing answers happened the first time.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||||
active_migrations()
|
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.contains(project_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RAII marker: removes the project from [`ACTIVE_MIGRATIONS`] however the
|
/// RAII marker: releases the project's [`crate::project_lock`] claim however
|
||||||
/// migration ends, including an early `?`.
|
/// the migration ends, including an early `?`.
|
||||||
struct ActiveGuard(String);
|
///
|
||||||
|
/// Kept as a named type rather than using [`crate::project_lock::ProjectGuard`]
|
||||||
|
/// directly so the migration path keeps reading as "take the migration guard",
|
||||||
|
/// and so the one place that decides what a migration's claim *is* stays here.
|
||||||
|
struct ActiveGuard(#[allow(dead_code)] crate::project_lock::ProjectGuard);
|
||||||
|
|
||||||
impl ActiveGuard {
|
impl ActiveGuard {
|
||||||
/// `None` when a migration is already running for this project.
|
/// `Err` with the registry's own refusal when a migration — **or anything
|
||||||
fn acquire(project_id: &str) -> Option<Self> {
|
/// else** — already holds this project.
|
||||||
let mut set = active_migrations()
|
///
|
||||||
.lock()
|
/// The error string is the point. This returned `Option`, and all three
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
/// callers replaced the discarded reason with a sentence about a migration
|
||||||
if !set.insert(project_id.to_string()) {
|
/// — so a user blocked by a *compaction*, a reset or a cache clear was told
|
||||||
return None;
|
/// to wait for a base update that was not running, with nothing in the UI
|
||||||
}
|
/// that could ever name what actually held the project.
|
||||||
Some(Self(project_id.to_string()))
|
/// `project_lock::try_acquire` already composes "what holds it" with "what
|
||||||
}
|
/// you were trying to do"; there is nothing to add to it here.
|
||||||
}
|
///
|
||||||
|
/// The tail it composes is `ProjectOp::Migration`'s — "…before starting a
|
||||||
impl Drop for ActiveGuard {
|
/// base update" — for confirm and rollback as well as for the migration
|
||||||
fn drop(&mut self) {
|
/// itself. That is the class all three belong to, and splitting it would
|
||||||
active_migrations()
|
/// mean a `ProjectOp` variant per command: the wrong place to encode a
|
||||||
.lock()
|
/// verb, for a phrase that is at worst imprecise where the old one was
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
/// simply wrong.
|
||||||
.remove(&self.0);
|
fn acquire(project_id: &str) -> Result<Self, String> {
|
||||||
|
crate::project_lock::try_acquire(project_id, crate::project_lock::ProjectOp::Migration)
|
||||||
|
.map(Self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,10 +298,9 @@ pub async fn migrate_project_to_base(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<MigrationReport, String> {
|
) -> Result<MigrationReport, String> {
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = match ActiveGuard::acquire(&project_id) {
|
||||||
return Ok(MigrationReport::failed_preflight(
|
Ok(guard) => guard,
|
||||||
"A migration is already running for this project.",
|
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
|
||||||
));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let existing = migration_store::load(&project_id)?;
|
let existing = migration_store::load(&project_id)?;
|
||||||
@@ -437,6 +463,33 @@ async fn fresh_migration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrub *before* the stop, not inside the commit below. `scrub_writable_layer`
|
||||||
|
// is a `docker exec`, which only works on a running container — and the
|
||||||
|
// pre-swap commit is the single largest snapshot Triple-C ever takes, so
|
||||||
|
// letting this one path commit unscrubbed is what the scrub exists to
|
||||||
|
// prevent. Failure is swallowed inside; it must never block a migration.
|
||||||
|
//
|
||||||
|
// The outcome is logged rather than discarded: this is the one scrub whose
|
||||||
|
// silence would be expensive, because the layer it declined to clean is
|
||||||
|
// about to be committed into a snapshot that outlives the migration.
|
||||||
|
//
|
||||||
|
// H2: **bind it first.** `ScrubOutcome` is `#[must_use]`, and the way that
|
||||||
|
// was satisfied here was by folding the awaited call into `log::info!`'s
|
||||||
|
// argument list. `log::info!` expands to
|
||||||
|
// `if Info <= max_level() { … }` — so the arguments, this data-integrity
|
||||||
|
// step among them, live inside the level check and do not run at all when
|
||||||
|
// the global filter is `Off`. That is reachable: `logging::init` tolerates
|
||||||
|
// `dispatch.apply()` failing, and fern returns *before* `set_max_level` on
|
||||||
|
// error, so a process that failed to install a logger sits at `Off` with
|
||||||
|
// every `log::` argument list silently dead. The scrub would then never
|
||||||
|
// run, and the unscrubbed layer would be committed into the longest-lived
|
||||||
|
// snapshot the app takes. `commit_container_snapshot` gets this right for
|
||||||
|
// the same reason; nothing may put an effect inside a log macro's
|
||||||
|
// arguments. (`logging::init` now also restores the level on failure, but
|
||||||
|
// the call site is not allowed to depend on that.)
|
||||||
|
let scrub = docker::scrub_writable_layer(&container_id).await;
|
||||||
|
log::info!("Pre-migration scrub of {}{}", container_id, scrub.commit_log_suffix());
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Stopping the container...");
|
emit_progress(&app_handle, &project_id, "Stopping the container...");
|
||||||
let _ = state
|
let _ = state
|
||||||
.projects_store
|
.projects_store
|
||||||
@@ -805,12 +858,7 @@ pub async fn confirm_migration(
|
|||||||
let _ = &state;
|
let _ = &state;
|
||||||
// Confirming drops the only way back. Doing that underneath a running
|
// Confirming drops the only way back. Doing that underneath a running
|
||||||
// migration would delete the pin it is relying on mid-flight.
|
// migration would delete the pin it is relying on mid-flight.
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||||
return Err(
|
|
||||||
"A container base update is running for this project right now. Wait for it to finish."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let Some(mstate) = migration_store::load(&project_id)? else {
|
let Some(mstate) = migration_store::load(&project_id)? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -833,6 +881,16 @@ pub async fn confirm_migration(
|
|||||||
migration_store::clear_staging(&project_id)?;
|
migration_store::clear_staging(&project_id)?;
|
||||||
migration_store::clear(&project_id)?;
|
migration_store::clear(&project_id)?;
|
||||||
log::info!("Migration confirmed for project {}", project_id);
|
log::info!("Migration confirmed for project {}", project_id);
|
||||||
|
|
||||||
|
// Dropping the pin above is what turns the pre-migration image into an
|
||||||
|
// orphan: it was the only tag holding a multi-gigabyte pre-migration
|
||||||
|
// snapshot. Accepting the update is therefore the moment to sweep, and
|
||||||
|
// waiting for the project's next recreation would leave it lying around
|
||||||
|
// indefinitely.
|
||||||
|
tauri::async_runtime::spawn(async {
|
||||||
|
crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,12 +905,7 @@ pub async fn rollback_migration(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||||
return Err(
|
|
||||||
"A container base update is running for this project right now. Wait for it to finish."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut project = state
|
let mut project = state
|
||||||
.projects_store
|
.projects_store
|
||||||
@@ -924,6 +977,16 @@ pub async fn rollback_migration(
|
|||||||
let _ = mig::untag_image(&rollback_ref).await;
|
let _ = mig::untag_image(&rollback_ref).await;
|
||||||
migration_store::clear_staging(&project_id)?;
|
migration_store::clear_staging(&project_id)?;
|
||||||
migration_store::clear(&project_id)?;
|
migration_store::clear(&project_id)?;
|
||||||
|
|
||||||
|
// Retagging above moved `:latest` off the *migrated* snapshot, and the
|
||||||
|
// container that was built from it was removed a few lines up — so a
|
||||||
|
// multi-gigabyte image is sitting there untagged and unreferenced with
|
||||||
|
// nothing else in the app that would ever look at it again. The confirm
|
||||||
|
// path sweeps for exactly this reason; rolling back orphans just as much
|
||||||
|
// and did not.
|
||||||
|
tauri::async_runtime::spawn(async {
|
||||||
|
crate::docker::sweep_orphaned_snapshots_logged("after migration rollback").await;
|
||||||
|
});
|
||||||
emit_progress(
|
emit_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&project_id,
|
&project_id,
|
||||||
@@ -955,9 +1018,203 @@ pub async fn get_migration_state(
|
|||||||
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
||||||
// A migration running right now is indistinguishable from a crashed one
|
// A migration running right now is indistinguishable from a crashed one
|
||||||
// from the outside; only this process knows the difference.
|
// from the outside; only this process knows the difference.
|
||||||
if is_migrating(&project.id) {
|
//
|
||||||
|
// **Any holder, not just a migration.** This asked `is_migrating`, which is
|
||||||
|
// `held() == Some(Migration)` — so a compaction, a reset or a destroy
|
||||||
|
// holding the project made this fall straight through and start rewriting
|
||||||
|
// the migration record's phase and untagging its rollback image underneath
|
||||||
|
// whatever was running. `reconcile_project_statuses` is a command, not just
|
||||||
|
// a startup step, so "nothing else can be running yet" is not available as
|
||||||
|
// an argument.
|
||||||
|
//
|
||||||
|
// Yielding is right; yielding *forever* was not. The only caller fires once
|
||||||
|
// per "Docker became available", so a project that happened to be held at
|
||||||
|
// that instant was never looked at again for the rest of the session: its
|
||||||
|
// phase stayed un-normalised, no resume or rollback was ever offered, and
|
||||||
|
// its `:pre-migration-*` pin stayed `Claimed`. So the visit is deferred
|
||||||
|
// rather than dropped — see [`defer_migration_reconcile`].
|
||||||
|
if let Some(holder) = crate::project_lock::held(&project.id) {
|
||||||
|
log::debug!(
|
||||||
|
"Deferring migration reconcile for '{}' ({}): {}",
|
||||||
|
project.name,
|
||||||
|
project.id,
|
||||||
|
holder.describe()
|
||||||
|
);
|
||||||
|
defer_migration_reconcile(project, app_handle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
reconcile_migration_now(project, app_handle).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long [`defer_migration_reconcile`] waits between looks, and how many
|
||||||
|
/// times it looks.
|
||||||
|
///
|
||||||
|
/// The operations it is waiting behind are minutes long — a Reset recreates a
|
||||||
|
/// container from a base image, a compaction rebuilds a multi-gigabyte
|
||||||
|
/// snapshot — so the interval is coarse on purpose: this is a `held()` read
|
||||||
|
/// against an in-process map, but every wakeup is a task and the point is to
|
||||||
|
/// catch the release, not to catch it promptly. Twenty seconds × ninety is
|
||||||
|
/// thirty minutes, comfortably past the longest measured compaction, after
|
||||||
|
/// which the project is left for the next `reconcile_project_statuses`.
|
||||||
|
const RECONCILE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
|
const RECONCILE_RETRY_ATTEMPTS: usize = 90;
|
||||||
|
|
||||||
|
/// Project ids with a deferred reconcile already waiting.
|
||||||
|
///
|
||||||
|
/// `reconcile_project_statuses` is a command the frontend can call more than
|
||||||
|
/// once — every "Docker became available" — and each call walks every project.
|
||||||
|
/// Without this, a project held for a few minutes would accumulate one waiting
|
||||||
|
/// task per call, all of which would then reconcile the same record in a row.
|
||||||
|
fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||||
|
static RETRIES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One project's place in [`reconcile_retries`], handed back on drop.
|
||||||
|
///
|
||||||
|
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
|
||||||
|
/// claim is the case that proves the rule: the release used to be a trailing
|
||||||
|
/// statement at the bottom of the spawned task in
|
||||||
|
/// [`defer_migration_reconcile`], sitting after an `.await` on
|
||||||
|
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
|
||||||
|
/// dropped, which is what happens to every in-flight task at shutdown — skips
|
||||||
|
/// the statement, and nothing else ever removes an id from that set. The
|
||||||
|
/// project is then fenced off from *every* later deferral for the rest of the
|
||||||
|
/// process: each `reconcile_project_statuses` pass finds it held, fails to
|
||||||
|
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
|
||||||
|
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
|
||||||
|
/// session-long silence deferring was written to end, reintroduced one panic
|
||||||
|
/// later and lasting until the app is restarted.
|
||||||
|
///
|
||||||
|
/// Dropping this hands the claim straight back, so a caller that discards the
|
||||||
|
/// value has claimed nothing while reading as though it had; `#[must_use]`
|
||||||
|
/// makes that a compile warning rather than a second waiter on one record.
|
||||||
|
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
|
||||||
|
struct ReconcileRetryClaim {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReconcileRetryClaim {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// `into_inner` past poisoning, as in `project_lock`: the only thing
|
||||||
|
// ever done while holding this mutex is a single `HashSet` insert or
|
||||||
|
// remove, so a panic on another thread cannot have left it half
|
||||||
|
// written — and declining to release here would strand the project
|
||||||
|
// permanently, which is the exact failure the guard exists to stop.
|
||||||
|
reconcile_retries()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(&self.project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim the right to be the one deferred reconcile for `project_id`.
|
||||||
|
/// `None` means somebody else already is.
|
||||||
|
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
|
||||||
|
reconcile_retries()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(project_id.to_string())
|
||||||
|
.then(|| ReconcileRetryClaim {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
||||||
|
///
|
||||||
|
/// Only for projects that have a record on disk: [`migration_store::has_record`]
|
||||||
|
/// is filesystem presence, so it costs nothing and is deliberately the *cheap*
|
||||||
|
/// question — every project is walked on every reconcile and almost none of
|
||||||
|
/// them have a migration in flight. A record that exists but cannot be parsed
|
||||||
|
/// answers `true` here and is handled, conservatively, by `load` when the
|
||||||
|
/// retry lands.
|
||||||
|
///
|
||||||
|
/// The wait is a poll rather than a notification because `project_lock` has no
|
||||||
|
/// release hook and giving it one would mean a guard's `Drop` waking tasks
|
||||||
|
/// while it still holds the map's mutex. A read of an in-process `HashMap`
|
||||||
|
/// every twenty seconds, for as long as one operation is running on one
|
||||||
|
/// project, is not worth a condvar.
|
||||||
|
fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
||||||
|
// No record means nothing to come back for. An unreadable migrations
|
||||||
|
// directory answers "maybe", and maybe is worth a look.
|
||||||
|
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(claim) = claim_reconcile_retry(&project.id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let project = project.clone();
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Moved in and bound for the whole body, rather than released by a
|
||||||
|
// statement at the bottom: everything below this line can panic or be
|
||||||
|
// dropped mid-await, and a claim that only comes back on the happy path
|
||||||
|
// is a claim that eventually does not come back at all. See
|
||||||
|
// [`ReconcileRetryClaim`].
|
||||||
|
let _claim = claim;
|
||||||
|
let released =
|
||||||
|
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
||||||
|
if released {
|
||||||
|
// Whatever was holding it may have finished the migration itself or
|
||||||
|
// cleared the record — `reconcile_migration_now` loads the record
|
||||||
|
// first and returns on `None`, so that is a no-op rather than a
|
||||||
|
// special case here.
|
||||||
|
reconcile_migration_now(&project, &app_handle).await;
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Gave up waiting to reconcile the migration record for '{}' ({}): it has been \
|
||||||
|
held for {} minutes. Its phase is unchanged and its rollback pin is still \
|
||||||
|
claimed; the next reconcile pass will try again.",
|
||||||
|
project.name,
|
||||||
|
project.id,
|
||||||
|
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for `project_id` to stop being held: `attempts` looks, the first
|
||||||
|
/// immediate and the rest `interval` apart. `true` means it was released,
|
||||||
|
/// `false` that the budget ran out with it still held.
|
||||||
|
///
|
||||||
|
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
||||||
|
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
|
||||||
|
/// that are easy to get wrong are "gives up while still holding the claim",
|
||||||
|
/// "never looks again", and the ordering of the look against the sleep, none of
|
||||||
|
/// which is visible from the constants.
|
||||||
|
async fn await_release(
|
||||||
|
project_id: &str,
|
||||||
|
interval: std::time::Duration,
|
||||||
|
attempts: usize,
|
||||||
|
) -> bool {
|
||||||
|
for attempt in 0..attempts {
|
||||||
|
// Look first, sleep second. Sleeping first charged every deferral a
|
||||||
|
// full interval before anyone read the map even once, and the common
|
||||||
|
// case is a holder that has already let go: `held()` is sampled in
|
||||||
|
// `reconcile_migration`, a task is spawned, and by the time it is first
|
||||||
|
// polled the Reset that was on its last step is frequently finished.
|
||||||
|
// That bought nothing and cost twenty seconds of a startup pass waiting
|
||||||
|
// on a lock nobody holds, in front of a check that is one `HashMap`
|
||||||
|
// lookup.
|
||||||
|
if crate::project_lock::held(project_id).is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// And no sleep after the final look: nothing reads the map again
|
||||||
|
// afterwards, so it is twenty seconds of delay in front of a `false`
|
||||||
|
// that has already been decided. The budget is still `attempts` looks,
|
||||||
|
// which is what the constants above are chosen against.
|
||||||
|
if attempt + 1 < attempts {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`reconcile_migration`] with the "is anything holding this project" question
|
||||||
|
/// already answered.
|
||||||
|
async fn reconcile_migration_now(project: &Project, app_handle: &tauri::AppHandle) {
|
||||||
let state = match migration_store::load(&project.id) {
|
let state = match migration_store::load(&project.id) {
|
||||||
Ok(Some(s)) => s,
|
Ok(Some(s)) => s,
|
||||||
Ok(None) => return,
|
Ok(None) => return,
|
||||||
@@ -1113,7 +1370,23 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => return,
|
Ok(None) => {
|
||||||
|
// **`Ok(None)` is not the same as "no file".** `migration_store::load`
|
||||||
|
// now reports an *unparseable* record as absent while deliberately
|
||||||
|
// leaving it on disk, so that `has_record` goes on protecting the
|
||||||
|
// rollback pin it describes. Returning here on that would leave the
|
||||||
|
// file — and therefore a permanently "claimed" pin — behind a Reset
|
||||||
|
// that has just deleted the snapshot and both volumes the record
|
||||||
|
// could possibly refer to.
|
||||||
|
if !migration_store::has_record(project_id).unwrap_or(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"Project {} has a migration record that could not be read; removing it anyway \
|
||||||
|
because a Reset supersedes it",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(e) => log::warn!(
|
Err(e) => log::warn!(
|
||||||
"Could not read the migration record for {} while cleaning up: {}",
|
"Could not read the migration record for {} while cleaning up: {}",
|
||||||
project_id,
|
project_id,
|
||||||
@@ -1122,6 +1395,10 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
|||||||
}
|
}
|
||||||
let _ = migration_store::clear_staging(project_id);
|
let _ = migration_store::clear_staging(project_id);
|
||||||
let _ = migration_store::clear(project_id);
|
let _ = migration_store::clear(project_id);
|
||||||
|
// The pins this project had are gone with the snapshot; their grace clocks
|
||||||
|
// are meaningless and would otherwise sit in the migrations directory
|
||||||
|
// forever.
|
||||||
|
migration_store::clear_ownerless_for_project(project_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_docker_socket() -> String {
|
fn default_docker_socket() -> String {
|
||||||
@@ -1661,6 +1938,32 @@ fn summarize(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_lineage_label_is_absence_and_falls_through_to_the_snapshot() {
|
||||||
|
let some = |s: &str| Some(s.to_string());
|
||||||
|
|
||||||
|
// The regression: the container always carries the label, so an
|
||||||
|
// unknown lineage reads as `Some("")`. Letting that satisfy the lookup
|
||||||
|
// skipped a snapshot that had recorded the real thing.
|
||||||
|
assert_eq!(
|
||||||
|
pick_recorded_lineage(some(""), some("sha256:base")),
|
||||||
|
some("sha256:base")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordinary precedence still holds: the container wins when it has one.
|
||||||
|
assert_eq!(
|
||||||
|
pick_recorded_lineage(some("sha256:container"), some("sha256:snapshot")),
|
||||||
|
some("sha256:container")
|
||||||
|
);
|
||||||
|
assert_eq!(pick_recorded_lineage(None, some("sha256:snap")), some("sha256:snap"));
|
||||||
|
|
||||||
|
// Genuinely unknown stays unknown — "probe instead", never a lineage
|
||||||
|
// invented to make the comparison succeed.
|
||||||
|
assert_eq!(pick_recorded_lineage(None, None), None);
|
||||||
|
assert_eq!(pick_recorded_lineage(some(""), some("")), None);
|
||||||
|
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||||
assert_eq!(human_bytes(512), "512 B");
|
assert_eq!(human_bytes(512), "512 B");
|
||||||
@@ -1819,16 +2122,23 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
||||||
assert!(is_migrating(id));
|
assert!(is_migrating(id));
|
||||||
|
let refused = ActiveGuard::acquire(id)
|
||||||
|
.err()
|
||||||
|
.expect("a second concurrent migration must be refused");
|
||||||
|
// The refusal has to say what is holding the project, not what the
|
||||||
|
// caller happens to be — the three commands used to substitute
|
||||||
|
// their own sentence for this and lost the distinction.
|
||||||
assert!(
|
assert!(
|
||||||
ActiveGuard::acquire(id).is_none(),
|
refused.contains("base update"),
|
||||||
"a second concurrent migration must be refused"
|
"the refusal must name the holder: {}",
|
||||||
|
refused
|
||||||
);
|
);
|
||||||
drop(g);
|
drop(g);
|
||||||
}
|
}
|
||||||
assert!(!is_migrating(id), "the guard must release on drop");
|
assert!(!is_migrating(id), "the guard must release on drop");
|
||||||
// …including when the migration bailed out through an early return.
|
// …including when the migration bailed out through an early return.
|
||||||
fn early_return(id: &str) -> Option<()> {
|
fn early_return(id: &str) -> Option<()> {
|
||||||
let _g = ActiveGuard::acquire(id)?;
|
let _g = ActiveGuard::acquire(id).ok()?;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
assert!(early_return(id).is_none());
|
assert!(early_return(id).is_none());
|
||||||
@@ -1842,4 +2152,225 @@ mod tests {
|
|||||||
assert!(!r.rollback_available);
|
assert!(!r.rollback_available);
|
||||||
assert!(r.packages_requested.is_empty());
|
assert!(r.packages_requested.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No `await` may sit inside a `log::*!` argument list — H2, generalised.
|
||||||
|
///
|
||||||
|
/// `log::info!(a, b)` expands to `if Info <= max_level() { … a … b … }`, so
|
||||||
|
/// an argument is only evaluated while the level admits the record. Folding
|
||||||
|
/// `scrub_writable_layer(&id).await.commit_log_suffix()` into the arguments
|
||||||
|
/// here — done to satisfy `#[must_use]` on `ScrubOutcome` — therefore made
|
||||||
|
/// the pre-migration scrub conditional on the log level, and
|
||||||
|
/// `logging::init` deliberately tolerates failing to install a logger,
|
||||||
|
/// which leaves `max_level()` at `Off`. A scrub that never runs before the
|
||||||
|
/// largest snapshot the app takes is not something a log level may decide.
|
||||||
|
///
|
||||||
|
/// Scanned over the source rather than asserted at one call site: the bug
|
||||||
|
/// is a shape, and it is reintroduced by whoever next has a `#[must_use]`
|
||||||
|
/// value they only want to log.
|
||||||
|
#[test]
|
||||||
|
fn nothing_awaits_inside_a_log_macros_arguments() {
|
||||||
|
let sources: &[(&str, &str)] = &[
|
||||||
|
("commands/migration_commands.rs", include_str!("migration_commands.rs")),
|
||||||
|
("docker/container.rs", include_str!("../docker/container.rs")),
|
||||||
|
("docker/migration.rs", include_str!("../docker/migration.rs")),
|
||||||
|
("logging.rs", include_str!("../logging.rs")),
|
||||||
|
];
|
||||||
|
let macros = ["log::error!(", "log::warn!(", "log::info!(", "log::debug!(", "log::trace!("];
|
||||||
|
let mut scanned = 0usize;
|
||||||
|
for (name, src) in sources {
|
||||||
|
for mac in macros {
|
||||||
|
let mut from = 0usize;
|
||||||
|
while let Some(at) = src[from..].find(mac) {
|
||||||
|
let start = from + at + mac.len();
|
||||||
|
// Balance the macro's own parentheses. String literals in
|
||||||
|
// these call sites never contain an unbalanced one, and a
|
||||||
|
// `(` inside a format string would only ever widen the
|
||||||
|
// slice, i.e. fail safe.
|
||||||
|
let mut depth = 1usize;
|
||||||
|
let mut end = start;
|
||||||
|
for (i, c) in src[start..].char_indices() {
|
||||||
|
match c {
|
||||||
|
'(' => depth += 1,
|
||||||
|
')' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
end = start + i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let args = &src[start..end];
|
||||||
|
// This test's own name mentions the thing it forbids.
|
||||||
|
assert!(
|
||||||
|
!args.contains(".await"),
|
||||||
|
"{}: an `.await` inside a `{}` argument list stops happening whenever the \
|
||||||
|
log level does not admit the record:\n{}",
|
||||||
|
name,
|
||||||
|
mac.trim_end_matches('('),
|
||||||
|
args
|
||||||
|
);
|
||||||
|
scanned += 1;
|
||||||
|
from = end.max(start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A scanner that matched nothing would pass silently forever.
|
||||||
|
assert!(scanned > 80, "only {} log call sites were scanned", scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MEDIUM: a project held when the reconcile pass reached it must be
|
||||||
|
/// revisited, not dropped for the session.
|
||||||
|
///
|
||||||
|
/// `reconcile_project_statuses` fires once per "Docker became available",
|
||||||
|
/// so the old `return` meant a project that happened to be mid-Reset at
|
||||||
|
/// that instant never had its migration phase normalised, was never offered
|
||||||
|
/// resume or rollback, and kept its `:pre-migration-*` pin `Claimed` — for
|
||||||
|
/// the rest of the session. On a paused clock, so the thirty-minute budget
|
||||||
|
/// costs nothing.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_held_project_is_revisited_once_the_holder_lets_go() {
|
||||||
|
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let guard = crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Reset)
|
||||||
|
.expect("a fresh project id is not held");
|
||||||
|
|
||||||
|
let waiting = {
|
||||||
|
let id = id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Long enough that several looks have already happened and found it
|
||||||
|
// held, so this cannot pass by the waiter never having polled.
|
||||||
|
tokio::time::sleep(RECONCILE_RETRY_INTERVAL * 3).await;
|
||||||
|
assert!(!waiting.is_finished(), "the waiter returned while the project was held");
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
assert!(
|
||||||
|
waiting.await.expect("the waiter task"),
|
||||||
|
"the holder let go and the reconcile never came back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And the budget is finite: a project held indefinitely does not leave a
|
||||||
|
/// task waiting on it forever, and the claim is handed back either way.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn waiting_for_a_holder_gives_up_eventually() {
|
||||||
|
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let _guard =
|
||||||
|
crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Migration)
|
||||||
|
.expect("a fresh project id is not held");
|
||||||
|
assert!(!await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||||
|
// Thirty minutes: past the longest measured compaction, and the thing
|
||||||
|
// being waited on is always a bounded, user-initiated operation.
|
||||||
|
assert!(RECONCILE_RETRY_ATTEMPTS > 0, "deferring would be a no-op");
|
||||||
|
let budget = RECONCILE_RETRY_INTERVAL * RECONCILE_RETRY_ATTEMPTS as u32;
|
||||||
|
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
|
||||||
|
///
|
||||||
|
/// The wait slept before its first look, so a holder that let go between
|
||||||
|
/// `reconcile_migration` sampling `held()` and this task being polled — the
|
||||||
|
/// *common* case, since a deferral is only taken when something was on its
|
||||||
|
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
|
||||||
|
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
|
||||||
|
/// the fixed shape returns without the clock moving at all, the sleep-first
|
||||||
|
/// shape cannot return before it has advanced one interval.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
|
||||||
|
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
assert!(
|
||||||
|
crate::project_lock::held(&id).is_none(),
|
||||||
|
"a fresh uuid is not held"
|
||||||
|
);
|
||||||
|
|
||||||
|
let before = tokio::time::Instant::now();
|
||||||
|
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||||
|
let waited = tokio::time::Instant::now() - before;
|
||||||
|
assert_eq!(
|
||||||
|
waited,
|
||||||
|
std::time::Duration::ZERO,
|
||||||
|
"an already-released project cost {:?} before anyone looked",
|
||||||
|
waited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_one_deferred_reconcile_waits_per_project() {
|
||||||
|
// Every "Docker became available" walks every project, so without the
|
||||||
|
// claim a project held for a few minutes accumulates one waiting task
|
||||||
|
// per call — all of which then reconcile the same record in a row.
|
||||||
|
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||||
|
assert!(
|
||||||
|
claim_reconcile_retry(&id).is_none(),
|
||||||
|
"a second waiter was allowed in"
|
||||||
|
);
|
||||||
|
let other_claim =
|
||||||
|
claim_reconcile_retry(&other).expect("the claim is not per-project");
|
||||||
|
drop(first);
|
||||||
|
// Bound rather than discarded: the guard releases on drop, so
|
||||||
|
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
|
||||||
|
// — which is what `#[must_use]` is there to catch in real callers.
|
||||||
|
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
|
||||||
|
drop(retaken);
|
||||||
|
drop(other_claim);
|
||||||
|
// And the other project's claim was never the same claim.
|
||||||
|
drop(claim_reconcile_retry(&other).expect("released independently"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MEDIUM: the claim survives the task that holds it dying badly.
|
||||||
|
///
|
||||||
|
/// The release used to be a trailing statement after
|
||||||
|
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
|
||||||
|
/// so a panic anywhere in that call — or the future being dropped at
|
||||||
|
/// shutdown — skipped it and left the id in the set with no task behind it.
|
||||||
|
/// Nothing removes it afterwards, so that project could never be deferred
|
||||||
|
/// again for the rest of the process: exactly the state deferring was added
|
||||||
|
/// to prevent, now permanent instead of one pass long. Fails against the
|
||||||
|
/// trailing-statement shape, which is the point.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
|
||||||
|
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||||
|
|
||||||
|
// Spawned, not just called: the real claim is held across an await
|
||||||
|
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
|
||||||
|
// the runtime rather than unwinding the caller.
|
||||||
|
let task = {
|
||||||
|
let id = id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _claim = claimed;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
panic!("reconcile_migration_now blew up on '{}'", id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(task.await.is_err(), "the task was supposed to panic");
|
||||||
|
|
||||||
|
let after = claim_reconcile_retry(&id);
|
||||||
|
assert!(
|
||||||
|
after.is_some(),
|
||||||
|
"a panicking reconcile stranded the claim — this project can never be \
|
||||||
|
deferred again for the rest of the process"
|
||||||
|
);
|
||||||
|
drop(after);
|
||||||
|
|
||||||
|
// The other half of the same failure: a task that is simply dropped
|
||||||
|
// mid-flight, which is every in-flight task at shutdown.
|
||||||
|
let claimed = claim_reconcile_retry(&id).expect("released above");
|
||||||
|
let never_finishes = tokio::spawn(async move {
|
||||||
|
let _claim = claimed;
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
never_finishes.abort();
|
||||||
|
let _ = never_finishes.await;
|
||||||
|
assert!(
|
||||||
|
claim_reconcile_retry(&id).is_some(),
|
||||||
|
"a dropped task stranded the claim"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ pub mod help_commands;
|
|||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
pub mod migration_commands;
|
pub mod migration_commands;
|
||||||
|
pub mod notes_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod settings_export_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_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)
|
||||||
|
}
|
||||||
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
|||||||
Ok(state.settings_store.get())
|
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]
|
#[tauri::command]
|
||||||
pub async fn update_settings(
|
pub async fn update_settings(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AppSettings, String> {
|
) -> Result<AppSettings, String> {
|
||||||
let before = state.settings_store.get();
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
validate_settings_update(&before, &settings)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// 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 => {
|
GatewayAction::StopIfRunning => {
|
||||||
log::info!("Model gateway disabled in settings — stopping the container");
|
log::info!("Model gateway disabled in settings — stopping the container");
|
||||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
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 => {
|
GatewayAction::RestartIfRunning => {
|
||||||
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pull_image(
|
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
image_name: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
docker::pull_image(&image_name, move |msg| {
|
docker::pull_image(&image_name, move |msg| {
|
||||||
let _ = app_handle.emit("image-pull-progress", msg);
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
@@ -302,7 +362,10 @@ mod tests {
|
|||||||
let before = enabled_gateway();
|
let before = enabled_gateway();
|
||||||
let mut after = before.clone();
|
let mut after = before.clone();
|
||||||
after.enabled = false;
|
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 true when it was already off — a stray running container is
|
||||||
// still a container that shouldn't be up.
|
// still a container that shouldn't be up.
|
||||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,31 +196,64 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
host_path: String,
|
host_path: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||||
|
// 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`.
|
||||||
|
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?;
|
||||||
|
|
||||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
let meta = tokio::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
// 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
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
// tar before upload, so cap the size of a dropped file.
|
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
||||||
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
// with the code that does the reading, which re-applies it to the open
|
||||||
|
// descriptor — this check is here only so the refusal reads like a sentence
|
||||||
|
// instead of arriving after a 300 MB read.
|
||||||
|
use crate::docker::exec::MAX_DROP_BYTES;
|
||||||
if meta.len() > MAX_DROP_BYTES {
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
return Err(format!(
|
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),
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
MAX_DROP_BYTES / (1024 * 1024)
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let base = std::path::Path::new(&host_path)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| "dropped-file".to_string());
|
|
||||||
|
|
||||||
// Ensure the destination directory exists rather than relying on Docker's
|
// Ensure the destination directory exists rather than relying on Docker's
|
||||||
// archive extractor to create the parent for the uploaded tar entry.
|
// archive extractor to create the parent for the uploaded tar entry.
|
||||||
@@ -231,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let file_name = format!("triple-c-drops/{}", base);
|
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]
|
#[tauri::command]
|
||||||
@@ -283,3 +322,37 @@ pub async fn stop_audio_bridge(
|
|||||||
state.exec_manager.close_session(&audio_session_id).await;
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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"`).
|
||||||
|
#[test]
|
||||||
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||||
|
use crate::commands::file_commands::host_upload_name;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
||||||
|
"latest.log"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
host_upload_name("/home/u/Downloads/").is_err(),
|
||||||
|
"a directory is not a file to drop"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
host_upload_name("/home/u/..").is_err(),
|
||||||
|
"the name becomes a tar entry, a container path and an argv element"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
|||||||
const GHCR_TOKEN_URL: &str =
|
const GHCR_TOKEN_URL: &str =
|
||||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
"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]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
&[".AppImage", ".deb", ".rpm"]
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter releases that have at least one asset matching the current platform
|
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||||
let platform_releases: Vec<&GitHubRelease> = releases
|
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||||
.iter()
|
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||||
.filter(|r| {
|
// so a preview build and the release it precedes compile to the identical
|
||||||
r.assets.iter().any(|a| {
|
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
// 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
|
||||||
.collect();
|
// 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
|
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
Some(release) => {
|
||||||
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, _)) => {
|
|
||||||
// Only include assets matching the current platform
|
// Only include assets matching the current platform
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.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)
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
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))
|
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.
|
/// Check whether a newer container image is available in the registry.
|
||||||
///
|
///
|
||||||
/// Compares the local image digest with the remote registry digest using the
|
/// Compares the local image digest with the remote registry digest using the
|
||||||
|
|||||||
@@ -301,21 +301,10 @@ impl ExecSessionManager {
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
// Build a tar archive in memory containing the file
|
// Owned by the container user, stamped now: a default tar header would
|
||||||
let mut tar_buf = Vec::new();
|
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||||
{
|
let (uid, gid) = container_user_ids(container_id).await;
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||||
let mut header = tar::Header::new_gnu();
|
|
||||||
header.set_size(data.len() as u64);
|
|
||||||
header.set_mode(0o644);
|
|
||||||
header.set_cksum();
|
|
||||||
builder
|
|
||||||
.append_data(&mut header, file_name, data)
|
|
||||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
|
||||||
builder
|
|
||||||
.finish()
|
|
||||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.upload_to_container(
|
.upload_to_container(
|
||||||
@@ -333,40 +322,85 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
/// Ceiling on one host file packed into a container upload.
|
||||||
|
///
|
||||||
|
/// The file goes through host RAM twice — once as bytes, once inside the tar —
|
||||||
|
/// so this is a memory bound, and it is checked against the *descriptor* that
|
||||||
|
/// was opened rather than a `metadata` call that described whatever the path
|
||||||
|
/// meant a moment earlier.
|
||||||
|
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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(
|
pub async fn upload_host_file_to_container(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
host_path: &str,
|
host_path: &str,
|
||||||
|
dest_dir: &str,
|
||||||
dest_name: &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> {
|
) -> Result<String, String> {
|
||||||
let host_path = host_path.to_string();
|
let host_path = host_path.to_string();
|
||||||
let dest_name = dest_name.to_string();
|
let dest_name = dest_name.to_string();
|
||||||
let dest_for_blk = dest_name.clone();
|
let dest_for_blk = dest_name.clone();
|
||||||
|
let mtime = now_epoch_secs();
|
||||||
|
|
||||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
let data = std::fs::read(&host_path)
|
// 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. 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))?;
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
crate::commands::file_commands::verify_opened_path(
|
||||||
{
|
&file,
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
std::path::Path::new(&host_path),
|
||||||
let mut header = tar::Header::new_gnu();
|
)?;
|
||||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
let mut data = Vec::new();
|
||||||
header.set_size(data.len() as u64);
|
std::io::Read::read_to_end(
|
||||||
header.set_mode(0o644);
|
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||||
header.set_cksum();
|
&mut data,
|
||||||
builder
|
)
|
||||||
.append_data(&mut header, &dest_for_blk, &data[..])
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
if data.len() as u64 > MAX_DROP_BYTES {
|
||||||
builder
|
return Err(format!(
|
||||||
.finish()
|
"File too large to upload (limit {} MB)",
|
||||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(tar_buf)
|
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||||
@@ -376,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
|||||||
.upload_to_container(
|
.upload_to_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(UploadToContainerOptions {
|
Some(UploadToContainerOptions {
|
||||||
path: "/tmp".to_string(),
|
path: dest_dir.to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
tar_buf.into(),
|
tar_buf.into(),
|
||||||
@@ -384,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
.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`.
|
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||||
@@ -402,20 +446,10 @@ pub async fn upload_bytes_to_container(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
|
||||||
{
|
// is read back as root. The mtime still gets stamped so the file doesn't
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
// read as 1970.
|
||||||
let mut header = tar::Header::new_gnu();
|
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||||
header.set_size(data.len() as u64);
|
|
||||||
header.set_mode(mode);
|
|
||||||
header.set_cksum();
|
|
||||||
builder
|
|
||||||
.append_data(&mut header, file_name, data)
|
|
||||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
|
||||||
builder
|
|
||||||
.finish()
|
|
||||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.upload_to_container(
|
.upload_to_container(
|
||||||
@@ -432,6 +466,74 @@ pub async fn upload_bytes_to_container(
|
|||||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an in-memory tar archive holding a single regular file.
|
||||||
|
///
|
||||||
|
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
|
||||||
|
/// them and Docker's archive extractor honours the header verbatim: a header
|
||||||
|
/// left at the defaults lands the file inside the container as `root:root`
|
||||||
|
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
|
||||||
|
/// listing. Callers that upload on a user's behalf should pass the container
|
||||||
|
/// user's ids from [`container_user_ids`].
|
||||||
|
pub fn build_single_file_tar(
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
mode: u32,
|
||||||
|
uid: u64,
|
||||||
|
gid: u64,
|
||||||
|
mtime: u64,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(mode);
|
||||||
|
header.set_uid(uid);
|
||||||
|
header.set_gid(gid);
|
||||||
|
header.set_mtime(mtime);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(tar_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since the Unix epoch, for a tar header mtime.
|
||||||
|
pub fn now_epoch_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numeric uid/gid of the container's `claude` user.
|
||||||
|
///
|
||||||
|
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
|
||||||
|
/// ids on Unix so bind-mounted project files stay writable, and deliberately
|
||||||
|
/// does not on Windows. So the only reliable answer comes from asking the
|
||||||
|
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||||
|
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||||
|
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||||
|
let out = exec_oneshot_limited(
|
||||||
|
container_id,
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||||
|
256,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||||
|
match (ids.next(), ids.next()) {
|
||||||
|
(Some(uid), Some(gid)) => (uid, gid),
|
||||||
|
_ => (1000, 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ceiling on how much container output a one-shot exec will buffer into the
|
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||||
/// host process.
|
/// host process.
|
||||||
///
|
///
|
||||||
@@ -450,14 +552,31 @@ pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
|||||||
/// past anything genuine, far short of a problem.
|
/// past anything genuine, far short of a problem.
|
||||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||||
|
|
||||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||||
/// limit is exceeded, at which point the caller must stop reading.
|
///
|
||||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
/// The byte count on its own is a fact about the transport, not about what the
|
||||||
|
/// user did — "Command output exceeded 8388608 bytes" is not a sentence anybody
|
||||||
|
/// can act on. A caller that knows what it was reading can recognise this and
|
||||||
|
/// say the useful thing instead; see `list_container_files`, where the real
|
||||||
|
/// cause is a directory with more entries than the panel can render.
|
||||||
|
pub const OUTPUT_LIMIT_MARKER: &str = "OUTPUT_LIMIT";
|
||||||
|
|
||||||
|
/// Append to `buf` while it stays inside `limit`, returning the range the chunk
|
||||||
|
/// now occupies. `None` once the limit is exceeded, at which point the caller
|
||||||
|
/// must stop reading — and nothing is appended, so a caller that ignored the
|
||||||
|
/// answer cannot parse a half-read document.
|
||||||
|
///
|
||||||
|
/// Bytes rather than `str` on purpose: Docker frames a stream wherever it
|
||||||
|
/// likes, so a chunk boundary can fall inside a UTF-8 sequence. Decoding each
|
||||||
|
/// chunk on its own turned that into two replacement characters in the middle
|
||||||
|
/// of a filename; the decode happens once, at the end, over the whole buffer.
|
||||||
|
fn push_capped(buf: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Option<(usize, usize)> {
|
||||||
if buf.len() + chunk.len() > limit {
|
if buf.len() + chunk.len() > limit {
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
buf.push_str(chunk);
|
let start = buf.len();
|
||||||
true
|
buf.extend_from_slice(chunk);
|
||||||
|
Some((start, buf.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
@@ -521,6 +640,65 @@ pub async fn exec_oneshot_as(
|
|||||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||||
|
///
|
||||||
|
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||||
|
/// every existing caller reads, and the right one for surfacing "why did that
|
||||||
|
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||||
|
/// caller that is *parsing* output can have just that without the buffer being
|
||||||
|
/// held twice.
|
||||||
|
struct OneshotOutput {
|
||||||
|
combined: Vec<u8>,
|
||||||
|
stdout_ranges: Vec<(usize, usize)>,
|
||||||
|
exit_code: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OneshotOutput {
|
||||||
|
/// Everything the command printed, in the order it printed it.
|
||||||
|
fn text(&self) -> String {
|
||||||
|
String::from_utf8_lossy(&self.combined).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||||
|
/// mid-record is a parse error at best.
|
||||||
|
fn stdout(&self) -> String {
|
||||||
|
let mut out = Vec::with_capacity(self.combined.len());
|
||||||
|
for (start, end) in &self.stdout_ranges {
|
||||||
|
out.extend_from_slice(&self.combined[*start..*end]);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||||
|
fn stderr(&self) -> String {
|
||||||
|
let mut out = Vec::with_capacity(self.combined.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for (start, end) in &self.stdout_ranges {
|
||||||
|
out.extend_from_slice(&self.combined[cursor..*start]);
|
||||||
|
cursor = *end;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&self.combined[cursor..]);
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`exec_oneshot_as`] with the two streams kept apart, for callers that parse
|
||||||
|
/// stdout.
|
||||||
|
///
|
||||||
|
/// `find`'s own diagnostics ("Permission denied") used to arrive inside the
|
||||||
|
/// records its `-printf` was emitting. GNU `find` escapes tabs and newlines in
|
||||||
|
/// those messages, so the listing parser held — but "the parser holds" is not
|
||||||
|
/// the same as "the input is trustworthy", and the fix costs one enum match.
|
||||||
|
pub async fn exec_oneshot_streams_as(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, String, i64), String> {
|
||||||
|
let out = exec_oneshot_raw(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||||
|
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
async fn exec_oneshot_inner(
|
async fn exec_oneshot_inner(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
user: &str,
|
user: &str,
|
||||||
@@ -528,6 +706,17 @@ async fn exec_oneshot_inner(
|
|||||||
env: Vec<String>,
|
env: Vec<String>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<(String, i64), String> {
|
) -> Result<(String, i64), String> {
|
||||||
|
let out = exec_oneshot_raw(container_id, user, cmd, env, limit).await?;
|
||||||
|
Ok((out.text(), out.exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_oneshot_raw(
|
||||||
|
container_id: &str,
|
||||||
|
user: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<OneshotOutput, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -550,22 +739,31 @@ async fn exec_oneshot_inner(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
let mut combined = String::new();
|
let mut combined: Vec<u8> = Vec::new();
|
||||||
|
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||||
match result {
|
match result {
|
||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||||
if !push_capped(&mut combined, &chunk, limit) {
|
let bytes = data.into_bytes();
|
||||||
|
match push_capped(&mut combined, &bytes, limit) {
|
||||||
|
Some(range) => {
|
||||||
|
if from_stdout {
|
||||||
|
stdout_ranges.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Stop reading rather than truncate silently: every
|
// Stop reading rather than truncate silently: every
|
||||||
// caller parses this output, and a half-read
|
// caller parses this output, and a half-read
|
||||||
// manifest or JSON array is worse than an error.
|
// manifest or JSON array is worse than an error.
|
||||||
// Dropping `output` kills the exec's stream.
|
// Dropping `output` kills the exec's stream.
|
||||||
|
None => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Command output exceeded {} bytes and was abandoned",
|
"{}: Command output exceeded {} bytes and was abandoned",
|
||||||
limit
|
OUTPUT_LIMIT_MARKER, limit
|
||||||
));
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
@@ -577,23 +775,60 @@ async fn exec_oneshot_inner(
|
|||||||
|
|
||||||
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||||
// final exit_code populated yet, so poll until the exec reports finished.
|
// final exit_code populated yet, so poll until the exec reports finished.
|
||||||
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
|
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||||
|
|
||||||
Ok((combined, exit_code))
|
Ok(OneshotOutput {
|
||||||
|
combined,
|
||||||
|
stdout_ranges,
|
||||||
|
exit_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||||
|
///
|
||||||
|
/// `unwrap_or(0)` is how a rename that never happened reported success: callers
|
||||||
|
/// branch on `code != 0`, so an unreadable status silently became "it worked",
|
||||||
|
/// the UI closed its rename box and the file had not moved. An exec whose
|
||||||
|
/// outcome cannot be established has not been established to have succeeded —
|
||||||
|
/// fail closed and let the caller surface it.
|
||||||
|
///
|
||||||
|
/// The `test -e` probe in `rename_container_path` also fails closed under this:
|
||||||
|
/// it propagates the error instead of reading an undeterminable status as
|
||||||
|
/// "the destination does not exist".
|
||||||
|
fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||||
|
code.ok_or_else(|| {
|
||||||
|
"Could not determine whether the command finished (Docker did not report an exit status)"
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||||
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||||
/// doesn't report finished within ~1s — which shouldn't happen once its output
|
/// doesn't report finished within ~5s — which shouldn't happen once its output
|
||||||
/// stream has drained).
|
/// stream has drained).
|
||||||
|
///
|
||||||
|
/// The window is generous because `None` is no longer a shrug: since
|
||||||
|
/// [`require_exit_code`], it fails the whole call. Waiting a few seconds longer
|
||||||
|
/// for a busy daemon to settle costs nothing in the normal case — the loop exits
|
||||||
|
/// on the first poll that reports finished — and it is the difference between a
|
||||||
|
/// spurious "the rename failed" and a real one.
|
||||||
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||||
let docker = get_docker().ok()?;
|
let docker = get_docker().ok()?;
|
||||||
for _ in 0..40 {
|
for _ in 0..200 {
|
||||||
match docker.inspect_exec(exec_id).await {
|
match docker.inspect_exec(exec_id).await {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
if info.running != Some(true) {
|
if info.running != Some(true) {
|
||||||
// Finished: use the reported code (default 0 if somehow absent).
|
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||||
return Some(info.exit_code.unwrap_or(0));
|
// 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,
|
Err(_) => return None,
|
||||||
@@ -607,31 +842,96 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||||
|
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||||
|
let mut combined = Vec::new();
|
||||||
|
let mut stdout_ranges = Vec::new();
|
||||||
|
for (from_stdout, bytes) in frames {
|
||||||
|
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||||
|
if *from_stdout {
|
||||||
|
stdout_ranges.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OneshotOutput {
|
||||||
|
combined,
|
||||||
|
stdout_ranges,
|
||||||
|
exit_code: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_under_the_limit_is_buffered_whole() {
|
fn output_under_the_limit_is_buffered_whole() {
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(push_capped(&mut buf, "hello ", 16));
|
assert_eq!(push_capped(&mut buf, b"hello ", 16), Some((0, 6)));
|
||||||
assert!(push_capped(&mut buf, "world", 16));
|
assert_eq!(push_capped(&mut buf, b"world", 16), Some((6, 11)));
|
||||||
assert_eq!(buf, "hello world");
|
assert_eq!(buf, b"hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||||
// The abandoned chunk must not land in the buffer either: a caller that
|
// The abandoned chunk must not land in the buffer either: a caller that
|
||||||
// ignored the error would otherwise parse a half-read document.
|
// ignored the error would otherwise parse a half-read document.
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(push_capped(&mut buf, "0123456789", 12));
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_some());
|
||||||
assert!(!push_capped(&mut buf, "0123456789", 12));
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_none());
|
||||||
assert_eq!(buf, "0123456789");
|
assert_eq!(buf, b"0123456789");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_single_oversized_chunk_is_refused() {
|
fn a_single_oversized_chunk_is_refused() {
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(!push_capped(&mut buf, "0123456789", 4));
|
assert!(push_capped(&mut buf, b"0123456789", 4).is_none());
|
||||||
assert!(buf.is_empty());
|
assert!(buf.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_character_split_across_two_frames_survives_the_decode() {
|
||||||
|
// Docker frames a stream wherever it likes, and a filename is where
|
||||||
|
// that shows: decoding each chunk on its own turned the two halves of
|
||||||
|
// `ü` into two replacement characters in the middle of a name.
|
||||||
|
let out = collect(&[(true, &[0xc3]), (true, &[0xbc, b'.', b't', b'x', b't'])]);
|
||||||
|
assert_eq!(out.stdout(), "ü.txt");
|
||||||
|
assert_eq!(out.text(), "ü.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_diagnostic_never_lands_in_the_stream_a_caller_parses() {
|
||||||
|
// `find`'s "Permission denied" used to arrive inside the records its
|
||||||
|
// `-printf` was emitting. Arrival order is still available for the
|
||||||
|
// error message; the parser gets stdout alone.
|
||||||
|
let out = collect(&[
|
||||||
|
(true, b"first"),
|
||||||
|
(false, b"find: /x: Permission denied\n"),
|
||||||
|
(true, b"second"),
|
||||||
|
]);
|
||||||
|
assert_eq!(out.stdout(), "firstsecond");
|
||||||
|
assert_eq!(out.stderr(), "find: /x: Permission denied\n");
|
||||||
|
assert_eq!(out.text(), "firstfind: /x: Permission denied\nsecond");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_output_limit_refusal_is_marked_so_a_caller_can_reword_it() {
|
||||||
|
// "Command output exceeded 8388608 bytes" is a fact about a buffer.
|
||||||
|
// The marker is what lets `list_container_files` say "too many entries"
|
||||||
|
// instead, which is the thing that actually happened.
|
||||||
|
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||||
|
let refusal = format!(
|
||||||
|
"{}: Command output exceeded {} bytes and was abandoned",
|
||||||
|
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||||
|
);
|
||||||
|
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undeterminable_exit_status_is_an_error_not_a_zero() {
|
||||||
|
// The bug this guards: `unwrap_or(0)` made every caller that branches on
|
||||||
|
// `code != 0` — rename, mkdir — report success for an exec whose outcome
|
||||||
|
// nobody could read.
|
||||||
|
assert_eq!(require_exit_code(Some(0)).unwrap(), 0);
|
||||||
|
assert_eq!(require_exit_code(Some(1)).unwrap(), 1);
|
||||||
|
assert!(require_exit_code(None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||||
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
||||||
@@ -640,4 +940,25 @@ mod tests {
|
|||||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[
|
|||||||
("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"),
|
("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"),
|
||||||
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
||||||
("/opt/mission-control", "Mission Control (Flight Control)"),
|
("/opt/mission-control", "Mission Control (Flight Control)"),
|
||||||
|
("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
|
||||||
|
("/opt/triple-c-skills", "Bundled skills for the VPN Support toggle (PIA VPN)"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Headroom demanded on Docker's storage backend on top of the measured
|
/// Headroom demanded on Docker's storage backend on top of the measured
|
||||||
@@ -367,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
|||||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||||
let mut out: Vec<String> = paths
|
let mut out: Vec<String> = paths
|
||||||
.iter()
|
.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))
|
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
@@ -710,13 +721,83 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
||||||
let id = created.id;
|
|
||||||
|
|
||||||
let result = run_throwaway_inner(&id).await;
|
// From here on the container's removal is owned by a guard rather than by
|
||||||
|
// the statement that used to sit after the await below. A plain statement
|
||||||
|
// only runs if this future is *polled to completion*: an `Err(...)?` was
|
||||||
|
// already handled, but a **dropped** future — the app quitting mid-flight,
|
||||||
|
// a timeout, any `select!` that loses — skipped it silently and left a
|
||||||
|
// container behind holding a multi-gigabyte base image open. That image is
|
||||||
|
// then unsweepable (removal is deliberately unforced) and there is nothing
|
||||||
|
// in the UI that would ever mention it.
|
||||||
|
let guard = ProbeContainerGuard::new(created.id);
|
||||||
|
|
||||||
if let Err(e) = docker
|
let result = run_throwaway_inner(guard.id()).await;
|
||||||
|
|
||||||
|
// The happy path still removes it *synchronously*, so a caller that goes on
|
||||||
|
// to `docker rmi` the image it probed does not race the removal.
|
||||||
|
guard.remove_now().await;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the lifetime of a probe container.
|
||||||
|
///
|
||||||
|
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
|
||||||
|
/// safety net for the abnormal one: it cannot await, so it hands the removal to
|
||||||
|
/// a detached task. That covers a dropped future while the process lives; it
|
||||||
|
/// cannot cover the process dying, which is what
|
||||||
|
/// [`reap_probe_containers`] is for.
|
||||||
|
struct ProbeContainerGuard {
|
||||||
|
id: String,
|
||||||
|
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeContainerGuard {
|
||||||
|
fn new(id: String) -> Self {
|
||||||
|
Self { id, armed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Disarm after the await, never before it.** Clearing `armed` first
|
||||||
|
/// looked equivalent and was the exact inverse of this guard's purpose: on
|
||||||
|
/// the one path it exists for — this future being dropped part-way through
|
||||||
|
/// the removal — `Drop` then saw a disarmed guard and did nothing, so the
|
||||||
|
/// container survived with no background removal queued behind it. Setting
|
||||||
|
/// it afterwards means a cancelled `remove_now` falls back to `Drop`'s
|
||||||
|
/// detached removal, and only a removal that actually completed disarms.
|
||||||
|
async fn remove_now(mut self) {
|
||||||
|
remove_probe_container(&self.id).await;
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ProbeContainerGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.armed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let id = std::mem::take(&mut self.id);
|
||||||
|
log::warn!("Probe container {} was abandoned; removing it in the background", id);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
remove_probe_container(&id).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-remove one probe container. Missing is success — the point is that the
|
||||||
|
/// container is gone.
|
||||||
|
async fn remove_probe_container(id: &str) {
|
||||||
|
let Ok(docker) = get_docker() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
&id,
|
id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
force: true,
|
force: true,
|
||||||
v: true,
|
v: true,
|
||||||
@@ -725,11 +806,95 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("Failed to remove probe container {}: {}", id, e);
|
Ok(())
|
||||||
|
| Err(bollard::errors::Error::DockerResponseServerError {
|
||||||
|
status_code: 404, ..
|
||||||
|
}) => {}
|
||||||
|
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
/// Remove probe containers left behind by a previous run of the app.
|
||||||
|
///
|
||||||
|
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
|
||||||
|
/// crash, but until now nothing ever went looking. One leftover probe pins the
|
||||||
|
/// base image it was created from — several gigabytes that
|
||||||
|
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
|
||||||
|
/// touch, with no way for the user to find out why.
|
||||||
|
///
|
||||||
|
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
|
||||||
|
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
|
||||||
|
/// right now it belongs to this process — and this runs before any migration
|
||||||
|
/// can be started, so there is none to interrupt.
|
||||||
|
///
|
||||||
|
/// **Except that "belongs to this process" is not something this can know.**
|
||||||
|
/// The filter is a label, and labels are daemon-wide: a second copy of the app
|
||||||
|
/// migrating a project on the same daemon has probe containers carrying exactly
|
||||||
|
/// this label, and force-removing one mid-manifest-capture fails that
|
||||||
|
/// migration. In-process state cannot see the other instance, so the only
|
||||||
|
/// available brake is age — [`PROBE_REAP_MIN_AGE_SECS`]. A probe runs a `df`, an
|
||||||
|
/// `apt-get update` or a `find` over a root filesystem; none of those is a
|
||||||
|
/// multi-minute job, so anything younger than the gate is far more likely to be
|
||||||
|
/// someone's live probe than a leftover, and a leftover simply waits for the
|
||||||
|
/// next start.
|
||||||
|
pub async fn reap_probe_containers() {
|
||||||
|
let Ok(docker) = get_docker() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"label".to_string(),
|
||||||
|
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = match docker
|
||||||
|
.list_containers(Some(bollard::container::ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(list) => list,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Could not list leftover probe containers: {}", e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
for c in containers {
|
||||||
|
// `created` is a unix timestamp; a summary without one is treated as
|
||||||
|
// too young to touch, because unknown is never permission.
|
||||||
|
let age = c.created.map(|created| now - created);
|
||||||
|
match age {
|
||||||
|
Some(age) if age >= PROBE_REAP_MIN_AGE_SECS => {}
|
||||||
|
_ => {
|
||||||
|
log::info!(
|
||||||
|
"Leaving migration probe container {} alone — it is younger than {} minutes, \
|
||||||
|
so it may belong to another Triple-C instance's live migration",
|
||||||
|
c.id.as_deref().unwrap_or("<unknown>"),
|
||||||
|
PROBE_REAP_MIN_AGE_SECS / 60
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(id) = c.id {
|
||||||
|
log::info!("Removing leftover migration probe container {}", id);
|
||||||
|
remove_probe_container(&id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How old a `triple-c.probe=migration` container must be before
|
||||||
|
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||||
|
///
|
||||||
|
/// The label is daemon-wide and this process cannot tell its own leftovers from
|
||||||
|
/// another instance's live probe, so this is the whole guard. Generous against
|
||||||
|
/// the longest probe there is (an `apt-get update` inside a throwaway container
|
||||||
|
/// on a slow link) and still short enough that a crashed run's probe stops
|
||||||
|
/// pinning a multi-gigabyte base image within the hour.
|
||||||
|
pub const PROBE_REAP_MIN_AGE_SECS: i64 = 30 * 60;
|
||||||
|
|
||||||
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -908,6 +1073,210 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
|
|||||||
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
|
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a rollback pin may sit with no migration record behind it before
|
||||||
|
/// [`reap_stale_migration_pins`] drops the tag.
|
||||||
|
///
|
||||||
|
/// Two weeks, chosen to be far longer than anyone deliberates over a base
|
||||||
|
/// update and far shorter than "forever", which is what it was.
|
||||||
|
///
|
||||||
|
/// **Measured from when the record went missing, not from the tag.** See
|
||||||
|
/// [`pin_is_reapable`] and
|
||||||
|
/// [`crate::storage::migration_store::note_ownerless_since`].
|
||||||
|
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
|
||||||
|
|
||||||
|
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
|
||||||
|
///
|
||||||
|
/// `None` for anything that is not one of ours — a tag that merely *starts*
|
||||||
|
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
|
||||||
|
/// rather than guessed at, because the consequence of guessing wrong is
|
||||||
|
/// deleting the only copy of somebody's system layer.
|
||||||
|
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
|
let stamp = tag.strip_prefix("pre-migration-")?;
|
||||||
|
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
|
||||||
|
Some(naive.and_utc())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
|
||||||
|
///
|
||||||
|
/// `None` when the reference is not a snapshot repo at all.
|
||||||
|
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
|
||||||
|
let (repo, tag) = split_image_ref(reference);
|
||||||
|
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
|
||||||
|
if project_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((project_id, tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a rollback pin is safe to drop, given whether the project it belongs
|
||||||
|
/// to still has a migration record and how long it has been without one.
|
||||||
|
///
|
||||||
|
/// Pure so the decision can be tested without a daemon. The order of the
|
||||||
|
/// conditions is the point: **a pin whose migration is still awaiting
|
||||||
|
/// confirmation is never reaped at any age**, because it is the only copy of
|
||||||
|
/// the rollback target and the user has not yet said they are happy with the
|
||||||
|
/// new base.
|
||||||
|
///
|
||||||
|
/// ## `ownerless_since`, and why it is not the tag's timestamp
|
||||||
|
///
|
||||||
|
/// This used to compute the age from `parse_rollback_tag(tag)` — the instant
|
||||||
|
/// the migration *started*. A migration is allowed to sit at
|
||||||
|
/// `awaiting-confirmation` for as long as the user likes; that is what
|
||||||
|
/// `keep_rollback` is for. A project parked there for a month whose record is
|
||||||
|
/// then lost had a tag a month old, so the pin was reapable on the very next
|
||||||
|
/// check and the startup sweep deleted the image immediately after. The
|
||||||
|
/// fourteen days were nominal: the real grace period for the case the constant
|
||||||
|
/// was written for was zero.
|
||||||
|
///
|
||||||
|
/// So the clock starts when the claim was lost, which is recorded by
|
||||||
|
/// [`crate::storage::migration_store::note_ownerless_since`] the first time a
|
||||||
|
/// reaper notices. `None` means no reaper has recorded a sighting yet, and that
|
||||||
|
/// is **not** "sighted now": returning false there is what gives a pin its
|
||||||
|
/// first full fourteen days instead of none.
|
||||||
|
///
|
||||||
|
/// ## Clock skew
|
||||||
|
///
|
||||||
|
/// A `now` earlier than `ownerless_since` — a host clock that ran fast and was
|
||||||
|
/// corrected, or a data directory carried between machines — yields a negative
|
||||||
|
/// elapsed time. That is treated as not reapable, and the marker writer
|
||||||
|
/// re-anchors it, rather than letting a negative `num_days()` mean "never" or
|
||||||
|
/// an inflated one mean "immediately".
|
||||||
|
pub fn pin_is_reapable(
|
||||||
|
tag: &str,
|
||||||
|
has_migration_record: bool,
|
||||||
|
ownerless_since: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
now: &chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> bool {
|
||||||
|
if has_migration_record {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Still required: the tag has to be one of ours. A hand-made
|
||||||
|
// `pre-migration-keepme` is somebody's deliberate pin and is never guessed
|
||||||
|
// at, whatever a marker beside it says.
|
||||||
|
if parse_rollback_tag(tag).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(since) = ownerless_since else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let elapsed = *now - since;
|
||||||
|
elapsed >= chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
|
||||||
|
/// claims any more, so the images behind them become sweepable.
|
||||||
|
///
|
||||||
|
/// ## Why this scans tags instead of reading the records
|
||||||
|
///
|
||||||
|
/// Every other path to a rollback pin starts from
|
||||||
|
/// `migration_store::load`, and `load` reports an unparseable state file as
|
||||||
|
/// *absent* — so a single corrupt record used to strand a 4–12 GB image that no
|
||||||
|
/// code could ever name again. Confirming or rolling back both remove the
|
||||||
|
/// record and drop the tag together, so a `pre-migration-*` tag with no record
|
||||||
|
/// beside it is by definition one that lost its owner: a crash between the two,
|
||||||
|
/// a record that was deleted by hand, or the corrupt-file case.
|
||||||
|
///
|
||||||
|
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
|
||||||
|
/// corrupt record aside (see `migration_store::load`) is what stops that case
|
||||||
|
/// from being permanently invisible here too.
|
||||||
|
///
|
||||||
|
/// ## Why it only untags
|
||||||
|
///
|
||||||
|
/// Dropping the tag turns the image dangling, and it is already labelled
|
||||||
|
/// `triple-c.managed=true` because `docker commit` created it — so
|
||||||
|
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
|
||||||
|
/// safety conditions, with the daemon's "still in use by a container" refusal
|
||||||
|
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
|
||||||
|
pub async fn reap_stale_migration_pins() -> usize {
|
||||||
|
use bollard::image::ListImagesOptions;
|
||||||
|
|
||||||
|
let Ok(docker) = get_docker() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `reference` matches against `repo:tag`, so this asks the daemon for
|
||||||
|
// exactly the shape [`rollback_tag`] produces and nothing else.
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"reference".to_string(),
|
||||||
|
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let images = match docker
|
||||||
|
.list_images(Some(ListImagesOptions {
|
||||||
|
all: false,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(images) => images,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Could not list rollback pins: {}", e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let mut reaped = 0usize;
|
||||||
|
|
||||||
|
for summary in images {
|
||||||
|
for reference in &summary.repo_tags {
|
||||||
|
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Filesystem presence, not `load`: a record we cannot parse must
|
||||||
|
// still count as "somebody may want this back".
|
||||||
|
let has_record =
|
||||||
|
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
|
||||||
|
if has_record {
|
||||||
|
// Owned again (or still owned): throw away any grace clock a
|
||||||
|
// previous pass started, so a pin that loses its record twice
|
||||||
|
// gets a fresh fourteen days rather than inheriting a stale one.
|
||||||
|
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only a *well-formed* pin gets a marker written for it — a tag
|
||||||
|
// that is not one of ours is left entirely alone, files included.
|
||||||
|
if parse_rollback_tag(&tag).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Records the first sighting when there is none, which is why this
|
||||||
|
// returns `None` on that pass and the pin survives it.
|
||||||
|
//
|
||||||
|
// A `save` can land between the `has_record` above and this write,
|
||||||
|
// which would plant a tombstone dated *now* behind a perfectly
|
||||||
|
// valid record — invisible until that record is legitimately lost,
|
||||||
|
// at which point the pin is already past its grace period and is
|
||||||
|
// reaped on the first check. `note_ownerless_since` re-asks
|
||||||
|
// `has_record` after the write and removes the marker again; the
|
||||||
|
// reasoning for why that closes the window is on it.
|
||||||
|
let ownerless_since =
|
||||||
|
crate::storage::migration_store::note_ownerless_since(&project_id, &tag, &now);
|
||||||
|
if !pin_is_reapable(&tag, has_record, ownerless_since, &now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match untag_image(reference).await {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||||
|
log::info!(
|
||||||
|
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it since {}, more than {} days",
|
||||||
|
reference,
|
||||||
|
summary.size as f64 / 1_073_741_824.0,
|
||||||
|
ownerless_since
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
STALE_PIN_MAX_AGE_DAYS,
|
||||||
|
);
|
||||||
|
reaped += 1;
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reaped
|
||||||
|
}
|
||||||
|
|
||||||
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||||
match image.rsplit_once(':') {
|
match image.rsplit_once(':') {
|
||||||
@@ -1008,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||||
@@ -1620,4 +2013,137 @@ mod tests {
|
|||||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(12, 0, 0)
|
||||||
|
.unwrap()
|
||||||
|
.and_utc()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rollback_tag_round_trips_through_its_parser() {
|
||||||
|
let made = at(2026, 3, 14);
|
||||||
|
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_real_rollback_tag_parses() {
|
||||||
|
assert_eq!(parse_rollback_tag("latest"), None);
|
||||||
|
assert_eq!(parse_rollback_tag("pre-migration-"), None);
|
||||||
|
// Looks like ours but carries no timestamp we produced. Guessing here
|
||||||
|
// would mean deleting the only copy of somebody's system layer.
|
||||||
|
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
|
||||||
|
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_reference_yields_its_project_id() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
|
||||||
|
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
|
||||||
|
);
|
||||||
|
// Not ours: a base image, and a repo that merely shares a prefix.
|
||||||
|
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
|
||||||
|
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
|
||||||
|
// The one rule that cannot bend: while a migration record exists, this
|
||||||
|
// image is the only copy of the rollback target and the user has not
|
||||||
|
// yet said they are happy on the new base.
|
||||||
|
let ancient = rollback_tag(&at(2020, 1, 1));
|
||||||
|
assert!(!pin_is_reapable(
|
||||||
|
&ancient,
|
||||||
|
true,
|
||||||
|
Some(at(2020, 1, 1)),
|
||||||
|
&at(2026, 8, 23)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
|
||||||
|
let tag = rollback_tag(&at(2026, 8, 1));
|
||||||
|
// The clock runs from when the record went missing, which here is well
|
||||||
|
// after the migration started.
|
||||||
|
let lost = at(2026, 8, 10);
|
||||||
|
assert!(!pin_is_reapable(&tag, false, Some(lost), &at(2026, 8, 11)));
|
||||||
|
assert!(!pin_is_reapable(
|
||||||
|
&tag,
|
||||||
|
false,
|
||||||
|
Some(lost),
|
||||||
|
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS) - chrono::Duration::seconds(1))
|
||||||
|
));
|
||||||
|
assert!(pin_is_reapable(
|
||||||
|
&tag,
|
||||||
|
false,
|
||||||
|
Some(lost),
|
||||||
|
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_grace_period_runs_from_the_lost_record_not_from_the_tag() {
|
||||||
|
// The bug this replaced, stated as a test. A migration parked at
|
||||||
|
// `awaiting-confirmation` for a month — supported, that is what
|
||||||
|
// `keep_rollback` is for — whose record is then lost had a
|
||||||
|
// month-old tag, so the old rule made its pin reapable on the very
|
||||||
|
// next app start with the startup sweep deleting the image two lines
|
||||||
|
// later. Zero grace, on the one case the fourteen days exist for.
|
||||||
|
let started = at(2026, 6, 1);
|
||||||
|
let tag = rollback_tag(&started);
|
||||||
|
let record_lost = at(2026, 7, 1);
|
||||||
|
let noticed_immediately_after = record_lost + chrono::Duration::minutes(5);
|
||||||
|
assert!(
|
||||||
|
!pin_is_reapable(&tag, false, Some(record_lost), ¬iced_immediately_after),
|
||||||
|
"a tag a month old must still get its full grace period once orphaned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsighted_pin_is_never_reaped_on_the_pass_that_first_sees_it() {
|
||||||
|
// `None` means no reaper has recorded a sighting. Treating that as
|
||||||
|
// "sighted now" would be harmless; treating it as "sighted long ago"
|
||||||
|
// would not, and neither is what it means — the marker is written on
|
||||||
|
// this pass and the pin becomes reapable fourteen days later.
|
||||||
|
let tag = rollback_tag(&at(2020, 1, 1));
|
||||||
|
assert!(!pin_is_reapable(&tag, false, None, &at(2026, 8, 23)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clock_that_ran_backwards_neither_reaps_nor_strands() {
|
||||||
|
// A marker dated after `now`: the host clock was fast and got
|
||||||
|
// corrected, or the data directory came from another machine. A
|
||||||
|
// negative elapsed time must read as "not yet", not as a huge age.
|
||||||
|
let tag = rollback_tag(&at(2026, 1, 1));
|
||||||
|
let marker = at(2026, 9, 1);
|
||||||
|
assert!(!pin_is_reapable(&tag, false, Some(marker), &at(2026, 8, 1)));
|
||||||
|
// The other direction is bounded by the marker rather than by the tag:
|
||||||
|
// a wildly future `now` can only expire a clock that was actually
|
||||||
|
// started, and a pin with no marker (the case above) still cannot be
|
||||||
|
// reaped at all — which is what stops a fast host clock from making
|
||||||
|
// *every* pin on the daemon instantly collectable.
|
||||||
|
assert!(pin_is_reapable(
|
||||||
|
&tag,
|
||||||
|
false,
|
||||||
|
Some(at(2026, 8, 20)),
|
||||||
|
&at(2030, 1, 1)
|
||||||
|
));
|
||||||
|
assert!(!pin_is_reapable(&tag, false, None, &at(2030, 1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tag_we_cannot_date_is_left_alone() {
|
||||||
|
// Even with an ancient ownerless marker sitting beside it: a tag that
|
||||||
|
// merely *starts* `pre-migration-` is somebody's deliberate pin, and
|
||||||
|
// the reaper never writes a marker for one in the first place.
|
||||||
|
let ancient = Some(at(2020, 1, 1));
|
||||||
|
let now = at(2026, 8, 23);
|
||||||
|
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||||
|
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod docker;
|
|||||||
mod install_helper;
|
mod install_helper;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod project_lock;
|
||||||
mod storage;
|
mod storage;
|
||||||
pub mod web_terminal;
|
pub mod web_terminal;
|
||||||
|
|
||||||
@@ -28,6 +29,21 @@ pub struct AppState {
|
|||||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
pub lifecycle: Arc<Lifecycle>,
|
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>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -212,7 +228,6 @@ pub fn run() {
|
|||||||
let lifecycle_setup = lifecycle.clone();
|
let lifecycle_setup = lifecycle.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
@@ -222,6 +237,7 @@ pub fn run() {
|
|||||||
auth_bridge,
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
@@ -235,6 +251,40 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Startup disk housekeeping ────────────────────────────────
|
||||||
|
// Until now the only sweep ran *after* a recreation, so a user who
|
||||||
|
// simply stopped launching a project kept its orphaned snapshot
|
||||||
|
// layers forever, and anything a crash left behind (a probe
|
||||||
|
// container pinning a base image, a rollback pin whose migration
|
||||||
|
// record is gone) had no path back at all. All of it is
|
||||||
|
// read-mostly and finishes in well under a second on an idle daemon,
|
||||||
|
// but they are detached anyway: housekeeping must never delay the
|
||||||
|
// window appearing, and a daemon that is not running yet is a
|
||||||
|
// 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
|
||||||
|
// 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;
|
||||||
|
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
|
// Auto-start web terminal server if enabled in settings
|
||||||
let settings = settings_store_setup.get();
|
let settings = settings_store_setup.get();
|
||||||
if settings.web_terminal.enabled {
|
if settings.web_terminal.enabled {
|
||||||
@@ -328,6 +378,14 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
// This handler fires for *every* window, and what follows stops
|
||||||
|
// containers and exits the process. Only the main window means
|
||||||
|
// that. Secondary windows — the browser view's pop-out — are
|
||||||
|
// closed and reopened freely and must just close.
|
||||||
|
if window.label() != "main" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let state = window.state::<AppState>();
|
let state = window.state::<AppState>();
|
||||||
let lifecycle = state.lifecycle.clone();
|
let lifecycle = state.lifecycle.clone();
|
||||||
|
|
||||||
@@ -403,7 +461,6 @@ pub fn run() {
|
|||||||
commands::docker_commands::check_image_exists,
|
commands::docker_commands::check_image_exists,
|
||||||
commands::docker_commands::build_image,
|
commands::docker_commands::build_image,
|
||||||
commands::docker_commands::get_container_info,
|
commands::docker_commands::get_container_info,
|
||||||
commands::docker_commands::list_sibling_containers,
|
|
||||||
// Projects
|
// Projects
|
||||||
commands::project_commands::list_projects,
|
commands::project_commands::list_projects,
|
||||||
commands::project_commands::add_project,
|
commands::project_commands::add_project,
|
||||||
@@ -413,6 +470,10 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
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
|
// Container base-image migration
|
||||||
commands::migration_commands::get_container_staleness,
|
commands::migration_commands::get_container_staleness,
|
||||||
commands::migration_commands::migrate_project_to_base,
|
commands::migration_commands::migrate_project_to_base,
|
||||||
@@ -426,12 +487,25 @@ pub fn run() {
|
|||||||
browser_view::commands::set_browser_view_enabled,
|
browser_view::commands::set_browser_view_enabled,
|
||||||
browser_view::commands::get_browser_view_status,
|
browser_view::commands::get_browser_view_status,
|
||||||
browser_view::commands::check_browser_view_support,
|
browser_view::commands::check_browser_view_support,
|
||||||
|
browser_view::commands::install_browser_view_support,
|
||||||
|
browser_view::commands::install_browser_view_browser,
|
||||||
|
browser_view::commands::open_browser_view_popout,
|
||||||
|
browser_view::commands::close_browser_view_popout,
|
||||||
|
browser_view::commands::get_browser_view_popout_state,
|
||||||
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||||
|
browser_view::commands::open_page_in_container_browser,
|
||||||
|
browser_view::commands::set_container_page_viewport,
|
||||||
|
browser_view::commands::get_container_page_state,
|
||||||
|
browser_view::commands::close_container_page,
|
||||||
|
browser_view::commands::set_browser_view_match_window,
|
||||||
|
browser_view::commands::get_browser_view_match_window,
|
||||||
// Shared Claude Code auth token
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
commands::auth_token_commands::submit_claude_token_code,
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
commands::auth_token_commands::cancel_claude_token,
|
commands::auth_token_commands::cancel_claude_token,
|
||||||
commands::auth_token_commands::has_claude_token,
|
commands::auth_token_commands::has_claude_token,
|
||||||
commands::auth_token_commands::clear_claude_token,
|
commands::auth_token_commands::clear_claude_token,
|
||||||
|
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
@@ -440,6 +514,10 @@ pub fn run() {
|
|||||||
commands::settings_commands::inspect_ca_cert_path,
|
commands::settings_commands::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
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
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
@@ -452,9 +530,12 @@ pub fn run() {
|
|||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
// Files
|
// Files
|
||||||
commands::file_commands::list_container_files,
|
commands::file_commands::list_container_files,
|
||||||
commands::file_commands::download_container_file,
|
|
||||||
commands::file_commands::download_container_backup,
|
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,
|
||||||
// AWS
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// Updates
|
// Updates
|
||||||
@@ -632,4 +713,244 @@ mod tests {
|
|||||||
lifecycle.settle_startup_tasks().await;
|
lifecycle.settle_startup_tasks().await;
|
||||||
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The capability file is the app's entire IPC attack surface, and it is
|
||||||
|
/// data — nothing in `cargo test` reads it, so a widened grant lands with a
|
||||||
|
/// green suite. This is what noticing looks like.
|
||||||
|
///
|
||||||
|
/// It exists because `core:default` was granted for months. That alias
|
||||||
|
/// 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 rather than from a macro
|
||||||
|
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||||
|
let this = include_str!("lib.rs");
|
||||||
|
let handler = this
|
||||||
|
.split_once("generate_handler![")
|
||||||
|
.and_then(|(_, rest)| rest.split_once("])"))
|
||||||
|
.map(|(inside, _)| inside)
|
||||||
|
.expect("lib.rs should contain a generate_handler! list");
|
||||||
|
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||||
|
// style comments, and splitting on commas glues each comment to the
|
||||||
|
// command that follows it. A `starts_with("//")` filter then drops that
|
||||||
|
// command — silently, and once per group.
|
||||||
|
let registered: BTreeSet<String> = handler
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
.filter_map(|l| {
|
||||||
|
l.trim_end_matches(',')
|
||||||
|
.rsplit("::")
|
||||||
|
.next()
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
})
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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 line in handler
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
{
|
||||||
|
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen.contains(&name) {
|
||||||
|
duplicated.push(name);
|
||||||
|
} else {
|
||||||
|
seen.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(raw).expect("capabilities/default.json must parse");
|
||||||
|
let listed: Vec<String> = parsed["permissions"]
|
||||||
|
.as_array()
|
||||||
|
.expect("a `permissions` array")
|
||||||
|
.iter()
|
||||||
|
.map(|p| match p {
|
||||||
|
// A scoped grant is an object; its identifier is what matters here.
|
||||||
|
serde_json::Value::Object(o) => o["identifier"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a scoped grant needs an identifier")
|
||||||
|
.to_string(),
|
||||||
|
other => other.as_str().expect("a grant is a string or an object").to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sorted = listed.clone();
|
||||||
|
sorted.sort();
|
||||||
|
let mut expected = vec![
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
|
"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 \
|
||||||
|
surface a compromised webview can call, so update this list \
|
||||||
|
deliberately rather than to make the test pass."
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
for grant in &listed {
|
||||||
|
assert!(
|
||||||
|
!grant.ends_with(":default"),
|
||||||
|
"{} is an alias — it expands to permissions this file does not \
|
||||||
|
name. Enumerate them instead.",
|
||||||
|
grant
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!grant.starts_with("store:"),
|
||||||
|
"store:* is `PathBuf::push` against AppData, which an absolute \
|
||||||
|
path discards: an arbitrary host-file read/write."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// The level the dispatch is built with, and the level restored by hand if
|
||||||
|
/// installing it fails — see the failure branch in [`init`] for why that
|
||||||
|
/// matters more than it looks.
|
||||||
|
const LOG_LEVEL: log::LevelFilter = log::LevelFilter::Info;
|
||||||
|
|
||||||
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
fn log_dir() -> Option<PathBuf> {
|
fn log_dir() -> Option<PathBuf> {
|
||||||
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
@@ -33,7 +38,7 @@ pub fn init() {
|
|||||||
message
|
message
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.level(log::LevelFilter::Info)
|
.level(LOG_LEVEL)
|
||||||
.chain(std::io::stderr());
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
if let Some((_path, file)) = &log_file_path {
|
if let Some((_path, file)) = &log_file_path {
|
||||||
@@ -41,7 +46,28 @@ pub fn init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = dispatch.apply() {
|
if let Err(e) = dispatch.apply() {
|
||||||
eprintln!("Failed to initialise logger: {}", e);
|
// H2's other half. `fern::Dispatch::apply` calls `log::set_boxed_logger`
|
||||||
|
// and only then `log::set_max_level`, so a failure returns with the
|
||||||
|
// global filter still at its default, `LevelFilter::Off`. That is not
|
||||||
|
// merely "no log output": every `log::info!(…)` expands to
|
||||||
|
// `if Info <= max_level() { … }`, so at `Off` the macro never evaluates
|
||||||
|
// its own arguments. Anything a call site put in an argument list —
|
||||||
|
// a function call, an `await`, a side effect — silently stops
|
||||||
|
// happening, app-wide, because a logger could not be installed.
|
||||||
|
//
|
||||||
|
// Call sites must not put effects in log arguments (see the
|
||||||
|
// pre-migration scrub in `migration_commands.rs`), but "the whole
|
||||||
|
// program's log macros are dead and nothing said so" is its own
|
||||||
|
// hazard, so the level this dispatch was configured with is restored
|
||||||
|
// by hand. Nothing is listening — `log`'s default logger is a no-op —
|
||||||
|
// but the macros evaluate, and the one thing that *is* guaranteed to
|
||||||
|
// reach the user, the stderr line below, says what happened.
|
||||||
|
eprintln!(
|
||||||
|
"Failed to initialise logger: {}. Log output is disabled for this run; \
|
||||||
|
log macros still evaluate their arguments.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
log::set_max_level(LOG_LEVEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install a panic hook that writes to the log file so crashes are captured.
|
// Install a panic hook that writes to the log file so crashes are captured.
|
||||||
@@ -71,3 +97,40 @@ pub fn init() {
|
|||||||
log::info!("Logging to {}", path.display());
|
log::info!("Logging to {}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_logger_that_could_not_be_installed_still_leaves_the_macros_evaluating() {
|
||||||
|
// H2: `log::info!(…)` expands to `if Info <= max_level() { … }`, so at
|
||||||
|
// `LevelFilter::Off` the arguments are never evaluated. `fern` returns
|
||||||
|
// before `set_max_level` when `apply()` fails, which leaves exactly
|
||||||
|
// that state — and a call site that folded an effect into an argument
|
||||||
|
// list then stops performing it, app-wide, because a log file could not
|
||||||
|
// be opened. The failure branch restores the level for that reason.
|
||||||
|
//
|
||||||
|
// Asserted on the level itself rather than by driving `init`, which
|
||||||
|
// installs a process-global logger and a panic hook and can only run
|
||||||
|
// once per process.
|
||||||
|
assert_ne!(LOG_LEVEL, log::LevelFilter::Off);
|
||||||
|
|
||||||
|
// The property that makes the above worth asserting, demonstrated
|
||||||
|
// against the macro itself: a side effect in an argument list runs only
|
||||||
|
// while the level admits the record.
|
||||||
|
let mut ran = false;
|
||||||
|
let effect = |v: &mut bool| {
|
||||||
|
*v = true;
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let previous = log::max_level();
|
||||||
|
log::set_max_level(log::LevelFilter::Off);
|
||||||
|
log::info!("{}", effect(&mut ran));
|
||||||
|
assert!(!ran, "the premise is wrong: arguments evaluated at LevelFilter::Off");
|
||||||
|
log::set_max_level(LOG_LEVEL);
|
||||||
|
log::info!("{}", effect(&mut ran));
|
||||||
|
assert!(ran, "arguments did not evaluate at the level this module configures");
|
||||||
|
log::set_max_level(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,145 @@
|
|||||||
// Prevents additional console window on Windows in release
|
// Prevents additional console window on Windows in release
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![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.
|
||||||
|
#[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() {
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
apply_webkit_wayland_workaround();
|
||||||
|
|
||||||
triple_c_lib::run()
|
triple_c_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
|||||||
pub gateway: GatewaySettings,
|
pub gateway: GatewaySettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
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 {
|
fn default_stt_model() -> String {
|
||||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
|||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
gateway: GatewaySettings::default(),
|
gateway: GatewaySettings::default(),
|
||||||
global_claude_code_settings: None,
|
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 app_settings;
|
||||||
|
pub mod container_config;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod note;
|
||||||
|
pub mod project;
|
||||||
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
|
||||||
pub use container_config::*;
|
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use container_config::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
|
pub use note::*;
|
||||||
|
pub use project::*;
|
||||||
|
pub use settings_export::*;
|
||||||
pub use update_info::*;
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ProjectPath {
|
pub struct ProjectPath {
|
||||||
pub host_path: String,
|
pub host_path: String,
|
||||||
@@ -85,31 +179,141 @@ impl PermissionMode {
|
|||||||
/// Settings for Claude Code CLI behavior inside the container.
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[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
|
||||||
|
/// whatever the global settings say"; for the *global* settings it is "leave
|
||||||
|
/// Claude Code's own default alone". `Some(false)` is a deliberate off, which
|
||||||
|
/// is what lets a project turn a globally-enabled setting back off — with a
|
||||||
|
/// plain `bool` there is no value that can express that, which is why these
|
||||||
|
/// were widened from `bool`.
|
||||||
pub struct ClaudeCodeSettings {
|
pub struct ClaudeCodeSettings {
|
||||||
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
|
||||||
#[serde(default)]
|
/// 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, skip_serializing_if = "Option::is_none")]
|
||||||
pub tui_mode: Option<String>,
|
pub tui_mode: Option<String>,
|
||||||
/// Effort level: None = default, Some("low"|"medium"|"high")
|
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||||
#[serde(default)]
|
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||||
|
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub effort: Option<String>,
|
pub effort: Option<String>,
|
||||||
/// Disable auto-scroll in fullscreen TUI mode
|
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||||
#[serde(default)]
|
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||||
pub auto_scroll_disabled: bool,
|
/// zero value of this field has to mean "leave it on".
|
||||||
/// Enable focus mode (collapsed tool output)
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(default)]
|
pub auto_scroll_disabled: Option<bool>,
|
||||||
pub focus_mode: 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, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub focus_mode: Option<bool>,
|
||||||
/// Show thinking summaries in responses
|
/// Show thinking summaries in responses
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub show_thinking_summaries: bool,
|
pub show_thinking_summaries: Option<bool>,
|
||||||
/// Enable session recap when returning to a session
|
/// Turn the session recap **off**.
|
||||||
#[serde(default)]
|
///
|
||||||
pub enable_session_recap: bool,
|
/// Held in the disabled sense for the same reason as `auto_scroll_disabled`,
|
||||||
|
/// and the rename from the old `enable_session_recap` is load-bearing rather
|
||||||
|
/// than cosmetic. Claude Code's recap is on by default, so the old field was
|
||||||
|
/// inverted: switching it on was a no-op and switching it off did nothing at
|
||||||
|
/// all. Reusing the name with the opposite meaning would have read every
|
||||||
|
/// stored `enable_session_recap: false` — which is what every project that
|
||||||
|
/// 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, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_recap_disabled: Option<bool>,
|
||||||
/// Strip credentials from subprocess environments
|
/// Strip credentials from subprocess environments
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub env_scrub: bool,
|
pub env_scrub: Option<bool>,
|
||||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||||
|
#[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)]
|
#[serde(default)]
|
||||||
pub prompt_caching_1h: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -145,6 +349,22 @@ pub struct Project {
|
|||||||
/// container-recreation label.
|
/// container-recreation label.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub browser_view_enabled: bool,
|
pub browser_view_enabled: bool,
|
||||||
|
/// Grant the container what a VPN client needs to build a tunnel:
|
||||||
|
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
|
||||||
|
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
|
||||||
|
/// OpenVPN) installs and runs but its connection attempt hangs until it
|
||||||
|
/// times out, because it cannot create the tunnel interface or touch the
|
||||||
|
/// routing table.
|
||||||
|
///
|
||||||
|
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
|
||||||
|
/// container reconfigure its own network stack, which reaches further than
|
||||||
|
/// it sounds — see `vpn_host_config` for what it does and does not confer.
|
||||||
|
/// Unlike `auth_bridge_enabled` this *is*
|
||||||
|
/// container state, so it carries a `triple-c.vpn-support` label and is
|
||||||
|
/// compared in `container_needs_recreation` — capabilities and devices are
|
||||||
|
/// fixed at creation and can only change by recreating the container.
|
||||||
|
#[serde(default)]
|
||||||
|
pub vpn_support_enabled: bool,
|
||||||
/// Use the shared, long-lived Claude Code OAuth token (from
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
@@ -202,6 +422,61 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
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.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
@@ -366,6 +641,7 @@ impl Project {
|
|||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
auth_bridge_enabled: false,
|
auth_bridge_enabled: false,
|
||||||
browser_view_enabled: false,
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
use_shared_auth_token: default_use_shared_auth_token(),
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
@@ -424,3 +700,189 @@ impl Project {
|
|||||||
val
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 body: String,
|
||||||
pub assets: Vec<GitHubAsset>,
|
pub assets: Vec<GitHubAsset>,
|
||||||
pub published_at: String,
|
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).
|
/// GitHub API asset response (internal).
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
//! Per-project mutual exclusion for everything that rewrites a project's
|
||||||
|
//! container or its snapshot image.
|
||||||
|
//!
|
||||||
|
//! ## Why polling was not enough
|
||||||
|
//!
|
||||||
|
//! Until this module existed the app had exactly one mutual-exclusion
|
||||||
|
//! primitive — the `ACTIVE_MIGRATIONS` set behind
|
||||||
|
//! `migration_commands::is_migrating` — and it was **one-way**. A migration
|
||||||
|
//! took a guard for its whole run; everything else merely *asked once, at
|
||||||
|
//! entry*, whether a migration was in flight and then proceeded with no claim
|
||||||
|
//! of its own. Two non-migration operations on the same project could not see
|
||||||
|
//! each other at all, and a migration could start underneath one that was
|
||||||
|
//! already halfway through.
|
||||||
|
//!
|
||||||
|
//! That is not a theoretical gap. Compaction resolves
|
||||||
|
//! `triple-c-snapshot-{id}:latest` when its build starts and commits back over
|
||||||
|
//! that same tag minutes later, and the Settings panel is a sidebar rather than
|
||||||
|
//! a modal — so Project Home stays live with Start, Stop, Reset and Migrate all
|
||||||
|
//! clickable while a compaction runs. Three interleavings were reproduced:
|
||||||
|
//!
|
||||||
|
//! * Compaction commits `flat(A)` over `:latest` after a migration has already
|
||||||
|
//! moved that tag to a new lineage. The migration is silently reverted, the
|
||||||
|
//! config replay lands twice, and the migration record says
|
||||||
|
//! `awaiting-confirmation` against a base the tag no longer points at.
|
||||||
|
//! * Compaction resolves A, the user starts the project and works for an hour,
|
||||||
|
//! a recreate commits D over `:latest`, and the compaction then overwrites it
|
||||||
|
//! with `flat(A)` — orphaning an hour of system-layer work while reporting
|
||||||
|
//! success and a byte saving.
|
||||||
|
//! * Compaction resurrects the system layer a Reset had just destroyed.
|
||||||
|
//!
|
||||||
|
//! Every one of those is "two writers of `:latest`, neither holding anything".
|
||||||
|
//! So this registry replaces the polling with an actual claim: an operation
|
||||||
|
//! **acquires** a [`ProjectGuard`] and holds it for its whole run, and a second
|
||||||
|
//! operation on the same project is refused with a message naming the holder.
|
||||||
|
//!
|
||||||
|
//! ## What this does NOT protect against, stated plainly
|
||||||
|
//!
|
||||||
|
//! **This is in-process state.** Two copies of the app pointed at the same
|
||||||
|
//! Docker daemon share nothing here: instance A's compaction and instance B's
|
||||||
|
//! migration will both acquire happily and then race exactly as before.
|
||||||
|
//! `reap_probe_containers` and the `triple-c-compact-*` / `triple-c-scrub-*`
|
||||||
|
//! sweeps are worse than that — they are daemon-wide force-removals driven by
|
||||||
|
//! a name or a label, so instance B can destroy a container instance A is
|
||||||
|
//! mid-commit against.
|
||||||
|
//!
|
||||||
|
//! A daemon-visible lock was considered and rejected for now, and the reasoning
|
||||||
|
//! is recorded here so it is not re-derived from scratch:
|
||||||
|
//!
|
||||||
|
//! * A **lock container** would work — container names are unique daemon-wide
|
||||||
|
//! and `create` fails atomically on a name conflict — but a container has to
|
||||||
|
//! be created *from an image*, and that pins the image. A lock on
|
||||||
|
//! `triple-c-snapshot-{id}` would block the very `rmi`/sweep paths it guards,
|
||||||
|
//! and a leaked lock container would pin multiple gigabytes forever.
|
||||||
|
//! * A **named volume** is not usable: `create_volume` on an existing name
|
||||||
|
//! returns the existing volume rather than failing, so it cannot be a
|
||||||
|
//! test-and-set.
|
||||||
|
//! * A **label on the snapshot image** is not atomic — read/modify/commit has
|
||||||
|
//! the same race it would be trying to close.
|
||||||
|
//!
|
||||||
|
//! So the cross-process case is **documented, not solved**. What this module
|
||||||
|
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
|
||||||
|
//! reapers skip work while this process is mid-operation, and the reapers
|
||||||
|
//! themselves gained age gates so a young container belonging to somebody else
|
||||||
|
//! is left alone (see `docker::migration::reap_probe_containers`).
|
||||||
|
//!
|
||||||
|
//! ## Refuse, do not queue
|
||||||
|
//!
|
||||||
|
//! [`try_acquire`] never waits. Every caller is a user-initiated action behind
|
||||||
|
//! a button, and a button that blocks for the four minutes a compaction takes
|
||||||
|
//! is worse than one that says what is running. The refusal string is written
|
||||||
|
//! for the user and names the holder.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
/// The operations that claim a project.
|
||||||
|
///
|
||||||
|
/// One variant per *class of writer*, not per command: `Recreate` covers Start
|
||||||
|
/// as well, because Start's create-and-commit path is the same writer of
|
||||||
|
/// `triple-c-snapshot-{id}:latest` that a recreate is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProjectOp {
|
||||||
|
/// `migrate_project_to_base`, `resume_migration`, `rollback_migration`,
|
||||||
|
/// `confirm_migration`.
|
||||||
|
Migration,
|
||||||
|
/// `disk::compact_snapshot` — the long one, and the reason this exists.
|
||||||
|
///
|
||||||
|
/// Not constructed on this branch: the Disk panel and its compaction were
|
||||||
|
/// held back for separate hardening and live on `hold/disk-and-dragout`.
|
||||||
|
/// The variant stays because this registry is the thing that made those
|
||||||
|
/// operations safe to re-land, and a re-land that had to re-derive the
|
||||||
|
/// claim classes would be re-deriving the bug.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Compaction,
|
||||||
|
/// Start / stop / recreate. Anything in `start_project_container`'s path.
|
||||||
|
Recreate,
|
||||||
|
/// `rebuild_project_container` — deletes both volumes and the snapshot.
|
||||||
|
Reset,
|
||||||
|
/// `disk::destroy` — a volume, a snapshot image, or a rollback pin.
|
||||||
|
Destroy,
|
||||||
|
/// `disk::clear_caches` — an exec into the live container. It does not
|
||||||
|
/// write `:latest`, but it must not run while the container is being
|
||||||
|
/// removed out from under it.
|
||||||
|
///
|
||||||
|
/// Not constructed on this branch, for the same reason as
|
||||||
|
/// [`ProjectOp::Compaction`].
|
||||||
|
#[allow(dead_code)]
|
||||||
|
CacheClear,
|
||||||
|
/// `container::scrub_secrets_from_snapshots` — the third writer of
|
||||||
|
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
|
||||||
|
/// creates a scratch container from the snapshot and commits back over the
|
||||||
|
/// same tag, so it is the same read-modify-write shape as a compaction and
|
||||||
|
/// loses the same race: any `:latest` move landing between its create and
|
||||||
|
/// its commit is overwritten by an image derived from the pre-read state.
|
||||||
|
SecretScrub,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectOp {
|
||||||
|
/// What is happening, phrased for the message a user reads.
|
||||||
|
pub fn describe(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProjectOp::Migration => "A container base update is running for this project",
|
||||||
|
ProjectOp::Compaction => "This project's snapshot is being compacted",
|
||||||
|
ProjectOp::Recreate => "This project's container is being started or recreated",
|
||||||
|
ProjectOp::Reset => "This project is being reset",
|
||||||
|
ProjectOp::Destroy => "Something of this project's is being deleted",
|
||||||
|
ProjectOp::CacheClear => "This project's caches are being cleared",
|
||||||
|
ProjectOp::SecretScrub => "A revoked credential is being removed from this project's snapshot",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the *refused* caller was trying to do, for the tail of the message.
|
||||||
|
fn blocked_action(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProjectOp::Migration => "starting a base update",
|
||||||
|
ProjectOp::Compaction => "compacting its snapshot",
|
||||||
|
ProjectOp::Recreate => "starting or recreating its container",
|
||||||
|
ProjectOp::Reset => "resetting it",
|
||||||
|
ProjectOp::Destroy => "deleting anything of its",
|
||||||
|
ProjectOp::CacheClear => "clearing its caches",
|
||||||
|
ProjectOp::SecretScrub => "removing a credential from its snapshot",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project id → the operation currently holding it.
|
||||||
|
///
|
||||||
|
/// A `std::sync::Mutex` rather than a `tokio` one on purpose: it is only ever
|
||||||
|
/// held for the length of a `HashMap` insert or remove, never across an await,
|
||||||
|
/// and [`is_held_by`] has to be callable from the synchronous helpers in
|
||||||
|
/// `disk.rs` that already ask this question.
|
||||||
|
static HOLDERS: OnceLock<Mutex<HashMap<String, ProjectOp>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
|
||||||
|
HOLDERS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A claim on one project, released on drop.
|
||||||
|
///
|
||||||
|
/// RAII rather than an explicit release for the reason [`ProjectOp::Migration`]'s
|
||||||
|
/// predecessor already learned: a plain release statement is skipped by an
|
||||||
|
/// early `?`, by a panic, and by the future simply being dropped. A guard is
|
||||||
|
/// not.
|
||||||
|
/// Dropping this releases the claim, so a caller that discards it has taken no
|
||||||
|
/// lock at all — `let _ = try_acquire(...)` drops immediately and reads as
|
||||||
|
/// success. `#[must_use]` makes that a compile warning rather than a race.
|
||||||
|
#[must_use = "the claim is released as soon as this guard is dropped; bind it for the whole operation"]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProjectGuard {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ProjectGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// `into_inner` on a poisoned lock: a panic while some other thread held
|
||||||
|
// this map for the duration of one insert cannot have left it
|
||||||
|
// inconsistent, and refusing to release afterwards would strand the
|
||||||
|
// project as permanently busy.
|
||||||
|
holders()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(&self.project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim a project for `op`, or say who has it.
|
||||||
|
///
|
||||||
|
/// The error is user-facing copy, not a debug string — it goes straight back
|
||||||
|
/// over IPC to a toast.
|
||||||
|
pub fn try_acquire(project_id: &str, op: ProjectOp) -> Result<ProjectGuard, String> {
|
||||||
|
let mut map = holders().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(holder) = map.get(project_id).copied() {
|
||||||
|
return Err(format!(
|
||||||
|
"{}. Wait for it to finish before {}.",
|
||||||
|
holder.describe(),
|
||||||
|
op.blocked_action()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
map.insert(project_id.to_string(), op);
|
||||||
|
Ok(ProjectGuard {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which operation holds this project, if any.
|
||||||
|
pub fn held(project_id: &str) -> Option<ProjectOp> {
|
||||||
|
holders()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(project_id)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this project is held by exactly `op`.
|
||||||
|
///
|
||||||
|
/// `migration_commands::is_migrating` is this, specialised — which is the whole
|
||||||
|
/// point of folding `ACTIVE_MIGRATIONS` into this registry: there is now one
|
||||||
|
/// answer to "is something happening to this project", not two that can
|
||||||
|
/// disagree.
|
||||||
|
pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
|
||||||
|
held(project_id) == Some(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any project **other than** `exclude_project_id` is currently held by
|
||||||
|
/// `op`. Pass an empty id to ask about every project.
|
||||||
|
///
|
||||||
|
/// Used by the daemon-wide reapers, which cannot tell which project a
|
||||||
|
/// `triple-c-compact-*` container belongs to — the name carries a random uuid,
|
||||||
|
/// not a project id — so "is this process compacting anything right now" is the
|
||||||
|
/// only in-process question they can ask before force-removing one. The
|
||||||
|
/// exclusion is for the reaper that runs *inside* a compaction, which is
|
||||||
|
/// already holding a claim of its own and would otherwise see it and skip.
|
||||||
|
///
|
||||||
|
/// No production caller on this branch: the compaction reaper it was written
|
||||||
|
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
|
||||||
|
/// (and still tested) because it is the only bound this module offers on the
|
||||||
|
/// cross-process case documented above.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
|
||||||
|
holders()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.iter()
|
||||||
|
.any(|(project_id, held)| *held == op && project_id != exclude_project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Ids are namespaced per test: the registry is process-global, and
|
||||||
|
/// `cargo test` runs these on several threads at once.
|
||||||
|
fn id(name: &str) -> String {
|
||||||
|
format!("project-lock-test-{}", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_acquire_on_the_same_project_is_refused() {
|
||||||
|
let p = id("second-acquire");
|
||||||
|
let first = try_acquire(&p, ProjectOp::Compaction).expect("first claim");
|
||||||
|
let second = try_acquire(&p, ProjectOp::Recreate);
|
||||||
|
let err = second.expect_err("a second claim must be refused, not queued");
|
||||||
|
// The refusal has to name the holder — "busy" alone leaves the user
|
||||||
|
// with nothing to wait for.
|
||||||
|
assert!(err.contains("snapshot is being compacted"), "{}", err);
|
||||||
|
assert!(err.contains("starting or recreating"), "{}", err);
|
||||||
|
drop(first);
|
||||||
|
// And it has to be retakeable the moment the holder goes away. Bound
|
||||||
|
// rather than discarded: `#[must_use]` is what stops a real caller
|
||||||
|
// writing `try_acquire(...)` and believing it holds something.
|
||||||
|
let retaken = try_acquire(&p, ProjectOp::Recreate).expect("released on drop");
|
||||||
|
drop(retaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_guard_releases_on_an_early_return() {
|
||||||
|
let p = id("early-return");
|
||||||
|
fn bails(project_id: &str) -> Result<(), String> {
|
||||||
|
let _guard = try_acquire(project_id, ProjectOp::Reset)?;
|
||||||
|
Err("something failed".to_string())
|
||||||
|
}
|
||||||
|
assert!(bails(&p).is_err());
|
||||||
|
assert_eq!(held(&p), None, "an early `?` must not strand the claim");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_guard_releases_on_a_panic() {
|
||||||
|
let p = id("panic");
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let _guard = try_acquire(&id("panic"), ProjectOp::Migration).unwrap();
|
||||||
|
panic!("boom");
|
||||||
|
});
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(held(&p), None, "a panic must not strand the claim either");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_projects_do_not_block_each_other() {
|
||||||
|
let a = id("independent-a");
|
||||||
|
let b = id("independent-b");
|
||||||
|
let _one = try_acquire(&a, ProjectOp::Compaction).expect("a");
|
||||||
|
let _two = try_acquire(&b, ProjectOp::Compaction).expect("b");
|
||||||
|
assert!(is_held_by(&a, ProjectOp::Compaction));
|
||||||
|
assert!(is_held_by(&b, ProjectOp::Compaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_held_by_distinguishes_the_operation() {
|
||||||
|
let p = id("which-op");
|
||||||
|
let _guard = try_acquire(&p, ProjectOp::Compaction).unwrap();
|
||||||
|
assert!(is_held_by(&p, ProjectOp::Compaction));
|
||||||
|
assert!(
|
||||||
|
!is_held_by(&p, ProjectOp::Migration),
|
||||||
|
"a compaction is not a migration — `is_migrating` is built on this"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_held_sees_across_projects() {
|
||||||
|
let p = id("any-held");
|
||||||
|
assert!(!any_held_excluding(ProjectOp::Destroy, ""));
|
||||||
|
let _guard = try_acquire(&p, ProjectOp::Destroy).unwrap();
|
||||||
|
assert!(any_held_excluding(ProjectOp::Destroy, ""));
|
||||||
|
// …and a holder can ask the question without its own claim answering
|
||||||
|
// it, which is what lets a compaction sweep leftovers before it starts.
|
||||||
|
assert!(!any_held_excluding(ProjectOp::Destroy, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrency, not just sequencing: N threads racing for one project must
|
||||||
|
/// produce exactly one winner.
|
||||||
|
#[test]
|
||||||
|
fn exactly_one_of_many_racing_threads_wins() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let p = id("race");
|
||||||
|
let start = Arc::new(std::sync::Barrier::new(8));
|
||||||
|
// The second barrier is what makes this deterministic rather than
|
||||||
|
// merely likely: no winner releases until every thread has had its
|
||||||
|
// turn, so "only one got in" cannot be an artefact of a loser arriving
|
||||||
|
// after the winner already left.
|
||||||
|
let attempted = Arc::new(std::sync::Barrier::new(8));
|
||||||
|
let won = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let start = Arc::clone(&start);
|
||||||
|
let attempted = Arc::clone(&attempted);
|
||||||
|
let won = Arc::clone(&won);
|
||||||
|
let p = p.clone();
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
let claim = try_acquire(&p, ProjectOp::Compaction);
|
||||||
|
if claim.is_ok() {
|
||||||
|
won.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
attempted.wait();
|
||||||
|
drop(claim);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
won.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"eight threads raced for one project and more than one got in"
|
||||||
|
);
|
||||||
|
assert_eq!(held(&p), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,29 @@ fn sanitize(project_id: &str) -> String {
|
|||||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||||
/// blocking every future migration on a corrupt record.
|
/// blocking every future migration on a corrupt record.
|
||||||
|
///
|
||||||
|
/// **A corrupt record is copied aside and left in place.** An earlier version
|
||||||
|
/// *renamed* it to `.bak`, on the reasoning that a file nothing can parse
|
||||||
|
/// should stop making the project look busy. That destroyed the one signal
|
||||||
|
/// [`has_record`] exists to carry. The chain, in order:
|
||||||
|
///
|
||||||
|
/// 1. The rename makes the file vanish, so `has_record` — pure filesystem
|
||||||
|
/// presence — flips to false.
|
||||||
|
/// 2. `reconcile_migration` calls this, gets `Ok(None)`, and returns. An
|
||||||
|
/// in-flight or interrupted migration becomes invisible: no resume offer, no
|
||||||
|
/// rollback offer, and the phase is never normalised.
|
||||||
|
/// 3. Both pin reapers use `has_record` as their conservative guard, so the
|
||||||
|
/// project's `:pre-migration-*` tag — the only copy of its pre-migration
|
||||||
|
/// system layer — is now "ownerless" to both of them, and the startup sweep
|
||||||
|
/// turns the untag into a deletion.
|
||||||
|
///
|
||||||
|
/// A record that cannot be parsed is exactly the case where the *most*
|
||||||
|
/// conservative answer is wanted, not the least. So the bytes are copied to a
|
||||||
|
/// **uniquely named** backup (a fixed `.bak` meant a second corruption silently
|
||||||
|
/// overwrote the first, and nothing ever read either back) and the original
|
||||||
|
/// stays where it is. The pin it describes then ages out through the ownerless
|
||||||
|
/// tombstone in `docker::migration::reap_stale_migration_pins` rather than
|
||||||
|
/// being reaped on the next app start.
|
||||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -59,27 +82,326 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
|||||||
match serde_json::from_str::<MigrationState>(&data) {
|
match serde_json::from_str::<MigrationState>(&data) {
|
||||||
Ok(state) => Ok(Some(state)),
|
Ok(state) => Ok(Some(state)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// Three outcomes, and they must not be conflated: a copy was made,
|
||||||
|
// a copy was deliberately not made, or a copy failed. The previous
|
||||||
|
// version folded "already kept enough" into `Ok(())` and then told
|
||||||
|
// the user "a copy was kept at <path>" — naming a file that was
|
||||||
|
// never created. A message that invents a backup is worse than no
|
||||||
|
// message, because it is what someone reads before going to look
|
||||||
|
// for their data.
|
||||||
|
let backup = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||||
|
let kept = if backup.exists() {
|
||||||
|
Kept::AlreadyThere
|
||||||
|
} else if corrupt_backups_full(&path) {
|
||||||
|
Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)
|
||||||
|
} else {
|
||||||
|
match fs::copy(&path, &backup) {
|
||||||
|
Ok(_) => Kept::Copied,
|
||||||
|
Err(e) => Kept::Failed(e.to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
"Failed to parse migration state for project {}: {} — treating as absent, but \
|
||||||
|
the record is left in place so `has_record` still protects its rollback pin{}",
|
||||||
project_id,
|
project_id,
|
||||||
e
|
e,
|
||||||
|
match kept {
|
||||||
|
Kept::Copied | Kept::AlreadyThere =>
|
||||||
|
format!(" (a copy is at {})", backup.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 record are already saved \
|
||||||
|
alongside it)",
|
||||||
|
n
|
||||||
|
),
|
||||||
|
Kept::Failed(ref e) => format!(" (could not keep a copy: {})", e),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically write a project's migration state.
|
/// What [`load`] did about a copy of an unparseable record, so the log line can
|
||||||
|
/// tell the truth about whether a file exists.
|
||||||
|
enum Kept {
|
||||||
|
Copied,
|
||||||
|
/// This exact second's copy was already on disk.
|
||||||
|
AlreadyThere,
|
||||||
|
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||||
|
EnoughAlready(usize),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a copy of an unparseable record is kept.
|
||||||
|
///
|
||||||
|
/// Timestamped rather than a fixed `.bak`: a second corruption used to
|
||||||
|
/// overwrite the first, so the one case where the user's bytes matter most was
|
||||||
|
/// the case where they were most likely to be gone.
|
||||||
|
fn corrupt_backup_path(path: &std::path::Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||||
|
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many timestamped copies of one project's corrupt record are kept.
|
||||||
|
///
|
||||||
|
/// Timestamping fixed the "second corruption overwrote the first" bug and
|
||||||
|
/// introduced its opposite: [`load`] runs on every reconcile, every survey and
|
||||||
|
/// every reaper pass, so a record that is *persistently* unparseable — the
|
||||||
|
/// normal case, since nothing repairs it — mints a new copy every time the
|
||||||
|
/// clock's second changes. 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. See [`corrupt_backups_full`] for why the cap is applied before the
|
||||||
|
/// copy rather than by pruning after it.
|
||||||
|
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||||
|
|
||||||
|
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this record 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 here
|
||||||
|
/// costs at most one extra file, and failing closed would drop the very first
|
||||||
|
/// copy of a record nothing else has kept.
|
||||||
|
fn corrupt_backups_full(path: &std::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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a project has a migration record on disk *at all*, without parsing
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// The pin reaper needs "is this project's rollback image still somebody's only
|
||||||
|
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
|
||||||
|
/// question on its own — it deliberately reports a corrupt record as absent —
|
||||||
|
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
|
||||||
|
/// what keeps the two answers from disagreeing forever.
|
||||||
|
pub fn has_record(project_id: &str) -> Result<bool, String> {
|
||||||
|
Ok(state_path(project_id)?.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically **and durably** write a project's migration state.
|
||||||
|
///
|
||||||
|
/// Write-temp-then-rename alone is only half of it, and the missing half is the
|
||||||
|
/// half this record exists for. `fs::write` returns once the bytes are in the
|
||||||
|
/// page cache; a rename over them is atomic *with respect to other readers*,
|
||||||
|
/// not with respect to power loss. Losing power in that window leaves the
|
||||||
|
/// rename applied and the data not yet written — i.e. a 0-byte or truncated
|
||||||
|
/// `{id}.json` — which is precisely the corrupt-record case above, produced by
|
||||||
|
/// the code whose job is to make that case impossible.
|
||||||
|
///
|
||||||
|
/// So: fsync the file before the rename, and fsync the *directory* after it,
|
||||||
|
/// because the rename itself is directory metadata and is not durable until the
|
||||||
|
/// directory is synced. A sync that fails is reported rather than swallowed —
|
||||||
|
/// this is the crash record, and "probably written" is not a state it may be
|
||||||
|
/// in.
|
||||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
let data = serde_json::to_string_pretty(state)
|
let data = serde_json::to_string_pretty(state)
|
||||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = fs::File::create(&tmp)
|
||||||
|
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||||
|
file.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| format!("Failed to flush migration state to disk: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||||
|
sync_dir(&path);
|
||||||
|
// A project with a record is not ownerless, whatever a reaper concluded
|
||||||
|
// before this write — so the grace clock is thrown away rather than left to
|
||||||
|
// expire against a pin that now has an owner again.
|
||||||
|
clear_ownerless_for_project(project_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 returns an error for the attempt, so a failure
|
||||||
|
/// is logged rather than propagated. The file's own `sync_all` above is the
|
||||||
|
/// part that carries the data, and it is not best effort.
|
||||||
|
fn sync_dir(path: &std::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 migrations directory {}: {} — the record itself was flushed",
|
||||||
|
dir.display(),
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ownerless-pin tombstones
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker recording **when a rollback pin was first seen with no record behind
|
||||||
|
/// it**.
|
||||||
|
///
|
||||||
|
/// ## Why the grace period cannot be measured from the tag
|
||||||
|
///
|
||||||
|
/// `docker::migration::pin_is_reapable` used to date a pin from the timestamp
|
||||||
|
/// encoded in `pre-migration-<YYYYmmdd-HHMMSS>` — i.e. from when the migration
|
||||||
|
/// *started*. That is the wrong epoch by a whole feature. A migration is
|
||||||
|
/// allowed to sit at `awaiting-confirmation` indefinitely; `keep_rollback`
|
||||||
|
/// exists precisely so a user can run on the new base for a month before
|
||||||
|
/// deciding. If that project's record is then lost — a corrupt file, a deleted
|
||||||
|
/// state file, a half-restored data directory — the pin is fourteen days old on
|
||||||
|
/// the very first check, so it is untagged on the next app start and the
|
||||||
|
/// startup sweep deletes the image two lines later. The fourteen-day grace
|
||||||
|
/// period the constant promises is zero in the only situation it was written
|
||||||
|
/// for.
|
||||||
|
///
|
||||||
|
/// The clock has to start when the *claim* was lost, and nothing on the daemon
|
||||||
|
/// records that moment. So it is written down here, the first time a reaper
|
||||||
|
/// notices, and the age is measured from the marker.
|
||||||
|
///
|
||||||
|
/// One file per `(project_id, tag)` in the migrations directory, holding an
|
||||||
|
/// RFC3339 instant. Tiny, and losing one costs a fresh fourteen days rather
|
||||||
|
/// than a deletion — the failure direction that keeps somebody's only rollback
|
||||||
|
/// copy.
|
||||||
|
fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String> {
|
||||||
|
Ok(migrations_dir()?.join(format!(
|
||||||
|
"{}.{}.ownerless",
|
||||||
|
sanitize(project_id),
|
||||||
|
sanitize(tag)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the first-observed instant for a pin, creating the marker if this is
|
||||||
|
/// the first sighting. Returns `None` when the clock has not started yet.
|
||||||
|
///
|
||||||
|
/// **Clock skew is handled here rather than at the comparison.** A host clock
|
||||||
|
/// that was running fast when the marker was written leaves a timestamp in the
|
||||||
|
/// future; measured naively that is a negative age, which a `num_days() >= 14`
|
||||||
|
/// test reads as "never reapable" — a pin that can never be collected, forever.
|
||||||
|
/// A marker dated after `now` is therefore rewritten to `now`, restarting the
|
||||||
|
/// grace period. The other direction — a clock jumping forward — cannot shorten
|
||||||
|
/// the period below what has actually elapsed on the *marker's* terms, because
|
||||||
|
/// there is nothing to compare against but wall time; what it cannot do any
|
||||||
|
/// more is make every pin instantly reapable, which dating from the tag did.
|
||||||
|
///
|
||||||
|
/// ## Why the write re-checks `has_record`
|
||||||
|
///
|
||||||
|
/// Both reapers ask [`has_record`] and only call this when the answer is no,
|
||||||
|
/// which leaves a window: a [`save`] landing between the two runs its
|
||||||
|
/// `clear_ownerless_for_project` against a marker that does not exist yet, and
|
||||||
|
/// this then plants one — dated *now* — behind a perfectly valid record. The
|
||||||
|
/// marker is invisible while the record stands, so nothing notices. It only
|
||||||
|
/// matters later, if that record is legitimately lost: the pin is then already
|
||||||
|
/// fourteen days ownerless on its very first check and is reaped with **zero**
|
||||||
|
/// grace, which is the exact failure the tombstone exists to prevent.
|
||||||
|
///
|
||||||
|
/// So the write is followed by a second `has_record`, and a marker that turns
|
||||||
|
/// out to sit behind a record is removed again. The two orderings that remain
|
||||||
|
/// are both safe: a `save` completing *after* this re-check clears the marker
|
||||||
|
/// itself, and one completing before it is what the re-check sees.
|
||||||
|
pub fn note_ownerless_since(
|
||||||
|
project_id: &str,
|
||||||
|
tag: &str,
|
||||||
|
now: &chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
|
let path = ownerless_marker_path(project_id, tag).ok()?;
|
||||||
|
let existing = fs::read_to_string(&path).ok().and_then(|raw| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(raw.trim())
|
||||||
|
.ok()
|
||||||
|
.map(|t| t.with_timezone(&chrono::Utc))
|
||||||
|
});
|
||||||
|
match existing {
|
||||||
|
Some(seen) if seen <= *now => Some(seen),
|
||||||
|
// Absent, unparseable, or dated in the future: (re)start the clock.
|
||||||
|
_ => {
|
||||||
|
if let Err(e) = fs::write(&path, now.to_rfc3339()) {
|
||||||
|
log::warn!(
|
||||||
|
"Could not record that rollback pin {}:{} is ownerless: {} — its grace \
|
||||||
|
period restarts on the next check",
|
||||||
|
project_id,
|
||||||
|
tag,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A record that appeared while this was being written owns the pin,
|
||||||
|
// and a tombstone behind an owned pin is a fourteen-day head start
|
||||||
|
// on reaping it the moment that record is next lost.
|
||||||
|
if has_record(project_id).unwrap_or(false) {
|
||||||
|
log::debug!(
|
||||||
|
"A migration record for {} appeared while marking {} ownerless; \
|
||||||
|
the marker was dropped again",
|
||||||
|
project_id,
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
clear_ownerless(project_id, tag);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a pin's ownerless marker. Missing is success.
|
||||||
|
///
|
||||||
|
/// Called when the pin is untagged, and when a record reappears for the
|
||||||
|
/// project — a re-migrated project must not inherit the previous run's clock.
|
||||||
|
pub fn clear_ownerless(project_id: &str, tag: &str) {
|
||||||
|
let Ok(path) = ownerless_marker_path(project_id, tag) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => log::warn!("Could not remove {}: {}", path.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every ownerless marker belonging to one project.
|
||||||
|
///
|
||||||
|
/// A project that has a record again is by definition not ownerless, whatever
|
||||||
|
/// a reaper concluded before.
|
||||||
|
pub fn clear_ownerless_for_project(project_id: &str) {
|
||||||
|
let Ok(dir) = migrations_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let prefix = format!("{}.", sanitize(project_id));
|
||||||
|
let Ok(entries) = fs::read_dir(&dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with(&prefix) && name.ends_with(".ownerless") {
|
||||||
|
let _ = fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a project's migration state file. Missing is success.
|
/// Remove a project's migration state file. Missing is success.
|
||||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
@@ -104,6 +426,39 @@ pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_copies_of_one_record_are_capped() {
|
||||||
|
// `load` runs on every reconcile, every survey and every reaper pass,
|
||||||
|
// and nothing repairs an unparseable record — so a persistently corrupt
|
||||||
|
// one minted a new timestamped copy every time the clock's second
|
||||||
|
// changed, and nothing ever removed them.
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-corrupt-cap-{}",
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
let record = dir.join("some-project.json");
|
||||||
|
|
||||||
|
assert!(!corrupt_backups_full(&record), "an empty directory is not full");
|
||||||
|
for n in 0..MAX_CORRUPT_BACKUPS {
|
||||||
|
fs::write(
|
||||||
|
dir.join(format!("some-project.json.corrupt-2026010{}-000000.bak", n)),
|
||||||
|
"x",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(corrupt_backups_full(&record));
|
||||||
|
|
||||||
|
// Another project's copies, and an unrelated `.bak`, are not this
|
||||||
|
// record's — the prefix is the whole point of the naming.
|
||||||
|
let other = dir.join("other-project.json");
|
||||||
|
assert!(!corrupt_backups_full(&other));
|
||||||
|
fs::write(dir.join("some-project.json.bak"), "x").unwrap();
|
||||||
|
assert!(!corrupt_backups_full(&other));
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
@@ -115,4 +470,43 @@ mod tests {
|
|||||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The log line must not name a backup that was never written.
|
||||||
|
///
|
||||||
|
/// `load` runs on every reconcile, every survey and every reaper pass, so a
|
||||||
|
/// persistently corrupt record hits the `MAX_CORRUPT_BACKUPS` cap within
|
||||||
|
/// seconds. The previous code folded "already kept enough" into `Ok(())`
|
||||||
|
/// and then reported " (a copy was kept at <path>)" — pointing at a file
|
||||||
|
/// that does not exist. That is the message someone reads immediately
|
||||||
|
/// before going to look for their data.
|
||||||
|
#[test]
|
||||||
|
fn the_corrupt_record_message_only_claims_a_copy_that_exists() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"tc-mstore-{}",
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("p.json");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
// Fill the cap with copies that really are on disk.
|
||||||
|
for i in 0..MAX_CORRUPT_BACKUPS {
|
||||||
|
let b = path.with_extension(format!("json.corrupt-2026010{}-000000.bak", i));
|
||||||
|
std::fs::write(&b, b"{ not json").unwrap();
|
||||||
|
}
|
||||||
|
assert!(corrupt_backups_full(&path), "precondition: the cap is reached");
|
||||||
|
|
||||||
|
// With the cap reached, no new copy may be created — and that is the
|
||||||
|
// state in which the old message lied.
|
||||||
|
let before: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||||
|
let fresh = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||||
|
assert!(
|
||||||
|
!fresh.exists(),
|
||||||
|
"the cap is reached, so this timestamped copy must not be written"
|
||||||
|
);
|
||||||
|
let after: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||||
|
assert_eq!(before.len(), after.len(), "nothing new appeared on disk");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
pub mod migration_store;
|
pub mod migration_store;
|
||||||
|
pub mod notes_store;
|
||||||
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
pub mod settings_crypto;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,65 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::models::Project;
|
use crate::models::Project;
|
||||||
|
|
||||||
|
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
|
||||||
|
///
|
||||||
|
/// Derived from the file rather than from `dirs::data_dir()` so the marker
|
||||||
|
/// always lands in the directory the store is actually using — and so the
|
||||||
|
/// writer can be tested against a temp directory.
|
||||||
|
fn corrupt_marker_for(file_path: &Path) -> PathBuf {
|
||||||
|
file_path.with_extension("json.corrupt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the bytes of an unparseable `projects.json`, and record that it
|
||||||
|
/// happened.
|
||||||
|
///
|
||||||
|
/// **The existing `.bak` is never overwritten.** A second corruption used to
|
||||||
|
/// clobber the first, and the first is the valuable one: it was taken before
|
||||||
|
/// the app rewrote the file with whatever it had in memory, so it is the only
|
||||||
|
/// copy that can still hold the full project list. Later ones are copies of an
|
||||||
|
/// already-degraded file and get a timestamped name.
|
||||||
|
fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||||
|
let first = file_path.with_extension("json.bak");
|
||||||
|
let backup = if first.exists() {
|
||||||
|
file_path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||||
|
} else {
|
||||||
|
first
|
||||||
|
};
|
||||||
|
if !backup.exists() {
|
||||||
|
if let Err(e) = fs::copy(file_path, &backup) {
|
||||||
|
log::error!("Failed to back up corrupted projects.json: {}", e);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"A copy of the unreadable projects.json was kept at {}",
|
||||||
|
backup.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky, and written even though nothing in the app reads it back on this
|
||||||
|
// branch: the Disk panel's `project_store_trust` was the reader and went to
|
||||||
|
// `hold/disk-and-dragout`. The marker stays because it is the only durable
|
||||||
|
// record that a project list was lost — the in-memory symptom does not
|
||||||
|
// survive the next save — and because re-deriving *when* it happened is
|
||||||
|
// impossible after the fact.
|
||||||
|
let marker = corrupt_marker_for(file_path);
|
||||||
|
if marker.exists() {
|
||||||
|
// The *first* corruption is the one that dates the loss.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
|
||||||
|
log::error!(
|
||||||
|
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
|
||||||
|
tell later that the project list was incomplete",
|
||||||
|
marker.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProjectsStore {
|
pub struct ProjectsStore {
|
||||||
projects: Mutex<Vec<Project>>,
|
projects: Mutex<Vec<Project>>,
|
||||||
file_path: PathBuf,
|
file_path: PathBuf,
|
||||||
@@ -43,20 +99,14 @@ impl ProjectsStore {
|
|||||||
Ok(parsed) => (parsed, migrated),
|
Ok(parsed) => (parsed, migrated),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||||
let backup = file_path.with_extension("json.bak");
|
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
||||||
}
|
|
||||||
(Vec::new(), false)
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||||
let backup = file_path.with_extension("json.bak");
|
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
||||||
}
|
|
||||||
(Vec::new(), false)
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,3 +253,89 @@ impl ProjectsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-store-{}-{}",
|
||||||
|
tag,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_load_leaves_a_marker_the_next_write_cannot_erase() {
|
||||||
|
// H-3, the whole chain in one test. `ProjectsStore::new()` swallows an
|
||||||
|
// unparseable file into an empty list *without rewriting it*, and the
|
||||||
|
// first `save()` after that — as little as `update_status()` — writes
|
||||||
|
// `[{one project}]` over it. Everything the old guard keyed on ("the
|
||||||
|
// list is empty and the file exists") is gone at that point, while
|
||||||
|
// every *other* project's volumes are still on the daemon claimed by
|
||||||
|
// nobody.
|
||||||
|
let dir = temp_dir("corrupt");
|
||||||
|
let file = dir.join("projects.json");
|
||||||
|
fs::write(&file, "{ this is not a project list").unwrap();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
record_corrupt_load(&file, &now);
|
||||||
|
|
||||||
|
let marker = corrupt_marker_for(&file);
|
||||||
|
assert!(marker.exists(), "the corrupt load must be recorded on disk");
|
||||||
|
assert_eq!(fs::read_to_string(&marker).unwrap(), now.to_rfc3339());
|
||||||
|
assert!(
|
||||||
|
dir.join("projects.json.bak").exists(),
|
||||||
|
"the unreadable bytes must be kept"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The write that used to erase the evidence. The marker is a separate
|
||||||
|
// file, so it does not care.
|
||||||
|
fs::write(&file, r#"[{"id":"the-one-project-started-since"}]"#).unwrap();
|
||||||
|
assert!(marker.exists());
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_corruption_keeps_the_first_copy_and_the_first_date() {
|
||||||
|
// The `.bak` used to be a fixed name, so a second corruption clobbered
|
||||||
|
// the first — and the first is the only copy taken before the app
|
||||||
|
// rewrote the file with whatever it had in memory, i.e. the only one
|
||||||
|
// that can still hold the full project list.
|
||||||
|
let dir = temp_dir("second");
|
||||||
|
let file = dir.join("projects.json");
|
||||||
|
fs::write(&file, "original bytes").unwrap();
|
||||||
|
let first = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
record_corrupt_load(&file, &first);
|
||||||
|
|
||||||
|
fs::write(&file, "degraded bytes").unwrap();
|
||||||
|
let second = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
record_corrupt_load(&file, &second);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join("projects.json.bak")).unwrap(),
|
||||||
|
"original bytes",
|
||||||
|
"the first copy must survive the second corruption"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join("projects.json.corrupt-20260601-000000.bak")).unwrap(),
|
||||||
|
"degraded bytes"
|
||||||
|
);
|
||||||
|
// And the marker still dates the loss from the first failure, which is
|
||||||
|
// when the project list actually stopped being complete.
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(corrupt_marker_for(&file)).unwrap(),
|
||||||
|
first.to_rfc3339()
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,48 +26,123 @@ const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"
|
|||||||
/// Fixed account name used for every triple-c keychain entry.
|
/// Fixed account name used for every triple-c keychain entry.
|
||||||
const KEYCHAIN_ACCOUNT: &str = "secret";
|
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||||
|
|
||||||
|
/// Every per-project secret this app stores, and therefore every one it has to
|
||||||
|
/// be able to delete.
|
||||||
|
///
|
||||||
|
/// This list is the **only** definition. It used to exist twice — once
|
||||||
|
/// implicitly, as whatever `store_secrets_for_project` happened to write, and
|
||||||
|
/// once explicitly, as a literal array inside `delete_project_secrets` — and
|
||||||
|
/// the two drifted: `openai-compatible-api-key` was added to the writer and
|
||||||
|
/// never to the deleter, so removing a project left a live provider API key in
|
||||||
|
/// the user's login keychain with nothing left in the app that referenced it,
|
||||||
|
/// or would ever offer to clean it up.
|
||||||
|
///
|
||||||
|
/// Drift is now a compile-time-shaped error rather than a review-time one:
|
||||||
|
/// [`project_secret_entry`] refuses a key that is not in this list, so a new
|
||||||
|
/// secret cannot be stored until it has been added here, and adding it here is
|
||||||
|
/// what makes [`delete_project_secrets`] cover it.
|
||||||
|
pub const PROJECT_SECRET_KEYS: &[&str] = &[
|
||||||
|
"git-token",
|
||||||
|
"aws-access-key-id",
|
||||||
|
"aws-secret-access-key",
|
||||||
|
"aws-session-token",
|
||||||
|
"aws-bearer-token",
|
||||||
|
"openai-compatible-api-key",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The keychain entry for one per-project secret, rejecting any key name not in
|
||||||
|
/// [`PROJECT_SECRET_KEYS`]. See that constant for why the rejection matters.
|
||||||
|
fn project_secret_entry(project_id: &str, key_name: &str) -> Result<keyring::Entry, String> {
|
||||||
|
if !PROJECT_SECRET_KEYS.contains(&key_name) {
|
||||||
|
return Err(format!(
|
||||||
|
"Unknown project secret '{}'. Add it to PROJECT_SECRET_KEYS so project deletion \
|
||||||
|
clears it too.",
|
||||||
|
key_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
keyring::Entry::new(&service, KEYCHAIN_ACCOUNT).map_err(|e| format!("Keyring error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
project_secret_entry(project_id, key_name)?
|
||||||
let entry = keyring::Entry::new(&service, "secret")
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
entry
|
|
||||||
.set_password(value)
|
.set_password(value)
|
||||||
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a per-project secret from the OS keychain.
|
/// Retrieve a per-project secret from the OS keychain.
|
||||||
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
match project_secret_entry(project_id, key_name)?.get_password() {
|
||||||
let entry = keyring::Entry::new(&service, "secret")
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.get_password() {
|
|
||||||
Ok(value) => Ok(Some(value)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all known secrets for a project from the OS keychain.
|
/// Delete one per-project secret, treating "wasn't there" as success.
|
||||||
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
pub fn delete_project_secret(project_id: &str, key_name: &str) -> Result<(), String> {
|
||||||
let secret_keys = [
|
match project_secret_entry(project_id, key_name)?.delete_credential() {
|
||||||
"git-token",
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
"aws-access-key-id",
|
Err(e) => Err(format!("Failed to delete project secret '{}': {}", key_name, e)),
|
||||||
"aws-secret-access-key",
|
|
||||||
"aws-session-token",
|
|
||||||
"aws-bearer-token",
|
|
||||||
];
|
|
||||||
for key_name in &secret_keys {
|
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
|
||||||
let entry = keyring::Entry::new(&service, "secret")
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.delete_credential() {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(keyring::Error::NoEntry) => {}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a per-project secret, or **clear** it when there is nothing to write.
|
||||||
|
///
|
||||||
|
/// This is the function every save path should call, and the reason it exists
|
||||||
|
/// is that the obvious `if let Some(v) = … { store(v) }` is wrong. The editors
|
||||||
|
/// in `components/projects/home/config/` send a blanked field as `null`
|
||||||
|
/// (`AccessSection.tsx`: `save({ git_token: gitToken || null })`), so a `None`
|
||||||
|
/// is a user asking for the secret to be *removed* — and skipping it left the
|
||||||
|
/// old value in the keychain, where `load_secrets_for_project` read it straight
|
||||||
|
/// back out and put it back on the project. Clearing a credential through the
|
||||||
|
/// UI was therefore impossible: the field looked empty and the container kept
|
||||||
|
/// getting the old token.
|
||||||
|
///
|
||||||
|
/// `Some("")` and `Some(" ")` are treated the same as `None` — a field the
|
||||||
|
/// user emptied, whichever shape it arrives in — because a stored empty secret
|
||||||
|
/// is not a secret, and `container_config` would inject it as an env var that
|
||||||
|
/// overrides the unset case with a blank.
|
||||||
|
// TODO(handoff): `commands/project_commands.rs::store_secrets_for_project` is
|
||||||
|
// the one caller this is for, and it still uses the `if let Some(v) = … ` shape
|
||||||
|
// that cannot clear anything. That file belongs to another change in this round,
|
||||||
|
// so the switch is deliberately left to it; the six call sites there become
|
||||||
|
// `store_or_clear_project_secret(&project.id, "<key>", field.as_deref())?`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn store_or_clear_project_secret(
|
||||||
|
project_id: &str,
|
||||||
|
key_name: &str,
|
||||||
|
value: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match secret_to_store(value) {
|
||||||
|
Some(v) => store_project_secret(project_id, key_name, v),
|
||||||
|
None => delete_project_secret(project_id, key_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store-or-clear decision, split out so it can be tested without a
|
||||||
|
/// keychain backend: `Some` means "write this", `None` means "remove whatever
|
||||||
|
/// is there".
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn secret_to_store(value: Option<&str>) -> Option<&str> {
|
||||||
|
match value.map(str::trim) {
|
||||||
|
Some(v) if !v.is_empty() => Some(v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every known secret for a project from the OS keychain.
|
||||||
|
///
|
||||||
|
/// Called when a project is removed, so it must cover [`PROJECT_SECRET_KEYS`]
|
||||||
|
/// exhaustively — a key missed here outlives the project that explained it.
|
||||||
|
/// One key failing does not stop the rest: a partial cleanup that keeps going
|
||||||
|
/// leaves strictly fewer credentials behind than one that gives up.
|
||||||
|
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
||||||
|
for key_name in PROJECT_SECRET_KEYS {
|
||||||
|
if let Err(e) = delete_project_secret(project_id, key_name) {
|
||||||
|
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -246,26 +321,125 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
|||||||
/// only enforces auth when a master key is configured, so Triple-C always
|
/// only enforces auth when a master key is configured, so Triple-C always
|
||||||
/// configures one.
|
/// configures one.
|
||||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
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 let Some(existing) = get_gateway_master_key()? {
|
||||||
if !existing.trim().is_empty() {
|
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
regenerate_gateway_master_key()
|
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
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||||
/// previous value must be updated.
|
/// previous value must be updated.
|
||||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||||
// LiteLLM requires the master key to start with `sk-`.
|
// LiteLLM requires the master key to start with `sk-`.
|
||||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
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)
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
entry
|
entry
|
||||||
.set_password(&key)
|
.set_password(key.trim())
|
||||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||||
|
|
||||||
bump_gateway_secret_version()?;
|
bump_gateway_secret_version()
|
||||||
Ok(key)
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The regression this list exists for. `openai-compatible-api-key` was
|
||||||
|
/// written by `store_secrets_for_project` and missing from the delete list,
|
||||||
|
/// so it survived project deletion.
|
||||||
|
#[test]
|
||||||
|
fn every_secret_the_app_writes_is_one_it_can_delete() {
|
||||||
|
for key in [
|
||||||
|
"git-token",
|
||||||
|
"aws-access-key-id",
|
||||||
|
"aws-secret-access-key",
|
||||||
|
"aws-session-token",
|
||||||
|
"aws-bearer-token",
|
||||||
|
"openai-compatible-api-key",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
PROJECT_SECRET_KEYS.contains(&key),
|
||||||
|
"{} is written by commands/project_commands.rs but would outlive the project",
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_key_list_has_no_duplicates() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for key in PROJECT_SECRET_KEYS {
|
||||||
|
assert!(seen.insert(*key), "duplicate project secret key {}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key that is not in the list is refused *before* any keychain entry is
|
||||||
|
/// constructed, which is what makes the list authoritative rather than
|
||||||
|
/// advisory. Without this, a new secret can be stored under a name nothing
|
||||||
|
/// ever deletes.
|
||||||
|
#[test]
|
||||||
|
fn an_unlisted_key_cannot_be_stored_at_all() {
|
||||||
|
let err = store_project_secret("some-project", "brand-new-token", "value")
|
||||||
|
.expect_err("an unlisted key must be refused");
|
||||||
|
assert!(
|
||||||
|
err.contains("PROJECT_SECRET_KEYS"),
|
||||||
|
"the refusal should say how to fix it: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = get_project_secret("some-project", "brand-new-token")
|
||||||
|
.expect_err("an unlisted key must be refused on read too");
|
||||||
|
assert!(err.contains("brand-new-token"), "{}", err);
|
||||||
|
|
||||||
|
let err = delete_project_secret("some-project", "brand-new-token")
|
||||||
|
.expect_err("an unlisted key must be refused on delete too");
|
||||||
|
assert!(err.contains("brand-new-token"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
|
||||||
|
/// a cleared field arrives as `None` — and before this existed, `None` was
|
||||||
|
/// skipped and the old secret stayed in the keychain forever.
|
||||||
|
#[test]
|
||||||
|
fn a_blanked_field_clears_rather_than_being_skipped() {
|
||||||
|
assert_eq!(secret_to_store(None), None);
|
||||||
|
assert_eq!(secret_to_store(Some("")), None);
|
||||||
|
assert_eq!(secret_to_store(Some(" \t\n")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_value_is_stored_trimmed() {
|
||||||
|
assert_eq!(secret_to_store(Some("ghp_abc123")), Some("ghp_abc123"));
|
||||||
|
// Pasted credentials routinely carry a trailing newline.
|
||||||
|
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,78 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<!--
|
||||||
|
This page is served by an axum server bound 0.0.0.0 (remote access is the
|
||||||
|
feature) behind a permissive CORS layer, and it fronts a shell in a container.
|
||||||
|
It gets a CSP of its own because nothing else gives it one: the app's
|
||||||
|
`tauri.conf.json` CSP covers the desktop webview, never this document.
|
||||||
|
|
||||||
|
`default-src 'none'` is the base, so anything not named below is refused
|
||||||
|
outright. What is named:
|
||||||
|
script-src jsdelivr for the three xterm bundles, plus 'unsafe-inline' for
|
||||||
|
this page's own inline <script>. Nonces/hashes were considered
|
||||||
|
and rejected: the file is a static `include_str!()` asset, so a
|
||||||
|
hash would have to be recomputed by hand on every edit to the
|
||||||
|
script, and the failure mode of getting that wrong is a terminal
|
||||||
|
that silently will not start.
|
||||||
|
style-src the xterm stylesheet, this page's <style>, and the one inline
|
||||||
|
`style=` attribute below (style attributes need 'unsafe-inline').
|
||||||
|
connect-src the WebSocket back to this same server. `ws:`/`wss:` as schemes
|
||||||
|
rather than an origin, because the host and port are whatever
|
||||||
|
the user reached this page on and are not knowable at build time.
|
||||||
|
form-action / base-uri / object-src / frame-ancestors — all 'none'. Note
|
||||||
|
`frame-ancestors` is ignored in a <meta> CSP; it is here as a
|
||||||
|
statement of intent, and the real protection would be a response
|
||||||
|
header from `server.rs`.
|
||||||
|
Deliberately absent: 'unsafe-eval', and any origin other than jsdelivr.
|
||||||
|
-->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
default-src 'none';
|
||||||
|
script-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||||
|
style-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||||
|
img-src 'self' data:;
|
||||||
|
font-src 'self' data:;
|
||||||
|
connect-src 'self' ws: wss:;
|
||||||
|
form-action 'none';
|
||||||
|
base-uri 'none';
|
||||||
|
object-src 'none';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
">
|
||||||
<title>Triple-C Web Terminal</title>
|
<title>Triple-C Web Terminal</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
<!--
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
Subresource Integrity on every CDN asset.
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
Without it this page executes whatever jsdelivr returns, inside a document
|
||||||
|
that holds the web terminal's access token and drives a shell in a container —
|
||||||
|
an upstream compromise, a hijacked package version or a MITM on a phone's
|
||||||
|
network is arbitrary code with that reach. The hashes below were computed
|
||||||
|
from the exact bytes at these pinned versions. `crossorigin="anonymous"` is
|
||||||
|
required for SRI to be checked on a cross-origin fetch.
|
||||||
|
|
||||||
|
Bumping a version means recomputing its hash:
|
||||||
|
curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
||||||
|
A mismatched hash blocks the asset, so a stale hash shows up immediately as a
|
||||||
|
terminal that does not render — never as an unverified load.
|
||||||
|
|
||||||
|
These are still remote loads: the remote terminal does not work with no
|
||||||
|
internet on the client side, and vendoring the ~300 KB of minified xterm into
|
||||||
|
this file would fix that. It was not done here — SRI already closes the
|
||||||
|
integrity half, which is the security half, and the availability half is a
|
||||||
|
separate call about binary size and diff readability.
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
||||||
|
integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"
|
||||||
|
integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"
|
||||||
|
integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"
|
||||||
|
integrity="sha384-S1biLeI8L/bFduIVvCxbn/l4EtaG4nTqQjGF7qCYTbsGXGFe8KgIKXtw4+UWxprv"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
@@ -334,6 +401,9 @@
|
|||||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
enterkeyhint="send" inputmode="text">
|
enterkeyhint="send" inputmode="text">
|
||||||
<button class="key-btn" id="btnEnter">Enter</button>
|
<button class="key-btn" id="btnEnter">Enter</button>
|
||||||
|
<!-- A newline *without* submitting. There is no Shift on a phone keyboard,
|
||||||
|
so the chord the desktop app binds needs a key of its own here. -->
|
||||||
|
<button class="key-btn" id="btnNewline" title="Insert a newline without submitting (Shift+Enter)">↵+</button>
|
||||||
<button class="key-btn" id="btnTab">Tab</button>
|
<button class="key-btn" id="btnTab">Tab</button>
|
||||||
<button class="key-btn" id="btnCtrlC">^C</button>
|
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +430,19 @@
|
|||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
const mobileInput = document.getElementById('mobileInput');
|
const mobileInput = document.getElementById('mobileInput');
|
||||||
const btnEnter = document.getElementById('btnEnter');
|
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 btnTab = document.getElementById('btnTab');
|
||||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
@@ -519,7 +602,7 @@
|
|||||||
updateProjectList(msg.projects);
|
updateProjectList(msg.projects);
|
||||||
break;
|
break;
|
||||||
case 'opened':
|
case 'opened':
|
||||||
onSessionOpened(msg.session_id, msg.project_name);
|
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
onSessionOutput(msg.session_id, msg.data);
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
@@ -570,8 +653,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionOpened(sessionId, projectName) {
|
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||||
const sessionType = pendingSessionType || 'claude';
|
// 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;
|
pendingSessionType = null;
|
||||||
|
|
||||||
// Create terminal
|
// Create terminal
|
||||||
@@ -647,6 +740,34 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||||
|
// submitting it. xterm.js does not consult `shiftKey` for Enter, so
|
||||||
|
// without this Shift+Enter is byte-identical to Enter.
|
||||||
|
//
|
||||||
|
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
|
||||||
|
// meta, and it is the same sequence its own `/terminal-setup` installs for
|
||||||
|
// VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that
|
||||||
|
// also works in Claude Code, but a shell would *run* the line, so the two
|
||||||
|
// session types would diverge. Claude sessions only, for that reason —
|
||||||
|
// `bash -l` has no readline binding for `\e\r`.
|
||||||
|
term.attachCustomKeyEventHandler(e => {
|
||||||
|
if (
|
||||||
|
e.type === 'keydown' && e.key === 'Enter' && e.shiftKey &&
|
||||||
|
!e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
|
||||||
|
sessionType === 'claude'
|
||||||
|
) {
|
||||||
|
sendTerminalInput('\x1b\r');
|
||||||
|
// `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;
|
||||||
|
});
|
||||||
|
|
||||||
// Track scroll position for scroll-to-bottom button
|
// Track scroll position for scroll-to-bottom button
|
||||||
term.onScroll(() => updateScrollButton());
|
term.onScroll(() => updateScrollButton());
|
||||||
|
|
||||||
@@ -698,6 +819,7 @@
|
|||||||
switchToSession(remaining[remaining.length - 1]);
|
switchToSession(remaining[remaining.length - 1]);
|
||||||
} else {
|
} else {
|
||||||
activeSessionId = null;
|
activeSessionId = null;
|
||||||
|
syncNewlineButton();
|
||||||
emptyState.style.display = '';
|
emptyState.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -724,6 +846,7 @@
|
|||||||
|
|
||||||
function switchToSession(sessionId) {
|
function switchToSession(sessionId) {
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
|
syncNewlineButton();
|
||||||
|
|
||||||
// Update tab styles
|
// Update tab styles
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
@@ -799,7 +922,11 @@
|
|||||||
sendTerminalInput(val);
|
sendTerminalInput(val);
|
||||||
mobileInput.value = '';
|
mobileInput.value = '';
|
||||||
}
|
}
|
||||||
sendTerminalInput('\r');
|
// Shift+Enter is a newline, not a submit — same bytes, and the same
|
||||||
|
// 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 && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendTerminalInput('\t');
|
sendTerminalInput('\t');
|
||||||
@@ -807,6 +934,27 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
btnEnter.onclick = () => { sendTerminalInput('\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(); };
|
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
|||||||
Opened {
|
Opened {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
project_name: 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 {
|
Output {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
|||||||
let _ = out_tx.send(ServerMessage::Opened {
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
session_id,
|
session_id,
|
||||||
project_name,
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"identifier": "com.triple-c.desktop",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827; form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico",
|
||||||
|
"icons/icon.icns",
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
|
import NotesDock from "./components/layout/NotesDock";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||||
import ToastHost from "./components/ui/ToastHost";
|
import ToastHost from "./components/ui/ToastHost";
|
||||||
|
import { PaneVisibilityProvider } from "./components/ui/PaneVisibility";
|
||||||
import StatusIndicator from "./components/ui/StatusIndicator";
|
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||||
import Button from "./components/ui/Button";
|
import Button from "./components/ui/Button";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
@@ -128,23 +130,39 @@ export default function App() {
|
|||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
{/* Every tab stays mounted and the inactive ones are merely
|
||||||
|
`hidden`, which a dialog's portal to `document.body` does not
|
||||||
|
inherit: a confirmation opened in one project stayed painted
|
||||||
|
over whatever tab the user switched to, kept its focus trap,
|
||||||
|
and — being a blocking overlay — refused every native file
|
||||||
|
drop in the window. `PaneVisibilityProvider` is how a `Modal`
|
||||||
|
inside a pane finds out the pane stepped aside. */}
|
||||||
{homeProjectIds.map((projectId) => (
|
{homeProjectIds.map((projectId) => (
|
||||||
<ProjectHome
|
<PaneVisibilityProvider
|
||||||
key={projectId}
|
key={projectId}
|
||||||
|
visible={activeTabKey === homeTabKey(projectId)}
|
||||||
|
>
|
||||||
|
<ProjectHome
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
active={activeTabKey === homeTabKey(projectId)}
|
active={activeTabKey === homeTabKey(projectId)}
|
||||||
/>
|
/>
|
||||||
|
</PaneVisibilityProvider>
|
||||||
))}
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<TerminalView
|
<PaneVisibilityProvider
|
||||||
key={session.id}
|
key={session.id}
|
||||||
|
visible={session.id === activeSessionId}
|
||||||
|
>
|
||||||
|
<TerminalView
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
active={session.id === activeSessionId}
|
active={session.id === activeSessionId}
|
||||||
/>
|
/>
|
||||||
|
</PaneVisibilityProvider>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<NotesDock />
|
||||||
</div>
|
</div>
|
||||||
<StatusBar stt={stt} />
|
<StatusBar stt={stt} />
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
@@ -156,6 +174,9 @@ export default function App() {
|
|||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
/* Covers the whole window, so no pane underneath may accept a
|
||||||
|
native file drop while it is up — see `lib/dropTarget.ts`. */
|
||||||
|
data-blocks-drop="true"
|
||||||
data-testid="shutdown-overlay"
|
data-testid="shutdown-overlay"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderMarkdown } from "./HelpDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `renderMarkdown` builds HTML by regex substitution and the result is handed
|
||||||
|
* to `dangerouslySetInnerHTML`. Its input is the help document, which is
|
||||||
|
* fetched from GitHub at runtime — remote, versioned by someone else, and not
|
||||||
|
* something the app gets to trust. These tests pin the escaping.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Parse rendered HTML and return its first anchor, asserting that *no* element
|
||||||
|
* anywhere in the output grew an attribute outside the allowed set. A broken
|
||||||
|
* attribute value is only interesting if it becomes an attribute, so the check
|
||||||
|
* has to run through a real parser rather than over the string.
|
||||||
|
*/
|
||||||
|
const ALLOWED_ATTRS = new Set(["class", "href", "target", "rel", "id"]);
|
||||||
|
|
||||||
|
function onlyAnchor(html: string): HTMLAnchorElement {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
||||||
|
for (const name of attrNames(el)) {
|
||||||
|
expect(ALLOWED_ATTRS.has(name), `unexpected attribute ${name}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const anchors = doc.querySelectorAll("a");
|
||||||
|
expect(anchors.length).toBeGreaterThan(0);
|
||||||
|
return anchors[0] as HTMLAnchorElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attribute names the parser actually saw on an element. */
|
||||||
|
function attrNames(el: Element): string[] {
|
||||||
|
return Array.from(el.attributes).map((a) => a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderMarkdown escaping", () => {
|
||||||
|
it("escapes the quote characters an attribute value is delimited by", () => {
|
||||||
|
const html = renderMarkdown('He said "hi" and it\'s fine.');
|
||||||
|
expect(html).not.toMatch(/said "hi"/);
|
||||||
|
expect(html).toContain(""hi"");
|
||||||
|
expect(html).toContain("it's");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a link target break out of href=\"…\"", () => {
|
||||||
|
// The sink: the URL capture is `[^)]+`, which includes `"` and spaces, and
|
||||||
|
// the value lands directly inside `href="…"`. Asserted through the DOM,
|
||||||
|
// not by string matching — the payload text legitimately survives *inside*
|
||||||
|
// the attribute value; what must not happen is it becoming an attribute.
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown(
|
||||||
|
'[click](https://example.com/" onmouseover="steal() formaction="https://evil.example)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||||
|
expect(a.getAttribute("href")).toContain('" onmouseover="');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an in-document anchor break out of href=\"#…\"", () => {
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown('[jump](#top" onfocus="steal() autofocus="x)'),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a bare URL break out of href=\"…\"", () => {
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown('See https://example.com/a"onmouseover="steal()\n'),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders ordinary links intact", () => {
|
||||||
|
const html = renderMarkdown("[docs](https://example.com/a?x=1&y=2)");
|
||||||
|
// `&` was entity-escaped by the first pass and must not be escaped twice.
|
||||||
|
expect(html).toContain('href="https://example.com/a?x=1&y=2"');
|
||||||
|
expect(html).not.toContain("&amp;");
|
||||||
|
expect(html).toContain('target="_blank"');
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"');
|
||||||
|
expect(html).toContain(">docs</a>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders an in-document anchor link intact", () => {
|
||||||
|
const html = renderMarkdown("[jump](#getting-started)");
|
||||||
|
expect(html).toContain('href="#getting-started"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps header slugs stable across the new quote escaping", () => {
|
||||||
|
// The regression this guards: quotes now become entities *before*
|
||||||
|
// `slugify` sees them, and an entity's letters would otherwise survive
|
||||||
|
// into the id ("claude39s-setup"), silently breaking every
|
||||||
|
// `[…](#claudes-setup)` in the document.
|
||||||
|
expect(renderMarkdown("## Claude's setup")).toContain('id="claudes-setup"');
|
||||||
|
expect(renderMarkdown('## The "safe" mode')).toContain('id="the-safe-mode"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses to emit raw tags from the source document", () => {
|
||||||
|
const html = renderMarkdown("<img src=x onerror=alert(1)>");
|
||||||
|
expect(html).not.toContain("<img");
|
||||||
|
expect(html).toContain("<img");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,21 +12,67 @@ function slugify(text: string): string {
|
|||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||||
|
// Quote characters are escaped to entities before this runs (see
|
||||||
|
// `renderMarkdown`). Drop those two entities whole, so a header with an
|
||||||
|
// apostrophe or a quote slugifies to what it did when the character was
|
||||||
|
// simply stripped — otherwise every such anchor id silently changes and
|
||||||
|
// the in-document links pointing at it stop resolving. `&`/`<`/
|
||||||
|
// `>` are deliberately not in this list: they were already entities
|
||||||
|
// before, so their existing (odd) slugs are the established ones.
|
||||||
|
.replace(/"|'/g, "")
|
||||||
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||||
.replace(/\s+/g, "-") // spaces to dashes
|
.replace(/\s+/g, "-") // spaces to dashes
|
||||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||||
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simple markdown-to-HTML converter for the help content. */
|
/**
|
||||||
function renderMarkdown(md: string): string {
|
* Escape a captured markdown value that is about to be interpolated into an
|
||||||
|
* HTML *attribute* value.
|
||||||
|
*
|
||||||
|
* `renderMarkdown` entity-escapes the whole document first, but that pass only
|
||||||
|
* covered `&`, `<` and `>` — not the quote characters, which is all an
|
||||||
|
* attribute value is delimited by. `[x](https://a" onload="…)` therefore closed
|
||||||
|
* `href="` and started a new attribute, because the URL capture is `[^)]+` and
|
||||||
|
* `"` is in `[^)]`. The document is remote GitHub markdown, so that capture is
|
||||||
|
* not ours to trust.
|
||||||
|
*
|
||||||
|
* Only quotes are escaped here: `&`, `<` and `>` have already been converted by
|
||||||
|
* the caller, and re-escaping the `&` would double-encode every `&` in a
|
||||||
|
* query string.
|
||||||
|
*/
|
||||||
|
function attr(value: string): string {
|
||||||
|
return value.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple markdown-to-HTML converter for the help content.
|
||||||
|
*
|
||||||
|
* Exported for `HelpDialog.test.tsx`: the output goes to
|
||||||
|
* `dangerouslySetInnerHTML`, so the escaping rules below are security rules and
|
||||||
|
* need to be asserted rather than assumed.
|
||||||
|
*/
|
||||||
|
export function renderMarkdown(md: string): string {
|
||||||
let html = md;
|
let html = md;
|
||||||
|
|
||||||
// Normalize line endings
|
// Normalize line endings
|
||||||
html = html.replace(/\r\n/g, "\n");
|
html = html.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
// Escape HTML entities (but we'll re-introduce tags below)
|
// Escape HTML entities (but we'll re-introduce tags below).
|
||||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
//
|
||||||
|
// The quote characters are part of this on purpose. Everything below builds
|
||||||
|
// HTML by regex substitution, and several of those substitutions drop a
|
||||||
|
// capture straight into an attribute value (`href="$2"`). Leaving `"` and `'`
|
||||||
|
// live meant a link target could close the attribute and open another one —
|
||||||
|
// in a document fetched from GitHub at runtime and handed to
|
||||||
|
// `dangerouslySetInnerHTML`. Escaping here closes every such sink at the
|
||||||
|
// source; `attr()` below is the belt to this pair of braces.
|
||||||
|
html = html
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
// Fenced code blocks (```...```)
|
// Fenced code blocks (```...```)
|
||||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||||
@@ -84,13 +130,15 @@ function renderMarkdown(md: string): string {
|
|||||||
// Markdown-style anchor links [text](#anchor)
|
// Markdown-style anchor links [text](#anchor)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||||
'<a class="help-link" href="#$2">$1</a>',
|
(_m, text: string, anchor: string) =>
|
||||||
|
`<a class="help-link" href="#${attr(anchor)}">${text}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Markdown-style external links [text](url)
|
// Markdown-style external links [text](url)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||||
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
(_m, text: string, url: string) =>
|
||||||
|
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unordered list items (- ...)
|
// Unordered list items (- ...)
|
||||||
@@ -117,7 +165,8 @@ function renderMarkdown(md: string): string {
|
|||||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
(_m, url: string) =>
|
||||||
|
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap remaining loose text lines in paragraphs
|
// Wrap remaining loose text lines in paragraphs
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import MainTabs from "./MainTabs";
|
||||||
|
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
|
||||||
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
|
const close = vi.fn();
|
||||||
|
|
||||||
|
const sessions: TerminalSession[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api-server",
|
||||||
|
sessionName: "claude",
|
||||||
|
sessionType: "claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api-server",
|
||||||
|
sessionName: "shell",
|
||||||
|
sessionType: "bash",
|
||||||
|
},
|
||||||
|
] as unknown as TerminalSession[];
|
||||||
|
|
||||||
|
const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
status: "running",
|
||||||
|
permission_mode: "bypass",
|
||||||
|
renamed_session_names: {},
|
||||||
|
},
|
||||||
|
] as unknown as Project[];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ sessions, close }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({ projects, update: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = homeTabKey("p1");
|
||||||
|
const S1 = terminalTabKey("s1");
|
||||||
|
const S2 = terminalTabKey("s2");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pointer event carrying a real `clientX`.
|
||||||
|
*
|
||||||
|
* jsdom implements no `PointerEvent`, so Testing Library's synthesized one has
|
||||||
|
* no coordinates — and the coordinate is the whole point here, since it decides
|
||||||
|
* which slot the drop lands in. `MouseEvent` has one, and React dispatches on
|
||||||
|
* the event's type name either way.
|
||||||
|
*/
|
||||||
|
function pointer(el: Element, type: string, clientX: number) {
|
||||||
|
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, button: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press, move past the drag threshold, and release over `endX`. */
|
||||||
|
function dragTab(el: Element, fromX: number, endX: number) {
|
||||||
|
pointer(el, "pointerdown", fromX);
|
||||||
|
pointer(el, "pointermove", endX);
|
||||||
|
pointer(el, "pointerup", endX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
|
||||||
|
function place(el: Element, left: number, width = 100) {
|
||||||
|
el.getBoundingClientRect = () =>
|
||||||
|
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay the strip out as three 100px tabs starting at x=0. */
|
||||||
|
function laidOut() {
|
||||||
|
const tabs = screen.getAllByRole("tab");
|
||||||
|
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = () => useAppState.getState().tabOrder;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [HOME, S1, S2],
|
||||||
|
activeTabKey: HOME,
|
||||||
|
activeSessionId: null,
|
||||||
|
projects,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MainTabs reordering", () => {
|
||||||
|
it("drags a tab to the front", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
// Left half of the first tab — the tab lands before it.
|
||||||
|
dragTab(tabs[2], 250, 10);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops after the tab when the pointer is past its midpoint", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[0], 50, 190);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops at the end when released past the last tab", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[0], 50, 800);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, S2, HOME]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dragging does not steal the selection", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[1], 150, 290);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S2, S1]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a drag select the tab's text", () => {
|
||||||
|
// A pointer-driven drag is still a mouse drag as far as the browser is
|
||||||
|
// concerned, so without this the label highlights blue while you move it.
|
||||||
|
// The rename field is exempt — selecting there is the whole point.
|
||||||
|
render(<MainTabs />);
|
||||||
|
for (const tab of screen.getAllByRole("tab")) {
|
||||||
|
expect(tab.className).toContain("select-none");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.doubleClick(screen.getAllByRole("tab")[1]);
|
||||||
|
expect(screen.getByLabelText("Rename tab").className).toContain("select-text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the tab itself under the cursor while dragging", () => {
|
||||||
|
// A dimmed source tab and a thin line do not read as "I am holding this
|
||||||
|
// tab" — the dragged copy is what makes the gesture legible.
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 120);
|
||||||
|
|
||||||
|
const ghost = screen.getByTestId("tab-drag-ghost");
|
||||||
|
expect(ghost).toHaveTextContent("shell (bash)");
|
||||||
|
expect(ghost).toHaveTextContent("▣");
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerup", 120);
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the project name when a home tab is dragged", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[0], "pointerdown", 50);
|
||||||
|
pointer(tabs[0], "pointermove", 250);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server");
|
||||||
|
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the dragged copy when the drag is abandoned", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons the drag on Escape, leaving the order alone", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a press that barely moves as a click, not a drag", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
// Two pixels of tremble, under the threshold.
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 252);
|
||||||
|
pointer(tabs[2], "pointerup", 252);
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(S2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not select the tab it just dropped", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[2], 250, 10);
|
||||||
|
// The browser fires a click after the pointerup that ended the drag.
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a press that starts on the close button", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
const close = screen.getByRole("button", { name: "Close shell (bash)" });
|
||||||
|
|
||||||
|
fireEvent(close, new MouseEvent("pointerdown", { bubbles: true, clientX: 290, button: 0 }));
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not drag a tab that is being renamed — that drag selects text", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
fireEvent.doubleClick(tabs[1]);
|
||||||
|
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
|
||||||
|
|
||||||
|
dragTab(screen.getAllByRole("tab")[1], 150, 10);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no drag payload that another element could receive", () => {
|
||||||
|
// An HTML5 drag would put the tab key in a DataTransfer, and releasing over
|
||||||
|
// any text field in the app would type `term:…` into it. Pointer events
|
||||||
|
// have nothing to hand over, and the tabs are not draggable at all.
|
||||||
|
render(<MainTabs />);
|
||||||
|
for (const tab of screen.getAllByRole("tab")) {
|
||||||
|
expect(tab).not.toHaveAttribute("draggable", "true");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../store/appState";
|
} from "../../store/appState";
|
||||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { sessionDisplayName } from "../../lib/sessionName";
|
||||||
import type { PermissionMode } from "../../lib/types";
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
@@ -18,6 +19,9 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pixels of horizontal travel before a press becomes a drag rather than a click. */
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
@@ -28,22 +32,46 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
|||||||
/**
|
/**
|
||||||
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
* terminals (▣).
|
* terminals (▣).
|
||||||
|
*
|
||||||
|
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see
|
||||||
|
* `pointerProps` for why neither of the two obvious alternatives works.
|
||||||
|
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
||||||
*/
|
*/
|
||||||
export default function MainTabs() {
|
export default function MainTabs() {
|
||||||
const { sessions, close } = useTerminal();
|
const { sessions, close } = useTerminal();
|
||||||
const { projects, update } = useProjects();
|
const { projects, update } = useProjects();
|
||||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
tabOrder: s.tabOrder,
|
tabOrder: s.tabOrder,
|
||||||
activeTabKey: s.activeTabKey,
|
activeTabKey: s.activeTabKey,
|
||||||
setActiveTabKey: s.setActiveTabKey,
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
closeHomeTab: s.closeHomeTab,
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
moveTab: s.moveTab,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
/** Where the dragged tab is drawn, and how it looked when the drag started. */
|
||||||
|
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** A press that has not yet moved far enough to be a drag. */
|
||||||
|
const pending = useRef<{
|
||||||
|
key: string;
|
||||||
|
startX: number;
|
||||||
|
dragging: boolean;
|
||||||
|
offsetX: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
top: number;
|
||||||
|
} | null>(null);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -63,6 +91,21 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
}, [renamingId]);
|
}, [renamingId]);
|
||||||
|
|
||||||
|
// Escape abandons a drag — the one affordance a pointer-event drag has to
|
||||||
|
// supply for itself, since the OS is not running this one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragKey) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
pending.current = null;
|
||||||
|
setDragKey(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [dragKey]);
|
||||||
|
|
||||||
if (tabOrder.length === 0) {
|
if (tabOrder.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
@@ -135,16 +178,139 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabClass = (active: boolean) =>
|
const tabClass = (active: boolean, dragging: boolean) =>
|
||||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}`;
|
}${dragging ? " opacity-40" : ""}`;
|
||||||
|
|
||||||
return (
|
/**
|
||||||
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||||
{tabOrder.map((key) => {
|
* uses — a ghost showing a different name from the tab it came from would be
|
||||||
|
* worse than no ghost.
|
||||||
|
*/
|
||||||
|
const tabLabel = (key: string): string => {
|
||||||
|
if (isHomeTab(key)) {
|
||||||
|
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||||
|
}
|
||||||
|
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||||
|
if (!session) return "";
|
||||||
|
return sessionDisplayName(
|
||||||
|
session,
|
||||||
|
projects.find((p) => p.id === session.projectId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
pending.current = null;
|
||||||
|
setDragKey(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which slot the pointer is currently over, as an insertion index into
|
||||||
|
* `tabOrder`.
|
||||||
|
*
|
||||||
|
* Measured from the tabs actually on screen rather than from the event's
|
||||||
|
* target, so the answer is the same whatever the pointer happens to be over —
|
||||||
|
* including the drop marker itself, and including a `tabOrder` entry whose
|
||||||
|
* session has already gone and which therefore renders nothing.
|
||||||
|
*/
|
||||||
|
const dropIndexAt = (clientX: number): number => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return tabOrder.length;
|
||||||
|
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
|
||||||
|
}
|
||||||
|
return tabOrder.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging is done with pointer events, not HTML5 drag-and-drop.
|
||||||
|
*
|
||||||
|
* Two reasons, both load-bearing. Tauri's `dragDropEnabled` — which the
|
||||||
|
* terminal needs left on, because only the native drag-drop event carries
|
||||||
|
* dropped *file paths* — blocks HTML5 drag inside the webview on Windows, so
|
||||||
|
* an HTML5 implementation is simply dead there. And an HTML5 drag carries a
|
||||||
|
* `DataTransfer`: released over any text field in the app, the default
|
||||||
|
* handler types the payload into it.
|
||||||
|
*/
|
||||||
|
const pointerProps = (key: string, renaming: boolean) => ({
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
// Left button only, never from the close button, and never while the
|
||||||
|
// rename input is up — that drag is a text selection.
|
||||||
|
if (e.button !== 0 || renaming) return;
|
||||||
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
pending.current = {
|
||||||
|
key,
|
||||||
|
startX: e.clientX,
|
||||||
|
dragging: false,
|
||||||
|
// Where inside the tab the pointer grabbed it, so the ghost sits under
|
||||||
|
// the cursor exactly where the real tab was — the thing that makes a
|
||||||
|
// drag feel like moving an object rather than nudging a setting.
|
||||||
|
offsetX: e.clientX - rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
top: rect.top,
|
||||||
|
};
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
},
|
||||||
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = pending.current;
|
||||||
|
if (!drag) return;
|
||||||
|
// A few pixels of slop, so a click that trembles stays a click.
|
||||||
|
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
|
||||||
|
drag.dragging = true;
|
||||||
|
setDragKey(drag.key);
|
||||||
|
setDropIndex(dropIndexAt(e.clientX));
|
||||||
|
setGhost({
|
||||||
|
x: e.clientX - drag.offsetX,
|
||||||
|
y: drag.top,
|
||||||
|
label: tabLabel(drag.key),
|
||||||
|
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = pending.current;
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
if (!drag?.dragging) {
|
||||||
|
pending.current = null;
|
||||||
|
return; // a plain click: leave it to `onClick` to select the tab
|
||||||
|
}
|
||||||
|
const to = dropIndexAt(e.clientX);
|
||||||
|
const from = tabOrder.indexOf(drag.key);
|
||||||
|
// `to` is a slot in the strip as it looks *now*; `moveTab` places the tab
|
||||||
|
// after pulling it out, so every slot past its own shifts down one.
|
||||||
|
if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to);
|
||||||
|
// The click that follows this pointerup is the drag's, not a selection.
|
||||||
|
suppressClick.current = true;
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onPointerCancel: endDrag,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A drag in progress swallows the click it ends with. */
|
||||||
|
const activateTab = (key: string) => {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTabKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropMarker = (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="tab-drop-marker"
|
||||||
|
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0 pointer-events-none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTab = (key: string, index: number) => {
|
||||||
const active = activeTabKey === key;
|
const active = activeTabKey === key;
|
||||||
|
|
||||||
if (isHomeTab(key)) {
|
if (isHomeTab(key)) {
|
||||||
@@ -153,18 +319,19 @@ export default function MainTabs() {
|
|||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => setActiveTabKey(key)}
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(key)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveTabKey(key);
|
setActiveTabKey(key);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={tabClass(active)}
|
{...pointerProps(key, false)}
|
||||||
|
className={tabClass(active, dragKey === key)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||||
@@ -191,23 +358,17 @@ export default function MainTabs() {
|
|||||||
const session = sessions.find((s) => s.id === sessionId);
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
const customName = getCustomName(session.projectId, session.id);
|
const displayLabel = sessionDisplayName(session, project);
|
||||||
const baseLabel =
|
|
||||||
(session.sessionName ?? session.projectName) +
|
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
const displayLabel = customName
|
|
||||||
? `${session.projectName}: ${customName}`
|
|
||||||
: baseLabel;
|
|
||||||
const isRenaming = renamingId === session.id;
|
const isRenaming = renamingId === session.id;
|
||||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(terminalTabKey(session.id))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -219,7 +380,8 @@ export default function MainTabs() {
|
|||||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => startRename(session.id)}
|
onDoubleClick={() => startRename(session.id)}
|
||||||
className={tabClass(active)}
|
{...pointerProps(key, isRenaming)}
|
||||||
|
className={tabClass(active, dragKey === key)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
@@ -234,7 +396,7 @@ export default function MainTabs() {
|
|||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
if (e.key === "Escape") setRenamingId(null);
|
if (e.key === "Escape") setRenamingId(null);
|
||||||
}}
|
}}
|
||||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
className="max-w-[180px] px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||||
@@ -263,8 +425,53 @@ export default function MainTabs() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The marker goes before the first tab that is *actually on screen* at or
|
||||||
|
// past the drop slot. Addressing it by raw index would lose it whenever a
|
||||||
|
// `tabOrder` entry renders nothing — the window between a session ending and
|
||||||
|
// the store dropping its key — leaving the drag with no visible target.
|
||||||
|
let markerPending = dragKey !== null && dropIndex !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
|
{tabOrder.map((key, index) => {
|
||||||
|
const tab = renderTab(key, index);
|
||||||
|
if (!tab) return null;
|
||||||
|
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||||
|
if (marker) markerPending = false;
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
{marker && dropMarker}
|
||||||
|
{tab}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* The empty run after the last tab is a drop target too — it is where
|
||||||
|
the hand naturally goes to say "put it at the end". */}
|
||||||
|
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||||
|
|
||||||
|
{ghost && (
|
||||||
|
// A copy of the tab, following the pointer. Without it the only
|
||||||
|
// feedback is a dimmed source and a thin line, which reads as "some
|
||||||
|
// setting changed" rather than "I am holding this tab".
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="tab-drag-ghost"
|
||||||
|
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
left: ghost.x,
|
||||||
|
top: ghost.y,
|
||||||
|
boxShadow: "var(--shadow-overlay)",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
|
||||||
|
<span className="truncate max-w-[180px]">{ghost.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{menu && (() => {
|
{menu && (() => {
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
const hasCustom = session
|
const hasCustom = session
|
||||||
|
|||||||
@@ -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) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom,
|
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -20,9 +20,16 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
terminalAtBottom: s.terminalAtBottom,
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||||
|
notesDockOpen: s.notesDockOpen,
|
||||||
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
// Only in a Claude tab: the chord is bound there and nowhere else, and a hint
|
||||||
|
// for a key that does nothing is worse than no hint.
|
||||||
|
const inClaudeSession = sessions.some(
|
||||||
|
(s) => s.id === activeSessionId && s.sessionType === "claude",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||||
@@ -45,6 +52,14 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!terminalHasSelection && inClaudeSession && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
<span title="Sends ESC+CR — the sequence Claude Code's own /terminal-setup installs. Alt+Enter does the same.">
|
||||||
|
Shift+Enter: newline
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
{activeSessionId && !terminalAtBottom && (
|
{activeSessionId && !terminalAtBottom && (
|
||||||
@@ -56,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
Jump to Current ↓
|
Jump to Current ↓
|
||||||
</button>
|
</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 && (
|
{sttEnabled && activeSessionId && (
|
||||||
<SttButton
|
<SttButton
|
||||||
state={stt.state}
|
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) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
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()) {
|
if (!name.trim()) {
|
||||||
setError("Project name is required");
|
setError("Project name is required");
|
||||||
return;
|
return;
|
||||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
<Button size="md" variant="ghost" onClick={onClose}>
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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"}
|
{loading ? "Adding…" : "Add Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
|
||||||
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
|
||||||
|
function renderEditor(
|
||||||
|
settings: ClaudeCodeSettings | null,
|
||||||
|
scope: "global" | "project" = "global",
|
||||||
|
) {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<ClaudeCodeSettingsEditor
|
||||||
|
scope={scope}
|
||||||
|
settings={settings}
|
||||||
|
disabled={false}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return onSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaudeCodeSettingsEditor", () => {
|
||||||
|
it("shows the two default-on settings as on for a project that never touched them", () => {
|
||||||
|
// Claude Code's session recap and fullscreen auto-scroll are both on by
|
||||||
|
// default, and the fields behind them store the *disabled* sense. A toggle
|
||||||
|
// rendered straight from the field would tell every existing user their
|
||||||
|
// recap is off.
|
||||||
|
renderEditor(null);
|
||||||
|
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
|
||||||
|
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
|
||||||
|
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the disabled sense when an inverted toggle is switched off", () => {
|
||||||
|
const onSave = renderEditor(null);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ session_recap_disabled: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses back to null once every setting is at its default again", () => {
|
||||||
|
// `null` is what tells the backend this project adds nothing over the
|
||||||
|
// global settings, so the round trip has to land exactly back on it.
|
||||||
|
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the classic renderer as a choice distinct from automatic", () => {
|
||||||
|
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
|
||||||
|
// different, and previously unreachable, instruction.
|
||||||
|
const onSave = renderEditor(null);
|
||||||
|
const tui = screen.getByLabelText("TUI mode");
|
||||||
|
expect(
|
||||||
|
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||||
|
).toEqual(["", "default", "fullscreen"]);
|
||||||
|
fireEvent.change(tui, { target: { value: "default" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
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", "max"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project scope", () => {
|
||||||
|
it("offers Global as a third state so a project can decline to have an opinion", () => {
|
||||||
|
renderEditor(null, "project");
|
||||||
|
const focus = screen.getByLabelText("Focus mode");
|
||||||
|
expect(
|
||||||
|
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||||
|
).toEqual(["global", "off", "on"]);
|
||||||
|
expect((focus as HTMLSelectElement).value).toBe("global");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a deliberate false so the project can turn a global On back off", () => {
|
||||||
|
// The reason the field widened from boolean to boolean|null. Under the
|
||||||
|
// old merge there was no project value that could produce this.
|
||||||
|
const onSave = renderEditor(null, "project");
|
||||||
|
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ focus_mode: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collapse a deliberate off to null", () => {
|
||||||
|
// `null` means inherit. Collapsing here would silently hand the setting
|
||||||
|
// straight back to the global value the user just overrode.
|
||||||
|
const onSave = renderEditor(null, "project");
|
||||||
|
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||||
|
expect(onSave).not.toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips the inverted fields through the disabled sense", () => {
|
||||||
|
// Session recap stores `session_recap_disabled`, so choosing "off" has to
|
||||||
|
// store `true` and choosing "on" has to store `false`.
|
||||||
|
const onSave = renderEditor(null, "project");
|
||||||
|
const recap = screen.getByLabelText("Session recap");
|
||||||
|
|
||||||
|
fireEvent.change(recap, { target: { value: "off" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ session_recap_disabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(recap, { target: { value: "on" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ session_recap_disabled: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a stored override rather than the inherited state", () => {
|
||||||
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
|
||||||
|
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
|
||||||
|
"off",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scroll is the second inverted field and had no project-scope test at
|
||||||
|
* all — every assertion above rides on `session_recap_disabled`, so a
|
||||||
|
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
|
||||||
|
* one of the two and pass silently for the other. It is stored as
|
||||||
|
* `auto_scroll_disabled`, so every value here reads back the other way up.
|
||||||
|
*/
|
||||||
|
describe("auto-scroll", () => {
|
||||||
|
const AUTO = "Auto-scroll";
|
||||||
|
|
||||||
|
it("starts on Global, which is not the same as on", () => {
|
||||||
|
// Claude Code scrolls by default, so an inheriting project *behaves*
|
||||||
|
// as on — but it has taken no position, and rendering it as "On" would
|
||||||
|
// make a later global change look like it had no effect.
|
||||||
|
renderEditor(null, "project");
|
||||||
|
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the disabled sense in both directions", () => {
|
||||||
|
const onSave = renderEditor(null, "project");
|
||||||
|
const auto = screen.getByLabelText(AUTO);
|
||||||
|
|
||||||
|
fireEvent.change(auto, { target: { value: "off" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ auto_scroll_disabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(auto, { target: { value: "on" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ auto_scroll_disabled: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the setting back to the global level when Global is chosen", () => {
|
||||||
|
// Back to no opinion, and with nothing else set that collapses the
|
||||||
|
// whole object to `null` — the value that means "adds nothing over the
|
||||||
|
// global settings".
|
||||||
|
const onSave = renderEditor(
|
||||||
|
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
|
||||||
|
"project",
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a stored override back the right way up", () => {
|
||||||
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
|
||||||
|
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inverted fields store a *deviation*, so a stored `false` is the one
|
||||||
|
* value that means "the user deliberately re-enabled the default". Nothing
|
||||||
|
* asserted it: every existing test drives the `true` (turned off) direction
|
||||||
|
* or the `null` (untouched) one, and both scopes would still read correctly
|
||||||
|
* if the inversion were dropped from the `false` branch alone.
|
||||||
|
*/
|
||||||
|
describe.each([
|
||||||
|
["session_recap_disabled", "Session recap"] as const,
|
||||||
|
["auto_scroll_disabled", "Auto-scroll"] as const,
|
||||||
|
])("a stored false on %s", (key, label) => {
|
||||||
|
it("reads as On at project scope, not as Off", () => {
|
||||||
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
|
||||||
|
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads as on at global scope, where the control is a switch", () => {
|
||||||
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,52 +8,91 @@ interface Props {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
disabledReason?: string;
|
disabledReason?: string;
|
||||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* `"project"` adds a third "Global" state to every switch, because a project
|
||||||
|
* has somewhere to inherit *from*. The global editor has no such fallback —
|
||||||
|
* unset there just means Claude Code's own default — so it stays a plain
|
||||||
|
* on/off and never renders the extra choice.
|
||||||
|
*/
|
||||||
|
scope?: "global" | "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||||
tui_mode: null,
|
tui_mode: null,
|
||||||
effort: null,
|
effort: null,
|
||||||
auto_scroll_disabled: false,
|
auto_scroll_disabled: null,
|
||||||
focus_mode: false,
|
focus_mode: null,
|
||||||
show_thinking_summaries: false,
|
show_thinking_summaries: null,
|
||||||
enable_session_recap: false,
|
session_recap_disabled: null,
|
||||||
env_scrub: false,
|
env_scrub: null,
|
||||||
prompt_caching_1h: false,
|
prompt_caching_1h: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Nothing is set at this level", which is saved as `null` rather than as a
|
||||||
|
* struct of nulls so that a project with no opinion is indistinguishable from
|
||||||
|
* one that never opened this editor.
|
||||||
|
*
|
||||||
|
* Note `false` is *not* a default any more: it is a deliberate off that
|
||||||
|
* overrides a global on, so a settings object holding one has to be persisted.
|
||||||
|
*/
|
||||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
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 (
|
return (
|
||||||
s.tui_mode === null &&
|
s.tui_mode == null &&
|
||||||
s.effort === null &&
|
s.effort == null &&
|
||||||
s.auto_scroll_disabled === false &&
|
s.auto_scroll_disabled == null &&
|
||||||
s.focus_mode === false &&
|
s.focus_mode == null &&
|
||||||
s.show_thinking_summaries === false &&
|
s.show_thinking_summaries == null &&
|
||||||
s.enable_session_recap === false &&
|
s.session_recap_disabled == null &&
|
||||||
s.env_scrub === false &&
|
s.env_scrub == null &&
|
||||||
s.prompt_caching_1h === false
|
s.prompt_caching_1h == null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two of Claude Code's settings are **on by default**, so the field behind them
|
||||||
|
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
|
||||||
|
* — that is what makes an untouched project mean "leave Claude Code alone"
|
||||||
|
* rather than "the user turned this off". `invert` is what lets those still
|
||||||
|
* read as an ordinary on/off switch here: the toggle shows the feature's state,
|
||||||
|
* the field stores the deviation from the default.
|
||||||
|
*/
|
||||||
const BOOLEAN_FIELDS: {
|
const BOOLEAN_FIELDS: {
|
||||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
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",
|
key: "show_thinking_summaries",
|
||||||
label: "Thinking summaries",
|
label: "Thinking summaries",
|
||||||
hint: "Shows Claude's thinking process as summaries.",
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enable_session_recap",
|
key: "session_recap_disabled",
|
||||||
label: "Session recap",
|
label: "Session recap",
|
||||||
hint: "Provides context when returning to a session.",
|
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
|
||||||
|
invert: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "auto_scroll_disabled",
|
key: "auto_scroll_disabled",
|
||||||
label: "Auto-scroll disabled",
|
label: "Auto-scroll",
|
||||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
hint: "Follows new output to the bottom in fullscreen rendering.",
|
||||||
|
invert: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "env_scrub",
|
key: "env_scrub",
|
||||||
@@ -72,6 +111,7 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
disabled,
|
disabled,
|
||||||
disabledReason,
|
disabledReason,
|
||||||
onSave,
|
onSave,
|
||||||
|
scope = "global",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||||
@@ -95,9 +135,16 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
|
||||||
|
the renderer for itself, which is not the same as pinning the classic
|
||||||
|
one — and the key is now always written (or explicitly deleted), so
|
||||||
|
"Automatic" has to be selectable rather than merely being what you get
|
||||||
|
when nothing is emitted.
|
||||||
|
*/}
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="TUI mode"
|
label="TUI mode"
|
||||||
hint="Enables flicker-free alt-screen rendering."
|
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
|
||||||
control={
|
control={
|
||||||
<select
|
<select
|
||||||
value={local.tui_mode ?? ""}
|
value={local.tui_mode ?? ""}
|
||||||
@@ -106,7 +153,8 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="">Default</option>
|
<option value="">Automatic</option>
|
||||||
|
<option value="default">Classic</option>
|
||||||
<option value="fullscreen">Fullscreen</option>
|
<option value="fullscreen">Fullscreen</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@@ -127,11 +175,24 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</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>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => {
|
||||||
|
const stored = local[key];
|
||||||
|
|
||||||
|
if (scope === "global") {
|
||||||
|
// No level above this one to inherit from, so "unset" and "off" are
|
||||||
|
// the same instruction here and a plain switch is the honest control.
|
||||||
|
// Unset therefore has to *display* as Claude Code's own default —
|
||||||
|
// which for the two inverted fields is on, not off.
|
||||||
|
const checked = invert ? stored !== true : stored === true;
|
||||||
|
return (
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
key={key}
|
key={key}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -139,13 +200,54 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
control={
|
control={
|
||||||
<Toggle
|
<Toggle
|
||||||
label={label}
|
label={label}
|
||||||
checked={local[key]}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
onChange={(v) => {
|
||||||
|
// Collapse back to null at the default rather than storing
|
||||||
|
// a redundant `false`, so an untouched global stays
|
||||||
|
// indistinguishable from one that was never opened.
|
||||||
|
const atDefault = invert ? v : !v;
|
||||||
|
apply({
|
||||||
|
[key]: atDefault ? null : invert ? !v : v,
|
||||||
|
} as Partial<ClaudeCodeSettings>);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwitchRow
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const choice = e.target.value;
|
||||||
|
const next =
|
||||||
|
choice === "global" ? null : invert ? choice === "off" : choice === "on";
|
||||||
|
apply({ [key]: next } as Partial<ClaudeCodeSettings>);
|
||||||
|
}}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="global">Global</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="on">On</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
Everything remove_project() destroys, named. It removes the container,
|
||||||
|
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
|
||||||
|
the triple-c-snapshot-{id} image and the project's keychain secrets — so
|
||||||
|
an accurate warning has to reach past "the config volume". The last
|
||||||
|
sentence is the reassuring half and matters just as much: project folders
|
||||||
|
are bind mounts from the host and nothing here touches them.
|
||||||
|
*/}
|
||||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
Are you sure you want to remove{" "}
|
Are you sure you want to remove{" "}
|
||||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||||
delete the container, config volume, and stored credentials.
|
its container, both of its volumes and its saved container image — so the home
|
||||||
|
directory, the Claude login and config, installed skills, session transcripts,
|
||||||
|
scheduled tasks and any stored credentials all go with it.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Your project folders on this machine are mounted in, not copied, and are left
|
||||||
|
untouched.
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ export default function EnvVarsEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
|
||||||
|
carries `w-full`, and a width utility on the input itself does not beat
|
||||||
|
it — class-attribute order is not what resolves the conflict, stylesheet
|
||||||
|
order is. Sizing the key input directly left it asking for the whole row
|
||||||
|
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
|
||||||
|
leftover space, to an unusable sliver. */}
|
||||||
{vars.map((ev, i) => (
|
{vars.map((ev, i) => (
|
||||||
<div key={i} className="flex gap-2 items-center">
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<div className="w-2/5 shrink-0">
|
||||||
<input
|
<input
|
||||||
value={ev.key}
|
value={ev.key}
|
||||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
@@ -52,8 +59,10 @@ export default function EnvVarsEditor({
|
|||||||
placeholder="KEY"
|
placeholder="KEY"
|
||||||
aria-label={`Environment variable ${i + 1} name`}
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`w-2/5 ${monoInputClass}`}
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
value={ev.value}
|
value={ev.value}
|
||||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
@@ -61,8 +70,9 @@ export default function EnvVarsEditor({
|
|||||||
placeholder="value"
|
placeholder="value"
|
||||||
aria-label={`Environment variable ${i + 1} value`}
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex-1 ${monoInputClass}`}
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only allows opening a terminal while the container runs", () => {
|
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" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
|||||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
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", () => {
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
|||||||
import { useAppState, homeTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { useUnavailable } from "../ui/unavailable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning}
|
{...terminal.controlProps}
|
||||||
onClick={() => openClaudeTerminal()}
|
title={
|
||||||
title={`Open a Claude terminal for ${project.name}`}
|
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}`}
|
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
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
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" />
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{terminal.reasonNode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import AutomationTab from "./AutomationTab";
|
||||||
|
import type { Project, ScheduledTask } from "../../../lib/types";
|
||||||
|
|
||||||
|
const listScheduledTasks = vi.fn(async () => tasks);
|
||||||
|
const getSchedulerNotifications = vi.fn(async () => []);
|
||||||
|
const runScheduledTaskNow = vi.fn(async () => "started");
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
|
listScheduledTasks: () => listScheduledTasks(),
|
||||||
|
getSchedulerNotifications: () => getSchedulerNotifications(),
|
||||||
|
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
|
||||||
|
clearSchedulerNotifications: vi.fn(async () => {}),
|
||||||
|
getScheduledTaskLog: vi.fn(async () => ""),
|
||||||
|
removeScheduledTask: vi.fn(async () => {}),
|
||||||
|
setScheduledTaskEnabled: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../store/appState", () => ({
|
||||||
|
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||||
|
|
||||||
|
const baseTask: ScheduledTask = {
|
||||||
|
id: "a1b2c3d4",
|
||||||
|
name: "nightly",
|
||||||
|
prompt: "Run the suite",
|
||||||
|
schedule: "0 3 * * *",
|
||||||
|
task_type: "recurring",
|
||||||
|
at: null,
|
||||||
|
enabled: true,
|
||||||
|
working_dir: "/workspace",
|
||||||
|
created_at: null,
|
||||||
|
last_run: null,
|
||||||
|
next_run: null,
|
||||||
|
running: false,
|
||||||
|
running_since: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasks: ScheduledTask[] = [];
|
||||||
|
|
||||||
|
async function renderTab() {
|
||||||
|
render(<AutomationTab project={project} />);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
tasks = [baseTask];
|
||||||
|
listScheduledTasks.mockClear();
|
||||||
|
runScheduledTaskNow.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AutomationTab run state", () => {
|
||||||
|
it("offers Run now for an idle task and says nothing about running", async () => {
|
||||||
|
await renderTab();
|
||||||
|
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
|
||||||
|
expect(screen.queryByText(/Running/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
|
||||||
|
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
|
||||||
|
await renderTab();
|
||||||
|
|
||||||
|
// The whole point: a detached run is visible rather than silent.
|
||||||
|
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
|
||||||
|
await renderTab();
|
||||||
|
const callsAfterLoad = listScheduledTasks.mock.calls.length;
|
||||||
|
|
||||||
|
// The runner needs a moment to write its state file; until then the task
|
||||||
|
// still reads as idle, which is exactly the window that used to look dead.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
|
||||||
|
|
||||||
|
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
|
||||||
|
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops polling once nothing is running", async () => {
|
||||||
|
await renderTab();
|
||||||
|
// No trigger, nothing running: the interval must not be armed at all.
|
||||||
|
const before = listScheduledTasks.mock.calls.length;
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(listScheduledTasks.mock.calls.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||