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 |
@@ -1,23 +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.
|
||||||
#
|
#
|
||||||
# The uploads pin actions/upload-artifact@v3 and must not be "upgraded". v4
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||||
# bundles @actions/artifact v2, which refuses to run before making a single
|
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||||
# request whenever GITHUB_SERVER_URL is not github.com:
|
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||||
|
# unreachable set of bundles; it is now releases-only.
|
||||||
#
|
#
|
||||||
# isGhes() -> hostname !== 'GITHUB.COM' && !endsWith('.GHE.COM') && !endsWith('.LOCALHOST')
|
# 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).
|
||||||
#
|
#
|
||||||
# act_runner sets GITHUB_SERVER_URL to this Gitea instance, so v4 fails on every
|
# ## Why not workflow artifacts
|
||||||
# runner and every OS with "GHESNotSupportedError" — after the whole Tauri build
|
#
|
||||||
# has been paid for. v3 uses the v1 artifact API, which Gitea implements. (Run
|
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||||
# #265 was this workflow's first ever run and lost all three platforms this way.)
|
#
|
||||||
# The other workflows here never hit it because they publish by curling the
|
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||||
# Gitea releases API instead — see build-app.yml.
|
# `@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:
|
||||||
@@ -25,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: |
|
||||||
@@ -118,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
|
||||||
@@ -129,30 +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/
|
||||||
|
|
||||||
# v3, not v4, and it must stay v3 — see the note at the top of this file.
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
- name: Upload Linux artifacts
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
uses: actions/upload-artifact@v3
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
with:
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
- name: Upload Linux bundles to the preview release
|
||||||
path: artifacts/
|
shell: bash
|
||||||
if-no-files-found: error
|
env:
|
||||||
retention-days: 14
|
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: |
|
||||||
@@ -201,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
|
||||||
@@ -212,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
|
||||||
@@ -223,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@v3 # v3 deliberately — see the top of this file
|
# 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
|
||||||
@@ -310,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
|
||||||
@@ -322,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@v3 # v3 deliberately — see the top of this file
|
# 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
|
||||||
|
|||||||
@@ -79,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`,
|
||||||
@@ -126,6 +181,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
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*
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||||
the browser.
|
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
|
- **`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
|
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||||
container stopped, process exited.
|
container stopped, process exited.
|
||||||
@@ -186,7 +258,8 @@ 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, plus the shared
|
- **`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)
|
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
|
- **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
|
`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
|
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||||
@@ -256,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.
|
||||||
@@ -368,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:**
|
||||||
|
|
||||||
@@ -225,7 +288,7 @@ buttons. Below that are six tabs:
|
|||||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
| **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) |
|
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
@@ -280,11 +343,35 @@ Press **Start browser view** and the pane fills with Playwright's own dashboard,
|
|||||||
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||||
exposed off the machine.
|
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
|
#### 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
|
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
|
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||||
terminal. This is a window change only: the browser and the view keep running throughout, so
|
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.
|
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 —
|
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||||
@@ -324,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 |
|
||||||
@@ -447,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:
|
||||||
@@ -501,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
|
||||||
|
|
||||||
@@ -678,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
|
||||||
@@ -1034,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
|
||||||
|
|
||||||
@@ -1087,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
|
||||||
|
|
||||||
@@ -1115,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`
|
||||||
@@ -1172,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
|
||||||
@@ -1191,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 |
|
||||||
@@ -1280,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
|
||||||
@@ -1318,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,7 +633,7 @@ 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)
|
||||||
|
|
||||||
@@ -406,4 +657,10 @@ The libraries are the opposite — a runtime `apt-get install` lands in the cont
|
|||||||
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
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.
|
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
|
||||||
|
|||||||
@@ -412,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
|
||||||
@@ -436,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
|
||||||
@@ -464,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,
|
||||||
@@ -504,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||||
use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus};
|
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.
|
||||||
@@ -164,6 +164,153 @@ pub async fn set_browser_view_popout_always_on_top(
|
|||||||
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
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.
|
/// 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
|
/// Every command here needs a *running* container, and every one of them used
|
||||||
|
|||||||
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
|
|||||||
/// user's own scripts and not for the MCP plugin.
|
/// user's own scripts and not for the MCP plugin.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub chrome_channel: Option<String>,
|
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>,
|
||||||
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
|
|||||||
None
|
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 —
|
/// Whether Playwright is present but has no browser at all to drive —
|
||||||
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||||
/// still runs, it just has nothing to show until a browser is bound.
|
/// still runs, it just has nothing to show until a browser is bound.
|
||||||
pub fn needs_browser(&self) -> bool {
|
pub fn needs_browser(&self) -> bool {
|
||||||
self.playwright_version.is_some()
|
self.playwright_version.is_some()
|
||||||
&& self.browsers.is_empty()
|
|
||||||
&& self.chrome_channel.is_none()
|
&& 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
|
/// The searched roots as prose, so a message never trails off into "Looked
|
||||||
@@ -277,6 +372,30 @@ const PROBE: &str = concat!(
|
|||||||
// the pane claim a browser is present when none is.
|
// 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#"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){}"#,
|
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
|
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||||
// is looked for where apt puts 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#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
||||||
@@ -467,6 +586,66 @@ mod tests {
|
|||||||
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", 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]
|
||||||
fn a_missing_viewer_package_is_reported_separately() {
|
fn a_missing_viewer_package_is_reported_separately() {
|
||||||
let d = parse_probe_output(&payload(
|
let d = parse_probe_output(&payload(
|
||||||
|
|||||||
@@ -73,14 +73,33 @@ use crate::docker::exec::{
|
|||||||
|
|
||||||
use super::detect::{self, PlaywrightDetection};
|
use super::detect::{self, PlaywrightDetection};
|
||||||
|
|
||||||
/// The two packages the pane genuinely needs, pinned to `@latest` because
|
/// The viewer package — installed **first**, and it decides the version of
|
||||||
/// `browser.bind()` is recent and the viewer tracks it.
|
/// `playwright` installed after it.
|
||||||
///
|
///
|
||||||
/// This is the *minimum* set. A user who followed the old guidance ended up
|
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
||||||
/// with a global install as well as these; only these are required. Note what
|
/// configuration to make, and it contributes nothing to serving a viewer.
|
||||||
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not
|
///
|
||||||
/// this pane's, and it contributes nothing to serving a viewer.
|
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
|
||||||
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@latest"];
|
///
|
||||||
|
/// Installing both at `@latest` produces a tree that looks right and is broken.
|
||||||
|
/// Verified on a real container: `@playwright/cli@0.1.18` pins
|
||||||
|
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
|
||||||
|
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
|
||||||
|
/// beside it. The two cores want *different browser revisions*. The browser
|
||||||
|
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
|
||||||
|
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
|
||||||
|
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
|
||||||
|
/// meanwhile reports a browser installed, because one is.
|
||||||
|
///
|
||||||
|
/// So the viewer package goes first and its own pinned `playwright` version is
|
||||||
|
/// what gets installed second — one core, one browser revision, both halves
|
||||||
|
/// agreeing. See [`pinned_playwright_spec`].
|
||||||
|
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";
|
||||||
|
|
||||||
|
/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
|
||||||
|
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
|
||||||
|
/// skew either way.
|
||||||
|
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";
|
||||||
|
|
||||||
/// Where the packages are installed. Container storage, not a bind mount — see
|
/// Where the packages are installed. Container storage, not a bind mount — see
|
||||||
/// the module docs.
|
/// the module docs.
|
||||||
@@ -175,11 +194,24 @@ impl BrowserTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `channel` a launch check must pass. `None` means the bundled build.
|
/// Every `channel` a launch check must pass, comma-separated, where
|
||||||
fn channel(self) -> Option<&'static str> {
|
/// `default` means "no channel — the bundled build".
|
||||||
|
///
|
||||||
|
/// Chromium is checked twice because the two consumers of this install do
|
||||||
|
/// not launch the same binary. A script calling `chromium.launch()` with
|
||||||
|
/// no channel gets `chromium-headless-shell`; the viewer reads
|
||||||
|
/// `~/.playwright/cli.config.json`, which pins channel
|
||||||
|
/// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
|
||||||
|
/// build — a separate download under the same `install chromium`.
|
||||||
|
///
|
||||||
|
/// Checking only the first is how a container reaches "verified" and then
|
||||||
|
/// fails in the pane with `Browser "chrome-for-testing" is not installed`.
|
||||||
|
/// Observed on a real project, where a stale `chromium-1217` satisfied the
|
||||||
|
/// headless-shell launch while the viewer wanted `chromium-1237`.
|
||||||
|
fn channels(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Chromium => None,
|
Self::Chromium => "default,chrome-for-testing",
|
||||||
Self::Chrome => Some("chrome"),
|
Self::Chrome => "chrome",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,49 +245,39 @@ pub async fn install_packages(
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
app,
|
app,
|
||||||
project_id,
|
project_id,
|
||||||
&format!(
|
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||||
"Installing playwright and @playwright/cli into {}/node_modules…",
|
|
||||||
INSTALL_DIR
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||||
// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
|
||||||
// involved. The guard matters because these are `@latest`: current
|
|
||||||
// Playwright has no postinstall (verified — `playwright@1.62.1` declares no
|
|
||||||
// `scripts` at all), but if a future release brings the browser download
|
|
||||||
// back, this step must stay small and the download must stay the step the
|
|
||||||
// user explicitly asked for.
|
|
||||||
let mut cmd = vec![
|
|
||||||
"env".to_string(),
|
|
||||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
|
||||||
"npm".to_string(),
|
|
||||||
"install".to_string(),
|
|
||||||
// Leaves any package.json and lockfile at /workspace untouched.
|
|
||||||
"--no-save".to_string(),
|
|
||||||
"--no-fund".to_string(),
|
|
||||||
"--no-audit".to_string(),
|
|
||||||
];
|
|
||||||
cmd.extend(PACKAGES.iter().map(|p| p.to_string()));
|
|
||||||
|
|
||||||
let step = run_step(
|
|
||||||
app,
|
|
||||||
project_id,
|
|
||||||
container_id,
|
|
||||||
"claude",
|
|
||||||
INSTALL_DIR,
|
|
||||||
cmd,
|
|
||||||
NPM_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if step.exit_code != 0 {
|
if step.exit_code != 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"npm couldn't install Playwright in this container (exit {}).\n\nnpm said:\n{}",
|
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||||
step.exit_code,
|
step.exit_code,
|
||||||
step.log_or("it produced no output at all")
|
step.log_or("it produced no output at all")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second, `playwright` at the version the viewer package pins — see
|
||||||
|
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
|
||||||
|
//
|
||||||
|
// The viewer package is named *again* here. It is already installed, so
|
||||||
|
// this adds no work, but omitting it is what made npm prune it back out —
|
||||||
|
// see the note on `npm_install`. The pin can only be read after the first
|
||||||
|
// install has written the manifest, which is why this stays two commands
|
||||||
|
// rather than one.
|
||||||
|
let spec = pinned_playwright_spec(container_id).await;
|
||||||
|
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||||
|
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
|
||||||
|
if second.exit_code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||||
|
spec,
|
||||||
|
second.exit_code,
|
||||||
|
second.log_or("it produced no output at all")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
step.log = merge_logs(step.log, second.log);
|
||||||
|
|
||||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||||
let detection = detect::detect(container_id).await?;
|
let detection = detect::detect(container_id).await?;
|
||||||
|
|
||||||
@@ -265,7 +287,12 @@ pub async fn install_packages(
|
|||||||
// saying so here is what stops someone walking away from a pane that will
|
// saying so here is what stops someone walking away from a pane that will
|
||||||
// never show them anything.
|
// never show them anything.
|
||||||
let mut warning = detection.blocker();
|
let mut warning = detection.blocker();
|
||||||
if detection.needs_browser() {
|
// Skew outranks "no browser": a container in that state *has* browsers, and
|
||||||
|
// telling someone to install one they can see already installed is how a
|
||||||
|
// real user ends up doing it three times.
|
||||||
|
if let Some(skew) = detection.skew_message() {
|
||||||
|
warning = merge(warning, skew);
|
||||||
|
} else if detection.needs_browser() {
|
||||||
warning = merge(
|
warning = merge(
|
||||||
warning,
|
warning,
|
||||||
"Playwright is installed, but this container has no browser to drive yet. Install \
|
"Playwright is installed, but this container has no browser to drive yet. Install \
|
||||||
@@ -282,6 +309,98 @@ pub async fn install_packages(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
|
||||||
|
///
|
||||||
|
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
||||||
|
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
||||||
|
/// involved. The guard matters because these are `@latest`: current Playwright
|
||||||
|
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
|
||||||
|
/// all), but if a future release brings the browser download back, this step
|
||||||
|
/// must stay small and the download must stay the step the user asked for.
|
||||||
|
///
|
||||||
|
/// **Every package that must survive has to appear in `specs`.** `--no-save`
|
||||||
|
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
|
||||||
|
/// npm with the command line as its only statement of what the tree should
|
||||||
|
/// contain, and npm ≥7 reconciles the tree against that on every run by
|
||||||
|
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
|
||||||
|
/// and then installing `playwright` in a second command therefore *deletes the
|
||||||
|
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
|
||||||
|
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
|
||||||
|
/// empty directory is why a fresh setup could report success and still leave
|
||||||
|
/// the pane saying `@playwright/cli` was not installed.
|
||||||
|
async fn npm_install(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
container_id: &str,
|
||||||
|
specs: &[&str],
|
||||||
|
) -> Result<StepResult, String> {
|
||||||
|
let mut cmd = vec![
|
||||||
|
"env".to_string(),
|
||||||
|
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||||
|
"npm".to_string(),
|
||||||
|
"install".to_string(),
|
||||||
|
// Leaves any package.json and lockfile at /workspace untouched.
|
||||||
|
"--no-save".to_string(),
|
||||||
|
"--no-fund".to_string(),
|
||||||
|
"--no-audit".to_string(),
|
||||||
|
];
|
||||||
|
cmd.extend(specs.iter().map(|s| s.to_string()));
|
||||||
|
run_step(
|
||||||
|
app,
|
||||||
|
project_id,
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
INSTALL_DIR,
|
||||||
|
cmd,
|
||||||
|
NPM_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `playwright` spec to install: the exact version `@playwright/cli`
|
||||||
|
/// depends on, so both halves share one `playwright-core`.
|
||||||
|
///
|
||||||
|
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||||
|
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||||
|
/// install something, not nothing.
|
||||||
|
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||||
|
let script = format!(
|
||||||
|
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||||
|
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||||
|
INSTALL_DIR
|
||||||
|
);
|
||||||
|
let (out, _code) = exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let version: &str = out.trim();
|
||||||
|
// A version, not a range or a URL: anything else goes to the fallback
|
||||||
|
// rather than into an npm command line.
|
||||||
|
if !version.is_empty()
|
||||||
|
&& version
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
||||||
|
{
|
||||||
|
format!("playwright@{}", version)
|
||||||
|
} else {
|
||||||
|
PLAYWRIGHT_FALLBACK.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep both npm runs' output, so a failure in either is diagnosable.
|
||||||
|
fn merge_logs(first: String, second: String) -> String {
|
||||||
|
match (first.trim().is_empty(), second.trim().is_empty()) {
|
||||||
|
(true, _) => second,
|
||||||
|
(_, true) => first,
|
||||||
|
_ => format!("{}\n{}", first.trim_end(), second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a browser: its system libraries first, then the browser, then prove
|
/// Install a browser: its system libraries first, then the browser, then prove
|
||||||
/// one actually starts.
|
/// one actually starts.
|
||||||
///
|
///
|
||||||
@@ -615,7 +734,7 @@ async fn verify_launch(
|
|||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
format!("TRIPLE_C_PW_DIR={}", dir),
|
format!("TRIPLE_C_PW_DIR={}", dir),
|
||||||
format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")),
|
format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
|
||||||
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
|
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -706,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
|
|||||||
/// The launch check. One `argv` element, no newlines, same contract as the
|
/// The launch check. One `argv` element, no newlines, same contract as the
|
||||||
/// detection probe.
|
/// detection probe.
|
||||||
///
|
///
|
||||||
/// Playwright leaves the Chromium sandbox disabled by default, which is what
|
/// `chromiumSandbox` is set explicitly rather than left to Playwright's
|
||||||
/// makes this work in a container at all. The timeout exists so a browser that
|
/// default, so this check states the same thing the seeded
|
||||||
/// hangs on a missing library still returns a verdict rather than sitting there
|
/// `cli.config.json` does instead of agreeing with it by coincidence. The
|
||||||
/// until the exec is torn down. The navigation is best-effort and never decides
|
/// containers forbid unprivileged user namespaces, so a sandboxed Chromium
|
||||||
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken
|
/// aborts on launch; nothing here should be able to drift back into testing a
|
||||||
/// install.
|
/// configuration the viewer will not use.
|
||||||
|
///
|
||||||
|
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
|
||||||
|
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
|
||||||
|
/// names the channel that failed, because "is not installed" is meaningless
|
||||||
|
/// without it. Only the last launch loads a page: the navigation is
|
||||||
|
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
|
||||||
|
/// network apart from a broken install, so doing it once is enough.
|
||||||
|
///
|
||||||
|
/// The timeout exists so a browser that hangs on a missing library still
|
||||||
|
/// returns a verdict rather than sitting there until the exec is torn down.
|
||||||
const LAUNCH_PROBE: &str = concat!(
|
const LAUNCH_PROBE: &str = concat!(
|
||||||
r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#,
|
r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
|
||||||
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
|
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
|
||||||
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
|
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
|
||||||
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
|
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
|
||||||
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
|
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
|
||||||
r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#,
|
r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
|
||||||
r#"let v="";try{v=b.version();}catch(e){}"#,
|
r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
|
||||||
r#"let nav={ok:true,cert:false,detail:""};"#,
|
r#"let v="",nav={ok:true,cert:false,detail:""};"#,
|
||||||
|
r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
|
||||||
|
r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
|
||||||
|
r#"try{v=b.version();}catch(e){}"#,
|
||||||
|
r#"if(i===list.length-1){"#,
|
||||||
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
|
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
|
||||||
// A certificate failure is classified here, next to the message, because
|
// A certificate failure is classified here, next to the message, because
|
||||||
// Chromium's wording is the only place the distinction exists.
|
// Chromium's wording is the only place the distinction exists.
|
||||||
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#,
|
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
|
||||||
r#"await b.close();clearTimeout(t);say(true,v,nav);}"#,
|
r#"await b.close();b=null;}"#,
|
||||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#,
|
r#"clearTimeout(t);say(true,v,nav);}"#,
|
||||||
|
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
|
||||||
|
r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
|
||||||
r#"process.exit(0);})();"#,
|
r#"process.exit(0);})();"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -865,10 +1000,20 @@ mod tests {
|
|||||||
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
||||||
// The viewer package is not optional, and `@playwright/mcp` is not a
|
// The viewer package is not optional, and `@playwright/mcp` is not a
|
||||||
// member: it can bind sessions, it can never serve the UI.
|
// member: it can bind sessions, it can never serve the UI.
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@")));
|
assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@")));
|
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
||||||
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp")));
|
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
||||||
assert_eq!(PACKAGES.len(), 2);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
|
||||||
|
// `@latest` for both is exactly what splits the tree into two
|
||||||
|
// `playwright-core`s wanting different browser revisions — the viewer
|
||||||
|
// green, every `require("playwright")` dead. The version comes from the
|
||||||
|
// viewer's own manifest instead; `@latest` is only the fallback for an
|
||||||
|
// unreadable one.
|
||||||
|
assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
|
||||||
|
assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -885,8 +1030,10 @@ mod tests {
|
|||||||
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
||||||
// must be able to say so.
|
// must be able to say so.
|
||||||
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
||||||
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome"));
|
assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
|
||||||
assert_eq!(BrowserTarget::Chromium.channel(), None);
|
// Both of Chromium's consumers, or the check passes for a browser the
|
||||||
|
// viewer cannot open — see `channels`.
|
||||||
|
assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
|
||||||
// And a size, before the click, for both.
|
// And a size, before the click, for both.
|
||||||
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
||||||
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
|
pub mod page;
|
||||||
pub mod popout;
|
pub mod popout;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|
||||||
@@ -303,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
|
||||||
@@ -600,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(),
|
||||||
],
|
],
|
||||||
@@ -615,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
//! 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.
|
//! 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 serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
@@ -106,11 +110,17 @@ pub fn open(
|
|||||||
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
.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
|
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||||
// to take the view back into the tab.
|
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||||
window.on_window_event(move |event| {
|
// see `set_match_window`.
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
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);
|
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);
|
log::info!("Browser view: popped out for project {}", project_id);
|
||||||
@@ -170,6 +180,99 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res
|
|||||||
Ok(())
|
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) {
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
POPOUT_EVENT,
|
POPOUT_EVENT,
|
||||||
@@ -198,4 +301,38 @@ mod tests {
|
|||||||
fn distinct_projects_get_distinct_windows() {
|
fn distinct_projects_get_distinct_windows() {
|
||||||
assert_ne!(window_label("alpha"), window_label("beta"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1188,16 +1188,53 @@ pub async fn has_claude_token() -> Result<bool, String> {
|
|||||||
Ok(secure::has_claude_oauth_token())
|
Ok(secure::has_claude_oauth_token())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is always
|
/// The tail of every refusal [`crate::project_lock::try_acquire`] produces.
|
||||||
/// gone by the time this is returned — the rest is about copies of the token
|
///
|
||||||
/// that live outside it.
|
/// [`crate::docker::container::scrub_secrets_from_snapshots`] folds two very
|
||||||
#[derive(Debug, Default, serde::Serialize)]
|
/// different things into one `failed` list: an image that genuinely could not
|
||||||
|
/// be rewritten, and one that was never *attempted* because another operation
|
||||||
|
/// held the project. Only the second is retryable, and only the second should
|
||||||
|
/// be described to the user as "come back in a minute" rather than "reset this
|
||||||
|
/// project". Splitting them needs a discriminator, and the refusal string is
|
||||||
|
/// the only one that crosses the module boundary — `try_acquire` returns
|
||||||
|
/// `Result<ProjectGuard, String>`, and `container.rs` pushes that `String`
|
||||||
|
/// through unchanged.
|
||||||
|
///
|
||||||
|
/// Matching on prose is normally a mistake, so this is pinned by
|
||||||
|
/// [`tests::a_real_lock_refusal_is_recognised_as_retryable`], which builds a
|
||||||
|
/// refusal by actually taking a guard rather than by copying the wording. If
|
||||||
|
/// `project_lock` ever rephrases, that test fails instead of this silently
|
||||||
|
/// misclassifying a credential that was left in place.
|
||||||
|
const PROJECT_BUSY_MARKER: &str = "Wait for it to finish before ";
|
||||||
|
|
||||||
|
/// Whether a scrub failure means "somebody else has this project right now",
|
||||||
|
/// which is transient, rather than "this image cannot be rewritten", which is
|
||||||
|
/// not. Nothing bollard returns contains [`PROJECT_BUSY_MARKER`].
|
||||||
|
fn is_project_busy_refusal(reason: &str) -> bool {
|
||||||
|
reason.contains(PROJECT_BUSY_MARKER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a cleanup managed to reach. Every field is about copies of the token
|
||||||
|
/// that live *outside* the keychain — snapshot images — so the same shape
|
||||||
|
/// serves [`clear_claude_token`], where the keychain entry is already gone by
|
||||||
|
/// the time this is returned, and [`sweep_claude_token_snapshots`], where the
|
||||||
|
/// keychain was never touched.
|
||||||
|
///
|
||||||
|
/// Three lists rather than one, because "we rewrote it", "we could not rewrite
|
||||||
|
/// it" and "we did not try" are three different things to tell somebody who
|
||||||
|
/// just revoked a credential, and only the last one is fixed by waiting.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||||
pub struct ClearTokenOutcome {
|
pub struct ClearTokenOutcome {
|
||||||
/// Snapshot images that were holding the token and have been rewritten.
|
/// Snapshot images that were holding the token and have been rewritten.
|
||||||
pub snapshots_scrubbed: Vec<String>,
|
pub snapshots_scrubbed: Vec<String>,
|
||||||
/// Images still holding it, with the reason each could not be rewritten.
|
/// Images still holding it, with the reason each could not be rewritten.
|
||||||
/// Non-empty means the revocation is **incomplete** and the UI must say so.
|
/// Non-empty means the revocation is **incomplete** and the UI must say so.
|
||||||
pub snapshots_failed: Vec<String>,
|
pub snapshots_failed: Vec<String>,
|
||||||
|
/// Images still holding it that were **not attempted**, because another
|
||||||
|
/// operation held the project (a start, a compaction, a migration). Also an
|
||||||
|
/// incomplete revocation — but a retryable one, and the UI must not offer
|
||||||
|
/// "Reset the project" as the remedy for it.
|
||||||
|
pub snapshots_skipped: Vec<String>,
|
||||||
/// Rewritten, but the pre-rewrite image object could not be deleted because
|
/// Rewritten, but the pre-rewrite image object could not be deleted because
|
||||||
/// a container is still running off it. Worth mentioning, not worth
|
/// a container is still running off it. Worth mentioning, not worth
|
||||||
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
||||||
@@ -1207,7 +1244,130 @@ pub struct ClearTokenOutcome {
|
|||||||
pub docker_unavailable: Option<String>,
|
pub docker_unavailable: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the shared Claude token.
|
impl ClearTokenOutcome {
|
||||||
|
/// Whether a copy of the credential is known — or suspected — to still be
|
||||||
|
/// reachable, so the caller should offer to run the sweep again.
|
||||||
|
///
|
||||||
|
/// `snapshots_superseded` is deliberately not counted: that image is
|
||||||
|
/// untagged, nothing new is built from it, and it goes away on the next
|
||||||
|
/// restart. Re-running would report it forever and train the user to
|
||||||
|
/// ignore the warning.
|
||||||
|
pub fn needs_another_pass(&self) -> bool {
|
||||||
|
!self.snapshots_failed.is_empty()
|
||||||
|
|| !self.snapshots_skipped.is_empty()
|
||||||
|
|| self.docker_unavailable.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a scrub report into the IPC shape, splitting the busy projects out of
|
||||||
|
/// the failures. Separate from the command so it can be tested without Docker.
|
||||||
|
fn summarise_scrub(report: crate::docker::container::SnapshotScrubReport) -> ClearTokenOutcome {
|
||||||
|
let mut outcome = ClearTokenOutcome {
|
||||||
|
snapshots_scrubbed: report.scrubbed,
|
||||||
|
snapshots_superseded: report.superseded_retained,
|
||||||
|
docker_unavailable: report.unavailable,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for (image, reason) in report.failed {
|
||||||
|
let line = format!("{}: {}", image, reason);
|
||||||
|
if is_project_busy_refusal(&reason) {
|
||||||
|
outcome.snapshots_skipped.push(line);
|
||||||
|
} else {
|
||||||
|
outcome.snapshots_failed.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which halves of a cleanup to run.
|
||||||
|
///
|
||||||
|
/// The distinction has to exist **on the wire**, not in a toast string. The UI
|
||||||
|
/// offers a "Retry snapshot cleanup" button after an incomplete revocation, and
|
||||||
|
/// while [`clear_claude_token`] was the only command behind it that button was
|
||||||
|
/// a *second revoke* wearing a retry's label: it deleted the keychain entry
|
||||||
|
/// unconditionally, with no confirmation, in a panel that survived the user
|
||||||
|
/// re-authenticating from the button directly above it. Pressing it then threw
|
||||||
|
/// away the token they had just acquired and said only that some images had
|
||||||
|
/// been checked.
|
||||||
|
///
|
||||||
|
/// [`Cleanup::ImagesOnly`] is the honest primitive the retry actually wanted:
|
||||||
|
/// the images are the durable record of what is left to do, so re-deriving the
|
||||||
|
/// work from Docker needs no keychain entry and must not consume one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Cleanup {
|
||||||
|
/// Delete the keychain entry, then rewrite the images. What "Revoke" does,
|
||||||
|
/// behind its confirmation modal.
|
||||||
|
KeychainThenImages,
|
||||||
|
/// Rewrite the images and leave the keychain entirely alone. What "Retry
|
||||||
|
/// snapshot cleanup" and "Check snapshot images" do.
|
||||||
|
ImagesOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of both cleanup commands, with its two halves injected so the
|
||||||
|
/// *order* — and the fact that [`Cleanup::ImagesOnly`] never reaches the
|
||||||
|
/// keychain at all — can be tested without a keychain or a Docker daemon.
|
||||||
|
async fn run_cleanup<K, S, F>(
|
||||||
|
what: Cleanup,
|
||||||
|
delete_keychain: K,
|
||||||
|
sweep: S,
|
||||||
|
) -> Result<ClearTokenOutcome, String>
|
||||||
|
where
|
||||||
|
K: FnOnce() -> Result<(), String>,
|
||||||
|
S: FnOnce() -> F,
|
||||||
|
F: std::future::Future<Output = crate::docker::container::SnapshotScrubReport>,
|
||||||
|
{
|
||||||
|
let revoking = what == Cleanup::KeychainThenImages;
|
||||||
|
|
||||||
|
if revoking {
|
||||||
|
// First, and before anything slow — see "Why the keychain goes first".
|
||||||
|
// Nothing has been touched if this fails, so the error is the whole
|
||||||
|
// answer: the caller still has its Revoke button, and the standalone
|
||||||
|
// sweep is available for the images regardless.
|
||||||
|
if let Err(e) = delete_keychain() {
|
||||||
|
log::error!(
|
||||||
|
"Could not delete the shared Claude token from the keychain; no snapshot image \
|
||||||
|
was touched: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
log::info!("Cleared the shared Claude authentication token from the keychain");
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = sweep().await;
|
||||||
|
let swept_clean = !report.left_something_behind();
|
||||||
|
let outcome = summarise_scrub(report);
|
||||||
|
|
||||||
|
let lead = if revoking {
|
||||||
|
"Revoked the shared Claude token but"
|
||||||
|
} else {
|
||||||
|
"Swept the snapshot images but"
|
||||||
|
};
|
||||||
|
for image in &outcome.snapshots_failed {
|
||||||
|
log::warn!("{} could not clear it from {}", lead, image);
|
||||||
|
}
|
||||||
|
for image in &outcome.snapshots_skipped {
|
||||||
|
log::warn!(
|
||||||
|
"{} left it in {} — the project was busy; running the snapshot sweep again will retry",
|
||||||
|
lead,
|
||||||
|
image
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref reason) = outcome.docker_unavailable {
|
||||||
|
log::warn!("{} checked no snapshot image at all: {}", lead, reason);
|
||||||
|
}
|
||||||
|
if swept_clean && !outcome.needs_another_pass() {
|
||||||
|
log::info!(
|
||||||
|
"No snapshot image is still holding the shared Claude token ({} rewritten)",
|
||||||
|
outcome.snapshots_scrubbed.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the shared Claude token, and remove the copies of it that outlive the
|
||||||
|
/// keychain entry.
|
||||||
///
|
///
|
||||||
/// Deleting the keychain entry is the easy half. The token also exists in two
|
/// Deleting the keychain entry is the easy half. The token also exists in two
|
||||||
/// other places, and a "Revoke" button that leaves either of them behind is
|
/// other places, and a "Revoke" button that leaves either of them behind is
|
||||||
@@ -1223,34 +1383,81 @@ pub struct ClearTokenOutcome {
|
|||||||
/// as long as the image exists. New commits no longer bake it in (see
|
/// as long as the image exists. New commits no longer bake it in (see
|
||||||
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
||||||
/// committed by earlier builds have to be rewritten, which is what
|
/// committed by earlier builds have to be rewritten, which is what
|
||||||
/// [`scrub_secrets_from_snapshots`] does here.
|
/// [`crate::docker::container::scrub_secrets_from_snapshots`] does here.
|
||||||
///
|
///
|
||||||
/// The keychain deletion is never rolled back if the scrub fails; a partially
|
/// ## Why the keychain goes first
|
||||||
/// completed revocation is still better than none, and the outcome is reported
|
///
|
||||||
/// so the UI can be explicit about what is left.
|
/// The sweep is not quick. It lists every `triple-c-snapshot-*` image and then
|
||||||
|
/// inspects, creates, commits and removes *per image*, over bollard's Docker
|
||||||
|
/// socket with its 120-second-per-request default. Deferring the keychain
|
||||||
|
/// delete behind all of that leaves the credential live for the whole window
|
||||||
|
/// while the UI says "Revoking…", and two separate things go wrong in it:
|
||||||
|
///
|
||||||
|
/// * A quit, a crash or a kill mid-sweep and the entry was never deleted at
|
||||||
|
/// all. The token the user believes they revoked is still in the keychain,
|
||||||
|
/// still ~1-year valid, and still injected into every container start.
|
||||||
|
/// * [`has_claude_token`] stays true throughout, and
|
||||||
|
/// [`crate::docker::container::create_container`] reads the keychain at
|
||||||
|
/// container-**create** time rather than at app start. The per-project
|
||||||
|
/// [`crate::project_lock::ProjectOp::SecretScrub`] guard is released as soon
|
||||||
|
/// as that one project's image has been rewritten — so a project scrubbed
|
||||||
|
/// early in the sweep can be started again later in the *same* sweep and be
|
||||||
|
/// handed a fresh copy of the credential in its env. The images end up clean
|
||||||
|
/// and the running fleet does not.
|
||||||
|
///
|
||||||
|
/// An earlier version ran the sweep first, on the argument that a crash
|
||||||
|
/// mid-sweep would otherwise leave the token in an image with the keychain
|
||||||
|
/// entry — and therefore the Revoke button — already gone. That argument was
|
||||||
|
/// about *recoverability*, and [`sweep_claude_token_snapshots`] answers it
|
||||||
|
/// directly: the images are the durable record, so the retry needs no keychain
|
||||||
|
/// entry to exist and no persisted to-do list. The comment that ordering
|
||||||
|
/// carried ("no window in which a scrubbed image is re-poisoned") was true of
|
||||||
|
/// images and silent about containers, which is where the leak was, and silent
|
||||||
|
/// about the minutes the token stayed live.
|
||||||
|
///
|
||||||
|
/// The keychain deletion is never rolled back if the scrub then fails; a
|
||||||
|
/// partially completed revocation is still better than none, and the outcome is
|
||||||
|
/// reported so the UI can be explicit about what is left.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||||
secure::delete_claude_oauth_token()?;
|
run_cleanup(
|
||||||
log::info!("Cleared the shared Claude authentication token");
|
Cleanup::KeychainThenImages,
|
||||||
|
secure::delete_claude_oauth_token,
|
||||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
crate::docker::container::scrub_secrets_from_snapshots,
|
||||||
if report.left_something_behind() {
|
)
|
||||||
log::warn!(
|
.await
|
||||||
"Revoked the shared Claude token but {} snapshot image(s) may still contain it",
|
|
||||||
report.failed.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ClearTokenOutcome {
|
/// Rewrite every snapshot image that still carries a credential, **without
|
||||||
snapshots_scrubbed: report.scrubbed,
|
/// touching the keychain**.
|
||||||
snapshots_failed: report
|
///
|
||||||
.failed
|
/// This is the retry, and it is its own command because the retry is its own
|
||||||
.into_iter()
|
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||||
.map(|(image, reason)| format!("{}: {}", image, reason))
|
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||||
.collect(),
|
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||||
snapshots_superseded: report.superseded_retained,
|
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||||
docker_unavailable: report.unavailable,
|
///
|
||||||
})
|
/// Safe to call at any time and in any state:
|
||||||
|
///
|
||||||
|
/// * with a token stored — a snapshot committed by an older build carries the
|
||||||
|
/// *current* token, and clearing it out of the image does not stop the
|
||||||
|
/// keychain entry being injected on the next container start;
|
||||||
|
/// * with nothing stored — images committed by earlier builds still carry
|
||||||
|
/// whatever token was live when they were committed, which is exactly the
|
||||||
|
/// case the old sweep-first ordering could strand;
|
||||||
|
/// * repeatedly — the work is re-derived from Docker each time, so an image
|
||||||
|
/// whose project was busy on the last pass is simply picked up on this one.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sweep_claude_token_snapshots() -> Result<ClearTokenOutcome, String> {
|
||||||
|
run_cleanup(
|
||||||
|
Cleanup::ImagesOnly,
|
||||||
|
// Never called; the `ImagesOnly` branch is the entire point of this
|
||||||
|
// command, and a change that made it reachable must fail loudly rather
|
||||||
|
// than delete a credential quietly.
|
||||||
|
|| -> Result<(), String> { unreachable!("an images-only sweep must never touch the keychain") },
|
||||||
|
crate::docker::container::scrub_secrets_from_snapshots,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1761,5 +1968,265 @@ mod tests {
|
|||||||
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
||||||
assert_eq!(parse_setup_token(&seen), Some(tok));
|
assert_eq!(parse_setup_token(&seen), Some(tok));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Revocation: what the sweep leaves behind, and how it is described ──
|
||||||
|
|
||||||
|
use crate::docker::container::SnapshotScrubReport;
|
||||||
|
use crate::project_lock::{try_acquire, ProjectOp};
|
||||||
|
|
||||||
|
/// The classifier is a substring match on a message another module owns,
|
||||||
|
/// which is only safe if something notices when that module rephrases. So
|
||||||
|
/// build the refusal the way production does — by actually losing the
|
||||||
|
/// race — rather than by pasting the wording in here.
|
||||||
|
#[test]
|
||||||
|
fn a_real_lock_refusal_is_recognised_as_retryable() {
|
||||||
|
let project = "auth-token-test-busy-project";
|
||||||
|
let _held = try_acquire(project, ProjectOp::Compaction).expect("first claim");
|
||||||
|
let refusal = try_acquire(project, ProjectOp::SecretScrub)
|
||||||
|
.expect_err("a second claim on the same project must be refused");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
is_project_busy_refusal(&refusal),
|
||||||
|
"project_lock's refusal is no longer recognised as retryable: {:?}",
|
||||||
|
refusal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_docker_failure_is_not_mistaken_for_a_busy_project() {
|
||||||
|
for reason in [
|
||||||
|
"could not inspect: error trying to connect: No such file or directory",
|
||||||
|
"could not create a scratch container: conflict: name already in use",
|
||||||
|
"an untagged snapshot image holds a credential and cannot be rewritten",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_project_busy_refusal(reason),
|
||||||
|
"{:?} was misclassified as a transient lock refusal",
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarise_scrub_separates_a_busy_project_from_a_broken_image() {
|
||||||
|
let project = "auth-token-test-summarise-busy";
|
||||||
|
let _held = try_acquire(project, ProjectOp::Recreate).expect("first claim");
|
||||||
|
let refusal = try_acquire(project, ProjectOp::SecretScrub).expect_err("refused");
|
||||||
|
|
||||||
|
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||||
|
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
failed: vec![
|
||||||
|
("triple-c-snapshot-b:latest".into(), refusal),
|
||||||
|
(
|
||||||
|
"triple-c-snapshot-c:latest".into(),
|
||||||
|
"could not create a scratch container: no such image".into(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
unavailable: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||||
|
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||||
|
assert!(outcome.snapshots_skipped[0].starts_with("triple-c-snapshot-b:latest: "));
|
||||||
|
assert_eq!(outcome.snapshots_failed.len(), 1, "{:?}", outcome);
|
||||||
|
assert!(outcome.snapshots_failed[0].starts_with("triple-c-snapshot-c:latest: "));
|
||||||
|
// The whole point: a skipped image is never folded into the scrubbed
|
||||||
|
// list, which is what "success" is rendered from.
|
||||||
|
assert!(!outcome.snapshots_scrubbed.iter().any(|s| s.contains("snapshot-b")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clean_sweep_needs_no_second_pass() {
|
||||||
|
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||||
|
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(!outcome.needs_another_pass());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_retained_superseded_image_alone_does_not_ask_for_a_second_pass() {
|
||||||
|
// The tag is clean; what is left is untagged and dies with the running
|
||||||
|
// container. Asking the user to sweep again would never stop.
|
||||||
|
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||||
|
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(!outcome.needs_another_pass());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_still_holding_the_credential_asks_for_a_second_pass() {
|
||||||
|
let skipped = summarise_scrub(SnapshotScrubReport {
|
||||||
|
failed: vec![(
|
||||||
|
"triple-c-snapshot-b:latest".into(),
|
||||||
|
format!("This project is being reset. {}resetting it.", PROJECT_BUSY_MARKER),
|
||||||
|
)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(skipped.needs_another_pass());
|
||||||
|
assert_eq!(skipped.snapshots_skipped.len(), 1);
|
||||||
|
|
||||||
|
let failed = summarise_scrub(SnapshotScrubReport {
|
||||||
|
failed: vec![("triple-c-snapshot-c:latest".into(), "could not inspect: boom".into())],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(failed.needs_another_pass());
|
||||||
|
|
||||||
|
let blind = summarise_scrub(SnapshotScrubReport {
|
||||||
|
unavailable: Some("Docker is not running".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(blind.needs_another_pass());
|
||||||
|
assert!(blind.snapshots_scrubbed.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPC contract the frontend reads. A field renamed on this side and
|
||||||
|
/// not on that one is a silent "nothing was skipped".
|
||||||
|
#[test]
|
||||||
|
fn the_outcome_serialises_under_the_names_the_frontend_reads() {
|
||||||
|
let json = serde_json::to_value(ClearTokenOutcome::default()).expect("serialise");
|
||||||
|
let object = json.as_object().expect("an object");
|
||||||
|
for key in [
|
||||||
|
"snapshots_scrubbed",
|
||||||
|
"snapshots_failed",
|
||||||
|
"snapshots_skipped",
|
||||||
|
"snapshots_superseded",
|
||||||
|
"docker_unavailable",
|
||||||
|
] {
|
||||||
|
assert!(object.contains_key(key), "missing {} in {:?}", key, object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The order of a revocation, and what a retry may touch ─────────────
|
||||||
|
//
|
||||||
|
// `run_cleanup` takes both halves as arguments precisely so this can be
|
||||||
|
// asserted with no keychain and no Docker daemon: the recorded order *is*
|
||||||
|
// the subject. Sweep-first put a live ~1-year credential behind a
|
||||||
|
// per-image inspect/create/commit/rmi loop — minutes, at bollard's
|
||||||
|
// 120s-per-request default — during which `has_claude_token` stayed true
|
||||||
|
// and `create_container` kept handing the token to anything started.
|
||||||
|
|
||||||
|
/// Records which half ran, in order.
|
||||||
|
type Trace = std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>;
|
||||||
|
|
||||||
|
fn trace() -> Trace {
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrubbed_one() -> SnapshotScrubReport {
|
||||||
|
SnapshotScrubReport {
|
||||||
|
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_keychain_entry_is_gone_before_the_first_image_is_touched() {
|
||||||
|
let t = trace();
|
||||||
|
let (tk, ts) = (t.clone(), t.clone());
|
||||||
|
|
||||||
|
let outcome = run_cleanup(
|
||||||
|
Cleanup::KeychainThenImages,
|
||||||
|
move || {
|
||||||
|
tk.lock().unwrap().push("keychain");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
move || async move {
|
||||||
|
ts.lock().unwrap().push("sweep");
|
||||||
|
scrubbed_one()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a cleanup whose halves both succeed is not an error");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*t.lock().unwrap(),
|
||||||
|
["keychain", "sweep"],
|
||||||
|
"the token stayed in the keychain — and therefore in every container created — \
|
||||||
|
for the whole length of the image sweep"
|
||||||
|
);
|
||||||
|
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_keychain_failure_leaves_the_images_untouched_and_is_reported() {
|
||||||
|
let t = trace();
|
||||||
|
let ts = t.clone();
|
||||||
|
|
||||||
|
let err = run_cleanup(
|
||||||
|
Cleanup::KeychainThenImages,
|
||||||
|
|| Err("the keychain is locked".to_string()),
|
||||||
|
move || async move {
|
||||||
|
ts.lock().unwrap().push("sweep");
|
||||||
|
SnapshotScrubReport::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a keychain that refused the delete must be reported, not swallowed");
|
||||||
|
|
||||||
|
assert_eq!(err, "the keychain is locked");
|
||||||
|
assert!(
|
||||||
|
t.lock().unwrap().is_empty(),
|
||||||
|
"images were rewritten for a revocation that never happened; the report is then \
|
||||||
|
discarded with the error and the user is told nothing they can act on"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug the images-only primitive exists to close: the "Retry snapshot
|
||||||
|
/// cleanup" button used to run `clear_claude_token`, so pressing it after
|
||||||
|
/// re-authenticating deleted the brand-new token with no confirmation.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_images_only_cleanup_never_reaches_the_keychain() {
|
||||||
|
let t = trace();
|
||||||
|
let (tk, ts) = (t.clone(), t.clone());
|
||||||
|
|
||||||
|
let outcome = run_cleanup(
|
||||||
|
Cleanup::ImagesOnly,
|
||||||
|
move || {
|
||||||
|
tk.lock().unwrap().push("keychain");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
move || async move {
|
||||||
|
ts.lock().unwrap().push("sweep");
|
||||||
|
scrubbed_one()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a sweep-only cleanup is not an error");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*t.lock().unwrap(),
|
||||||
|
["sweep"],
|
||||||
|
"the retry deleted a credential nobody confirmed deleting"
|
||||||
|
);
|
||||||
|
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// …and it still has to report what it could not finish, because "run it
|
||||||
|
/// again once that project is idle" is the whole affordance.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_images_only_cleanup_still_reports_what_it_could_not_finish() {
|
||||||
|
let outcome = run_cleanup(
|
||||||
|
Cleanup::ImagesOnly,
|
||||||
|
|| -> Result<(), String> { unreachable!("images only") },
|
||||||
|
|| async {
|
||||||
|
SnapshotScrubReport {
|
||||||
|
failed: vec![(
|
||||||
|
"triple-c-snapshot-b:latest".into(),
|
||||||
|
format!("This project is being started. {}removing a credential from its snapshot.", PROJECT_BUSY_MARKER),
|
||||||
|
)],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("an image left for the next pass is not a command failure");
|
||||||
|
|
||||||
|
assert!(outcome.needs_another_pass());
|
||||||
|
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||||
|
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -411,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,
|
||||||
@@ -421,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,
|
||||||
@@ -440,12 +493,19 @@ pub fn run() {
|
|||||||
browser_view::commands::close_browser_view_popout,
|
browser_view::commands::close_browser_view_popout,
|
||||||
browser_view::commands::get_browser_view_popout_state,
|
browser_view::commands::get_browser_view_popout_state,
|
||||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
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,
|
||||||
@@ -454,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,
|
||||||
@@ -466,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
|
||||||
@@ -646,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
|
||||||
|
|||||||
@@ -127,6 +127,59 @@ describe("MainTabs reordering", () => {
|
|||||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
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", () => {
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = laidOut();
|
const tabs = laidOut();
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -55,9 +56,21 @@ export default function MainTabs() {
|
|||||||
/** The tab being dragged, and the slot it would drop into. */
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | 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);
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
/** A press that has not yet moved far enough to be a drag. */
|
/** A press that has not yet moved far enough to be a drag. */
|
||||||
const pending = useRef<{ key: string; startX: number; dragging: boolean } | null>(null);
|
const pending = useRef<{
|
||||||
|
key: string;
|
||||||
|
startX: number;
|
||||||
|
dragging: boolean;
|
||||||
|
offsetX: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
top: number;
|
||||||
|
} | null>(null);
|
||||||
const suppressClick = useRef(false);
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,6 +100,7 @@ export default function MainTabs() {
|
|||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -165,16 +179,34 @@ export default function MainTabs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabClass = (active: boolean, dragging: 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" : ""}`;
|
}${dragging ? " opacity-40" : ""}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||||
|
* 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 = () => {
|
const endDrag = () => {
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,7 +244,19 @@ export default function MainTabs() {
|
|||||||
// rename input is up — that drag is a text selection.
|
// rename input is up — that drag is a text selection.
|
||||||
if (e.button !== 0 || renaming) return;
|
if (e.button !== 0 || renaming) return;
|
||||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
pending.current = { key, startX: e.clientX, dragging: false };
|
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);
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
},
|
},
|
||||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -223,6 +267,12 @@ export default function MainTabs() {
|
|||||||
drag.dragging = true;
|
drag.dragging = true;
|
||||||
setDragKey(drag.key);
|
setDragKey(drag.key);
|
||||||
setDropIndex(dropIndexAt(e.clientX));
|
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>) => {
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const drag = pending.current;
|
const drag = pending.current;
|
||||||
@@ -308,13 +358,7 @@ 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;
|
||||||
|
|
||||||
@@ -352,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}>
|
||||||
@@ -408,6 +452,26 @@ export default function MainTabs() {
|
|||||||
the hand naturally goes to say "put it at the end". */}
|
the hand naturally goes to say "put it at the end". */}
|
||||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||