Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
943c83b9e3 | ||
|
|
60188610ee | ||
|
|
db648230ee | ||
|
|
5a452e7a2a | ||
|
|
5a09254538 | ||
|
|
9297020688 | ||
|
|
bf8094dbc4 | ||
|
|
90b7e4ccb2 | ||
|
|
afe9d5cdb2 | ||
|
|
b59c6148ff | ||
|
|
95a78fe9a3 | ||
|
|
307ea07409 | ||
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec | ||
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 | ||
|
|
670450ccfd | ||
|
|
a0b9f1e19b | ||
|
|
9fadfbc37a | ||
|
|
a3bdf6f4da | ||
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 | ||
|
|
3239057f8f | ||
|
|
23364f412e | ||
|
|
b24807bd5f | ||
|
|
0f3fff92f4 | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
2708772bf9 | ||
|
|
436b6dd470 | ||
|
|
5c47656444 | ||
|
|
be47c5edfd | ||
|
|
037ed78570 | ||
|
|
31e8f9df5f | ||
|
|
3704064006 | ||
|
|
f79a44e0a8 | ||
|
|
5a8e24ccbe | ||
|
|
a1f4eee9a3 | ||
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 |
@@ -299,8 +299,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -319,14 +345,22 @@ jobs:
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
@@ -418,8 +452,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
|
||||
@@ -172,8 +172,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -185,16 +211,38 @@ jobs:
|
||||
working-directory: ./app
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
# The versioned AppImage only. The update channel's copy lives in
|
||||
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||
# it up and publish an 80 MB duplicate under a second name.
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# A green job that published nothing is the worst outcome available:
|
||||
# the release exists, carries no AppImage, and nobody is told. The
|
||||
# `|| true` above is there so a missing bundle does not mask the real
|
||||
# error, which makes this check the thing that catches it.
|
||||
shopt -s nullglob
|
||||
collected=(artifacts/*)
|
||||
if [ ${#collected[@]} -eq 0 ]; then
|
||||
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
@@ -270,6 +318,19 @@ jobs:
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
# The fixed tag every installed AppImage checks for updates. Separate
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
bash scripts/publish-update-channel.sh \
|
||||
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
@@ -325,8 +386,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
|
||||
@@ -28,6 +28,27 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# Put BuildKit in the host's network namespace so it can reach
|
||||
# act_runner's cache service.
|
||||
#
|
||||
# The `docker-container` driver — which the multi-arch build below
|
||||
# requires, since the plain `docker` driver cannot do
|
||||
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
|
||||
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
|
||||
# an address the *job* container can reach, and nothing teaches the
|
||||
# BuildKit container about it: the job could reach
|
||||
# 192.168.1.126:40649 while the container actually making the request
|
||||
# could not, and the build died with `no route to host`.
|
||||
#
|
||||
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
|
||||
# missing route (a wrong address times out instead) — which is what a
|
||||
# default firewalld zone does to traffic arriving from the docker
|
||||
# bridge. Sharing the host's namespace sidesteps the question
|
||||
# entirely: the cache address becomes local to BuildKit.
|
||||
#
|
||||
# No effect on runners where this already worked.
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -55,5 +76,21 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
# `ignore-error` is what stops a cache failure failing a build that
|
||||
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||
# service on the runner host's LAN address, and the `docker-container`
|
||||
# builder `setup-buildx-action` creates could not route to it —
|
||||
# every layer of both arches built, then the job died on
|
||||
# `GetCacheEntryDownloadURL: no route to host` while exporting.
|
||||
#
|
||||
# On a pull_request `push:` above is false, so this job pushes
|
||||
# nothing and the cache is its only output: failing it discarded a
|
||||
# complete, successful validation of the Dockerfile for both
|
||||
# architectures. A cache is an optimisation and must degrade to
|
||||
# "slow", never to "red".
|
||||
#
|
||||
# The import is already non-fatal — the build ran all 37 layers after
|
||||
# warning that it could not read the cache — so only the exporter
|
||||
# needs the flag.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
name: Publish AUR Package
|
||||
|
||||
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
|
||||
# for a given release, or the latest one if none is given. Manual dispatch
|
||||
# only — deliberately not triggered by `release` or `push`, for the same
|
||||
# reason sync-release.yml (removed in triple-c#32) never worked safely as an
|
||||
# automatic trigger: this repo's releases are assembled by build-app.yml
|
||||
# across three separate platform jobs, and there is no single automatic event
|
||||
# that fires only once everything (including the Linux .deb this workflow
|
||||
# needs) is actually uploaded. A human deciding "this release is ready, go
|
||||
# package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step)
|
||||
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
|
||||
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
|
||||
# a hand-maintained template with a placeholder version, and every real,
|
||||
# published version lives only in AUR's own git history, which is where a
|
||||
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
|
||||
# a hand-edit made directly in the AUR repo (outside this workflow) is
|
||||
# silently overwritten the next time this runs, since every run renders fresh
|
||||
# from this repo's template rather than starting from AUR's current state.
|
||||
#
|
||||
# ## Required secret
|
||||
#
|
||||
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
|
||||
# account that has already created (or been given co-maintainer access to)
|
||||
# the `triple-c-bin` package. This workflow cannot create that AUR account or
|
||||
# register the key for you — both are manual, one-time steps on
|
||||
# https://aur.archlinux.org. Until this secret exists, every run fails at the
|
||||
# "Push to AUR" step with a clear error rather than silently doing nothing.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||
Leave empty to use the latest published GitHub release.
|
||||
required: false
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and find the Linux asset
|
||||
id: resolve
|
||||
env:
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Authenticated when the secret is available (it is, everywhere
|
||||
# else in this repo's workflows) to avoid the unauthenticated
|
||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||
# lower limit, since this hits nothing but a public repo's public
|
||||
# releases.
|
||||
AUTH=()
|
||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||
|
||||
if [ -z "${VERSION_INPUT}" ]; then
|
||||
echo "No version given — resolving the latest GitHub release"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||
else
|
||||
echo "Using requested version ${VERSION_INPUT}"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||
fi
|
||||
|
||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
VERSION="${TAG#v}"
|
||||
echo "Resolved to ${TAG}"
|
||||
|
||||
# Discovered from the real release, not assumed: Tauri names the
|
||||
# asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||
# of thing that silently drifts if a future Tauri upgrade changes
|
||||
# bundler defaults — a hardcoded pattern here would then 404
|
||||
# forever until someone noticed. `head -1` guards against a release
|
||||
# somehow carrying more than one matching asset, which would
|
||||
# otherwise pass the emptiness check below and then break the
|
||||
# download step with two URLs on one line.
|
||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found asset: ${DEB_NAME}"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download the release asset and compute real checksums
|
||||
id: checksums
|
||||
env:
|
||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||
|
||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD
|
||||
id: render
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p rendered
|
||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||
cd rendered
|
||||
|
||||
# Plain string replacement throughout, not sed — the source URL
|
||||
# contains slashes and the repo name does too, and getting a sed
|
||||
# delimiter choice AND its escaping right for that is exactly the
|
||||
# kind of thing that looks correct, passes review, and breaks the
|
||||
# next time someone touches it. `re.sub` with `count=1` and an
|
||||
# exact `.format`-free literal match is boring and that's the
|
||||
# point: every substitution below fails loudly (an assertion /
|
||||
# the checks after) rather than silently no-op'ing if the
|
||||
# template's shape ever drifts from what this expects.
|
||||
#
|
||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||
# for, and this workflow always republishes the current PKGBUILD
|
||||
# verbatim rather than incrementing anything, so 1 is always
|
||||
# correct for what this workflow does. It is NOT correct for a
|
||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||
# would be forced back to 1, and no existing installation sees an
|
||||
# upgrade. That case needs a manual pkgrel bump in the template
|
||||
# before dispatching, which this workflow has no input for.
|
||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||
import re, sys
|
||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||
|
||||
with open("PKGBUILD") as f:
|
||||
text = f.read()
|
||||
|
||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||
assert n == 1, "pkgver=... line not found"
|
||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||
assert n == 1, "pkgrel=... line not found"
|
||||
|
||||
old_source = (
|
||||
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
|
||||
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
|
||||
f'Triple-C_${{pkgver}}_amd64.deb"'
|
||||
)
|
||||
new_source = (
|
||||
f'source=("{deb_name}::'
|
||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||
)
|
||||
assert old_source in text, "source=() line does not match the expected template shape"
|
||||
text = text.replace(old_source, new_source, 1)
|
||||
|
||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||
|
||||
with open("PKGBUILD", "w") as f:
|
||||
f.write(text)
|
||||
PY
|
||||
|
||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||
# obvious way to write this, and was the first draft — but on a
|
||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||
# job's own container, which the daemon's host cannot resolve; the
|
||||
# mount would silently attach an empty directory instead of failing
|
||||
# loudly. `docker cp` moves real bytes across that boundary
|
||||
# regardless of where the daemon actually lives, which is what
|
||||
# makes this work under both a bind-mount-capable runner and a
|
||||
# containerized one.
|
||||
docker pull archlinux:latest
|
||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||
useradd -m builder
|
||||
chown -R builder:builder /work
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||
echo "--- namcap ---"
|
||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||
echo "$NAMCAP_OUT"
|
||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||
# uses more than one line shape for its two rule families, and
|
||||
# namcap itself exits 0 regardless of what it reports, so this
|
||||
# grep is the only thing standing between an E: and a green job.
|
||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||
echo "namcap reported an error — see above" >&2
|
||||
exit 1
|
||||
fi
|
||||
')
|
||||
mkdir -p rendered
|
||||
docker cp rendered/. "${CID}:/work"
|
||||
# `docker start -a` streams output and its exit code is the
|
||||
# container's own — the same failure this would have hit with a
|
||||
# bind mount still fails the job the same way.
|
||||
docker start -a "${CID}"
|
||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||
docker rm -f "${CID}" >/dev/null
|
||||
|
||||
- name: Push to AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
|
||||
echo "the one-time AUR account setup this needs before it can publish anything." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
# Created with the final mode before any bytes land in it, rather
|
||||
# than a plain redirect followed by chmod, which leaves the key
|
||||
# world-readable for whatever window falls between the two calls.
|
||||
install -m 600 /dev/null ~/.ssh/aur
|
||||
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
# TOFU, not verification — accepted here because pinning AUR's
|
||||
# actual host key needs a value fetched from somewhere trusted
|
||||
# ahead of time, which this workflow doesn't have, and getting a
|
||||
# pinned value wrong fails every future run rather than just this
|
||||
# one. A keyscan failure below surfaces later as an opaque
|
||||
# "Host key verification failed" rather than a clear one here.
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
|
||||
|
||||
git clone "${AUR_REPO}" aur-repo
|
||||
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
|
||||
cd aur-repo
|
||||
git config user.name "Triple-C CI"
|
||||
git config user.email "noreply@triple-c.invalid"
|
||||
git add PKGBUILD .SRCINFO
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change from what's already published on AUR for ${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "triple-c-bin: update to ${VERSION}"
|
||||
# AUR itself uses `master`, which is what a fresh, not-yet-created
|
||||
# AUR package's empty repo advertises on clone — but the *local*
|
||||
# branch name after cloning an empty repo falls back to whatever
|
||||
# this runner's `init.defaultBranch` is if the server sends no
|
||||
# symref, so naming the destination explicitly is what keeps this
|
||||
# working if that default is ever `main` instead of `master`.
|
||||
git push origin HEAD:master
|
||||
+21
-5
@@ -71,13 +71,29 @@ npm ci
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
npx tauri build --bundles appimage
|
||||
```
|
||||
|
||||
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
|
||||
publish for an audience the AppImage already serves, and neither could
|
||||
self-update. A bare `npx tauri build` still emits them, because
|
||||
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
|
||||
untouched; they are not released and not tested.
|
||||
|
||||
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||
|
||||
| Format | Path |
|
||||
|------------|-------------------------------|
|
||||
| AppImage | `appimage/*.AppImage` |
|
||||
| Debian pkg | `deb/*.deb` |
|
||||
| RPM pkg | `rpm/*.rpm` |
|
||||
| Format | Path | Released |
|
||||
|------------|-------------------------------|----------|
|
||||
| AppImage | `appimage/*.AppImage` | yes |
|
||||
| Debian pkg | `deb/*.deb` | no |
|
||||
| RPM pkg | `rpm/*.rpm` | no |
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
|
||||
section of `CLAUDE.md` for why both of its steps are load-bearing.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
|
||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||
so a project already at `true` gets no recreation at all on upgrade.
|
||||
|
||||
### Keeping Claude Code current
|
||||
|
||||
`claude update` runs in **two** places, and both are needed:
|
||||
|
||||
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||
command every Claude session launches with, because containers use a stop/start model and a
|
||||
long-lived one would otherwise never re-check.
|
||||
|
||||
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||
deliberately untouched.
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||
@@ -436,6 +456,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
||||
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
|
||||
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
|
||||
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
|
||||
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
|
||||
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
|
||||
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
|
||||
for the same reason a running container already outranked it. Assuming a snapshot existed is what
|
||||
made a stopped, never-recreated project report "no container or snapshot image yet" with its
|
||||
container sitting right there, and left Update disabled on the projects furthest behind.
|
||||
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
|
||||
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
|
||||
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
|
||||
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
|
||||
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
|
||||
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
|
||||
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
|
||||
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
|
||||
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
|
||||
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
|
||||
layer acceptable. Two rules it earned the hard way:
|
||||
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
|
||||
daemon-wide, so a second copy of the app has live probe images matching the glob.
|
||||
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
|
||||
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
|
||||
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
|
||||
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
|
||||
two concurrent probes fought over one tag — whichever finished first force-removed the image the
|
||||
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
|
||||
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
|
||||
uniqueness is what makes overlapping probes safe.
|
||||
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
|
||||
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
|
||||
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
|
||||
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
|
||||
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
|
||||
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
|
||||
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
|
||||
failed to invalidate would plan a migration against a filesystem the project no longer has.
|
||||
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
|
||||
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
|
||||
which leaves the migrate action in the project menu enabled — that action is not gated on the
|
||||
banner — so the pre-flight would report nothing to copy while the backend was told to copy
|
||||
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
|
||||
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
|
||||
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
|
||||
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
|
||||
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
|
||||
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
|
||||
- **`get_container_staleness` never commits while the project is claimed.** It takes no
|
||||
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
|
||||
reads `project_lock::held` instead and probes the snapshot rather than the container. The
|
||||
collision is not symmetric: the probe losing is a retryable `probe_error`, but
|
||||
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
|
||||
commit would fail the user's Start with an opaque error.
|
||||
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
|
||||
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
|
||||
stamps *now* — which is what real probe images do, and what any fixture for them must do.
|
||||
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||
@@ -552,6 +629,169 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
|
||||
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
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
+93
-5
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,65 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one.
|
||||
|
||||
> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions.
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
|
||||
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||
because nothing installed it — so it will not show up in your app launcher, and
|
||||
running it from a file manager gives you a generic icon in the taskbar.
|
||||
|
||||
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Apps
|
||||
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
```
|
||||
|
||||
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||
copied or moved. To remove the entry again:
|
||||
|
||||
```bash
|
||||
./scripts/install-appimage.sh --uninstall
|
||||
```
|
||||
|
||||
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||
bundle would appear in the menu and then fail to start anything.
|
||||
|
||||
Two follow-ups worth knowing:
|
||||
|
||||
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||
with a newer version under a different filename, re-run the script against the
|
||||
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||
icon cache, not a failed install — see
|
||||
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -183,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
|
||||
│ │ │ │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
├─────────────┴────────────────────────────────────────────────────────┤
|
||||
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │
|
||||
│ 2 project(s) · 1 running · 2 terminal(s) Notes │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -208,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
|
||||
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
||||
open you get a welcome screen with Docker/image/project readiness checks.
|
||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
||||
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
|
||||
speech-to-text is enabled.
|
||||
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -1164,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
|
||||
|
||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||
|
||||
### Jump to Current
|
||||
### Scrolling
|
||||
|
||||
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
||||
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
|
||||
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
|
||||
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
|
||||
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
|
||||
for them to act on.
|
||||
|
||||
### When the mouse stops working
|
||||
|
||||
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
|
||||
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
|
||||
you cannot select text, and stray characters can appear as you move the pointer.
|
||||
|
||||
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
|
||||
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
|
||||
container — only the terminal's own state is reset.
|
||||
|
||||
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
|
||||
the button is showing most of the time you are in one. It is there for when a program exits
|
||||
without handing the mouse back and the terminal is left stuck; releasing while a program is still
|
||||
running just takes the mouse away from that program.
|
||||
|
||||
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
|
||||
macOS.
|
||||
|
||||
### Files
|
||||
|
||||
@@ -1536,3 +1618,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
|
||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ choice it never asked about.
|
||||
|
||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
file drag-drop, STT), the web terminal, and workspace backup.
|
||||
|
||||
---
|
||||
|
||||
+6
-9
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
||||
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||
|
||||
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
|
||||
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
|
||||
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
|
||||
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
|
||||
|
||||
### bollard (Docker API)
|
||||
|
||||
@@ -418,12 +421,6 @@ triple-c/
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
|
||||
│
|
||||
├── packaging/
|
||||
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
|
||||
│ ├── PKGBUILD
|
||||
│ └── README.md
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
|
||||
Generated
+145
@@ -8,6 +8,41 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -310,6 +363,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -569,6 +631,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -5149,6 +5280,8 @@ dependencies = [
|
||||
name = "triple-c"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
@@ -5173,7 +5306,9 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5287,6 +5422,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -36,6 +36,13 @@ tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
zeroize = "1"
|
||||
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
|
||||
# container. Already in the tree transitively (reqwest), and the point of
|
||||
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,6 +15,12 @@ use crate::AppState;
|
||||
/// non-`Running` status carrying an explanation rather than an error, so the
|
||||
/// pane always has something specific to say. This is host-side only — no
|
||||
/// container recreation is involved either way.
|
||||
///
|
||||
/// Either way the choice is persisted, so it survives an app restart. This is
|
||||
/// the only caller allowed to write `false`: every other path to
|
||||
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
|
||||
/// is a teardown rather than the user changing their mind. Enabling persists
|
||||
/// inside `start`, which is the single funnel for it.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_enabled(
|
||||
project_id: String,
|
||||
@@ -23,9 +29,32 @@ pub async fn set_browser_view_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
if !enabled {
|
||||
// Persist first, then tear down: the supervisor's own teardown emit
|
||||
// reads this flag back out of the store, and reading it mid-stop would
|
||||
// announce a view that is going away as still enabled.
|
||||
//
|
||||
// But the write's outcome is a *value*, not a branch. A `?` here meant
|
||||
// that a store with no such project record returned early and
|
||||
// `manager().stop()` never ran, leaving the supervisor, the proxy and
|
||||
// the host port up for a project that, as far as the user is concerned,
|
||||
// just had its view switched off. That state is not hypothetical while
|
||||
// a session is live — the supervisor's own `store.get()` check in
|
||||
// [`crate::browser_view`] exists because a record can go away
|
||||
// underneath it — and before the flag was persisted at all, turning the
|
||||
// view off always tore the session down.
|
||||
let persisted = state
|
||||
.projects_store
|
||||
.set_browser_view_enabled(&project_id, false);
|
||||
// Awaits the supervisor, so the host port is released before we return.
|
||||
manager().stop(&project_id).await;
|
||||
return Ok(manager().status(&project_id).await);
|
||||
//
|
||||
// A failed write is still reported rather than logged and swallowed.
|
||||
// The resources are gone either way by this point, so surfacing it
|
||||
// costs nothing that matters, and the failure it describes is one the
|
||||
// user needs: the stored flag still says *enabled*, so the view comes
|
||||
// back by itself on the next launch. Returning `Ok` would be a claim
|
||||
// about persistence that isn't true.
|
||||
tear_down_then_report(persisted, manager().stop(&project_id)).await?;
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
@@ -40,10 +69,31 @@ pub async fn set_browser_view_enabled(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Current status. Cheap: reads in-process state only, never the container.
|
||||
/// Await `teardown`, then report `persisted`.
|
||||
///
|
||||
/// Trivial on purpose, and split out for one reason: it is the whole rule the
|
||||
/// disable path of [`set_browser_view_enabled`] has to obey — the teardown is
|
||||
/// unconditional, and a failed persist surfaces only after it has run — and as
|
||||
/// a free function that rule can be tested without a live `AppState`.
|
||||
async fn tear_down_then_report(
|
||||
persisted: Result<(), String>,
|
||||
teardown: impl std::future::Future<Output = ()>,
|
||||
) -> Result<(), String> {
|
||||
teardown.await;
|
||||
persisted
|
||||
}
|
||||
|
||||
/// Current status. Cheap: the session map in this process plus the stored flag,
|
||||
/// never the container.
|
||||
///
|
||||
/// The two are independent on purpose — this is what the pane reads on mount,
|
||||
/// and after an app restart the honest answer is "enabled, nothing running".
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id).await)
|
||||
pub async fn get_browser_view_status(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
|
||||
}
|
||||
|
||||
/// Probe the container for Playwright without starting anything.
|
||||
@@ -110,7 +160,9 @@ pub async fn open_browser_view_popout(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||
return Err(
|
||||
"The browser view isn't running. Start it before opening it in its own window."
|
||||
@@ -209,7 +261,9 @@ pub async fn open_page_in_container_browser(
|
||||
// the user to go and press Start in the Browser tab themselves — and from
|
||||
// the terminal's URL prompt, with no indication that was even needed.
|
||||
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if status.state != BrowserViewState::Running {
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
@@ -229,7 +283,9 @@ pub async fn open_page_in_container_browser(
|
||||
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||
// window of its own or it lands somewhere the user isn't looking.
|
||||
if show_window {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
let name = state
|
||||
.projects_store
|
||||
@@ -311,6 +367,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
|
||||
Ok(popout::match_window(&project_id))
|
||||
}
|
||||
|
||||
/// The project's stored browser-view opt-in.
|
||||
///
|
||||
/// The manager holds no copy of this — see
|
||||
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
|
||||
/// status call reads it here, the way `get_auth_bridge_status` does. A project
|
||||
/// that has gone away reads as off, which is the only answer that can be given
|
||||
/// about a record that no longer exists.
|
||||
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
|
||||
state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled)
|
||||
}
|
||||
|
||||
/// The project's container, or a sentence saying why there isn't one.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
@@ -344,3 +414,43 @@ async fn running_container(
|
||||
}
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// The regression: turning the view off must not leave the supervisor, the
|
||||
/// proxy and the host port running just because the project record could
|
||||
/// not be written — which is exactly what a missing record did.
|
||||
#[tokio::test]
|
||||
async fn a_failed_persist_does_not_skip_the_teardown() {
|
||||
let torn_down = AtomicBool::new(false);
|
||||
let result = tear_down_then_report(Err("Project x not found".to_string()), async {
|
||||
torn_down.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
torn_down.load(Ordering::SeqCst),
|
||||
"the session must be torn down even when the store write failed"
|
||||
);
|
||||
assert_eq!(
|
||||
result.err().as_deref(),
|
||||
Some("Project x not found"),
|
||||
"and the write failure must still reach the caller, not be swallowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_persist_reports_success_after_the_teardown() {
|
||||
let torn_down = AtomicBool::new(false);
|
||||
let result = tear_down_then_report(Ok(()), async {
|
||||
torn_down.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(torn_down.load(Ordering::SeqCst));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,22 @@
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
|
||||
//! Off by default and per-project opt-in. The opt-in itself is
|
||||
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
|
||||
//! `auth_bridge_enabled` and read from the store on demand rather than cached
|
||||
//! here — so the pane comes back the way it was left. What does *not* persist
|
||||
//! is the session: nothing starts a viewer on app start, so a project left
|
||||
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
|
||||
//! for one. That is deliberate, and the reason the flag and the session are
|
||||
//! separate ideas — see [`BrowserViewManager::status`].
|
||||
//!
|
||||
//! One supervisor task per session owns the proxy and the viewer process, and it
|
||||
//! is the only thing that tears them down, so every way a session can end funnels
|
||||
//! through one code path:
|
||||
//!
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
@@ -59,7 +67,10 @@
|
||||
//! orphan is reachable on container loopback only: the host-side port dies with
|
||||
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
|
||||
//! precisely so the bridge will not mirror an orphan the next time the app
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it.
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
|
||||
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
|
||||
//! which is exactly the state that invites the user to press the button that
|
||||
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
|
||||
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BrowserViewStatus {
|
||||
/// The per-project opt-in. Off by default.
|
||||
/// The per-project opt-in, read from the persisted project record. Off by
|
||||
/// default, and true without a `Running` state whenever the view is turned
|
||||
/// on but has nothing up — a stopped container, or an app that has just
|
||||
/// restarted and does not auto-start viewers.
|
||||
pub enabled: bool,
|
||||
pub state: BrowserViewState,
|
||||
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
|
||||
@@ -201,17 +215,20 @@ struct Session {
|
||||
|
||||
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
|
||||
|
||||
/// Live sessions, and nothing else.
|
||||
///
|
||||
/// The per-project opt-in deliberately is **not** a field here. It lives on
|
||||
/// the project record as
|
||||
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
|
||||
/// is read from [`ProjectsStore`] at each use, exactly as
|
||||
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
|
||||
/// one copy, durable across a restart, and impossible to get out of step with
|
||||
/// what the Config tab shows. A cached copy here was the previous design and
|
||||
/// its only observable behaviour was forgetting the user's choice on every
|
||||
/// app start.
|
||||
#[derive(Default)]
|
||||
pub struct BrowserViewManager {
|
||||
sessions: SessionMap,
|
||||
/// The per-project opt-in.
|
||||
///
|
||||
/// NOTE: in memory only, so it does not survive an app restart. The durable
|
||||
/// home for this is a `browser_view_enabled: bool` field on
|
||||
/// `models::Project` (see the report) — `models/project.rs` is out of scope
|
||||
/// for this change, so the flag lives here and the wiring is otherwise
|
||||
/// identical to `auth_bridge_enabled`.
|
||||
enabled: Mutex<std::collections::HashSet<String>>,
|
||||
next_epoch: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
|
||||
}
|
||||
|
||||
impl BrowserViewManager {
|
||||
pub async fn is_enabled(&self, project_id: &str) -> bool {
|
||||
self.enabled.lock().await.contains(project_id)
|
||||
}
|
||||
|
||||
async fn set_enabled(&self, project_id: &str, enabled: bool) {
|
||||
let mut set = self.enabled.lock().await;
|
||||
if enabled {
|
||||
set.insert(project_id.to_string());
|
||||
} else {
|
||||
set.remove(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status without touching the container.
|
||||
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
|
||||
let enabled = self.is_enabled(project_id).await;
|
||||
///
|
||||
/// `enabled` is passed in rather than looked up, the way
|
||||
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
|
||||
/// the caller's to read from the store, and keeping it out of here is what
|
||||
/// stops a second copy of it appearing. A project whose view is enabled but
|
||||
/// whose container is stopped — or whose app has just restarted — reports
|
||||
/// `enabled: true` with a state of `Off`, which is the honest answer.
|
||||
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
|
||||
match self.sessions.lock().await.get(project_id) {
|
||||
Some(session) => BrowserViewStatus {
|
||||
enabled,
|
||||
@@ -261,6 +271,14 @@ impl BrowserViewManager {
|
||||
///
|
||||
/// Idempotent: a call while a live session exists returns that session's
|
||||
/// status untouched, so re-opening the tab does not restart the dashboard.
|
||||
///
|
||||
/// This is the single funnel for turning the view **on**, so it is also
|
||||
/// where the durable flag is written — both call sites (the toggle and
|
||||
/// `open_page_in_container_browser`, which opens a page and then shows it)
|
||||
/// mean "on", and neither can forget. The **off** direction is not
|
||||
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
|
||||
/// that are not the user changing their mind, so the command owns that
|
||||
/// write. See [`Self::stop`].
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
@@ -268,7 +286,7 @@ impl BrowserViewManager {
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
self.set_enabled(&project_id, true).await;
|
||||
store.set_browser_view_enabled(&project_id, true)?;
|
||||
|
||||
// Bind the answer before acting on it: `status()` takes the same lock,
|
||||
// and this mutex is not reentrant.
|
||||
@@ -279,7 +297,7 @@ impl BrowserViewManager {
|
||||
.get(&project_id)
|
||||
.is_some_and(|s| !s.supervisor.is_finished());
|
||||
if already_live {
|
||||
return Ok(self.status(&project_id).await);
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
@@ -364,14 +382,21 @@ impl BrowserViewManager {
|
||||
},
|
||||
);
|
||||
|
||||
let status = self.status(&project_id).await;
|
||||
let status = self.status(&project_id, true).await;
|
||||
emit(&app, &project_id, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Stop one project's view and wait until its host port has been released.
|
||||
///
|
||||
/// Tears the *session* down and deliberately leaves the durable flag alone.
|
||||
/// Most callers are not the user turning the feature off — a migration
|
||||
/// removes the container out from under a running view
|
||||
/// (`migration_commands`), and the container can stop for any other reason
|
||||
/// — and persisting `false` for those would quietly opt the project out of
|
||||
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
|
||||
/// the one caller that means it, and it writes the flag itself first.
|
||||
pub async fn stop(&self, project_id: &str) {
|
||||
self.set_enabled(project_id, false).await;
|
||||
// Remove under the lock, then release it before awaiting: the
|
||||
// supervisor takes the same lock to deregister itself on exit.
|
||||
let session = self.sessions.lock().await.remove(project_id);
|
||||
@@ -483,7 +508,12 @@ async fn supervise(
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
let _ = popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
// Straight from the store, like the auth bridge's own teardown emit: the
|
||||
// session is over, but the project may well still be opted in — a stopped
|
||||
// container is not a changed mind, and the pane has to show the difference.
|
||||
let enabled = store
|
||||
.get(&project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled);
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
@@ -915,6 +945,25 @@ mod tests {
|
||||
assert!(s.url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_opt_in_and_the_live_session_are_separate_answers() {
|
||||
let manager = BrowserViewManager::default();
|
||||
|
||||
// Exactly what the pane reads on mount after an app restart of a
|
||||
// project that was left enabled: the durable flag says on, and nothing
|
||||
// auto-starts, so the state is honestly `Off`. The old in-memory flag
|
||||
// could not express this — it came back `false` and the pane silently
|
||||
// showed the feature as never having been turned on.
|
||||
let status = manager.status("p1", true).await;
|
||||
assert!(status.enabled);
|
||||
assert_eq!(status.state, BrowserViewState::Off);
|
||||
assert!(status.url.is_none());
|
||||
|
||||
// The flag belongs to the caller, read from the store. The manager
|
||||
// keeps no copy, so it has nothing to contradict it with.
|
||||
assert!(!manager.status("p1", false).await.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
||||
let mut d = PlaywrightDetection::default();
|
||||
|
||||
@@ -92,8 +92,111 @@ fn pick_recorded_lineage(
|
||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||
/// be called on demand, not polled.
|
||||
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||
/// container, stopped or otherwise, and no snapshot image.
|
||||
///
|
||||
/// It used to be reported for a *stopped* container too, which was simply
|
||||
/// untrue — the container was sitting right there — and it disabled Update on
|
||||
/// exactly the long-lived projects that had never been recreated and so had no
|
||||
/// snapshot to fall back on.
|
||||
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
|
||||
|
||||
/// Where [`get_container_staleness`] reads the project's *current* filesystem
|
||||
/// from, in descending order of how current the answer is.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ProbeSource {
|
||||
/// `docker exec` into the live container. The only source that includes
|
||||
/// everything installed since the last commit *in this session*.
|
||||
RunningContainer,
|
||||
/// Commit the stopped container's writable layer to a throwaway image and
|
||||
/// probe that. Exactly as current as the container, which is what makes it
|
||||
/// preferable to the snapshot — see below.
|
||||
StoppedContainer,
|
||||
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
|
||||
Snapshot,
|
||||
/// Nothing to read: no container, no snapshot.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Pick the probe source. `container_running` is `None` when the project has no
|
||||
/// container at all, `Some(false)` when it has a stopped one.
|
||||
///
|
||||
/// **A stopped container outranks the snapshot.** The snapshot image is not a
|
||||
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
|
||||
/// config-change recreate) or inside a migration, so a project that has never
|
||||
/// hit either has *no snapshot at all*, however long it has been in use, and
|
||||
/// one that has is stale by everything installed since. The container's
|
||||
/// writable layer is the truth in both cases. This is the same argument
|
||||
/// [`mig::manifest_from_container`] already makes for the running case; it does
|
||||
/// not stop applying when the container is stopped.
|
||||
///
|
||||
/// Getting this wrong is what made a stopped, never-recreated project report
|
||||
/// "no container or snapshot image yet" — with its container sitting right
|
||||
/// there — and left Update disabled on the projects that most needed it.
|
||||
fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) -> ProbeSource {
|
||||
match (container_running, snapshot_exists) {
|
||||
(Some(true), _) => ProbeSource::RunningContainer,
|
||||
(Some(false), _) => ProbeSource::StoppedContainer,
|
||||
(None, true) => ProbeSource::Snapshot,
|
||||
(None, false) => ProbeSource::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reported as `probe_error` when another operation owns the project and there
|
||||
/// is no snapshot image to read instead. Deliberately not a claim about the
|
||||
/// container: nothing is wrong with it, the answer is simply not safe to take
|
||||
/// right now. See [`stopped_probe_policy`].
|
||||
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
|
||||
|
||||
/// What to do about a stopped container, whose probe is the expensive one: it
|
||||
/// commits the writable layer before it can read anything.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum StoppedProbe {
|
||||
/// Commit and probe. The current answer, and the default.
|
||||
Commit,
|
||||
/// Probe the snapshot image instead. Less current — it lags the container by
|
||||
/// everything installed since the last commit — but it allocates nothing and
|
||||
/// touches nothing, which is what makes it the right answer while another
|
||||
/// operation owns the container.
|
||||
SnapshotInstead,
|
||||
/// Report rather than guess.
|
||||
Defer,
|
||||
}
|
||||
|
||||
/// Pick what to do about a stopped container.
|
||||
///
|
||||
/// **Never commits while the project is claimed.** `get_container_staleness`
|
||||
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
|
||||
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
|
||||
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
|
||||
/// *recreate* losing is not, because `start_project_container` removes the old
|
||||
/// container with a hard `?`, so a non-404 from a remove that raced this commit
|
||||
/// fails the whole Start with an opaque "Failed to remove container". Reading
|
||||
/// the claim costs nothing and takes that failure off the table.
|
||||
fn stopped_probe_policy(project_is_busy: bool, snapshot_exists: bool) -> StoppedProbe {
|
||||
match (project_is_busy, snapshot_exists) {
|
||||
(false, _) => StoppedProbe::Commit,
|
||||
(true, true) => StoppedProbe::SnapshotInstead,
|
||||
(true, false) => StoppedProbe::Defer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
|
||||
/// on demand, not polled.
|
||||
///
|
||||
/// **Not read-only, despite only reporting.** The stopped-container path commits
|
||||
/// a throwaway image and force-removes it, which makes this a writer of a
|
||||
/// `triple-c-probe-*` image and puts it in the class of thing
|
||||
/// [`crate::project_lock`] exists for — and it takes no claim. That is
|
||||
/// deliberate: this is what the migration banner calls to decide whether to
|
||||
/// offer an update, including while a migration is in flight, so refusing it
|
||||
/// under a claim would blank the banner exactly when it has the most to say.
|
||||
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
|
||||
/// migration can remove the container out from under the commit, and the result
|
||||
/// is a `probe_error` the user can retry, never a damaged container or a
|
||||
/// mislabelled image. Two overlapping probes cannot collide either, because
|
||||
/// probe image names are unique per call; see
|
||||
/// [`crate::docker::container::get_probe_image_name`].
|
||||
#[tauri::command]
|
||||
pub async fn get_container_staleness(
|
||||
project_id: String,
|
||||
@@ -145,16 +248,62 @@ pub async fn get_container_staleness(
|
||||
};
|
||||
|
||||
// ── Probes ───────────────────────────────────────────────────────────
|
||||
let running = match &container_id {
|
||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
||||
None => false,
|
||||
let container_running = match &container_id {
|
||||
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
|
||||
None => None,
|
||||
};
|
||||
let from_manifest = if running {
|
||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
|
||||
let from_manifest = match (
|
||||
pick_probe_source(container_running, snapshot_exists),
|
||||
&container_id,
|
||||
) {
|
||||
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
|
||||
(ProbeSource::StoppedContainer, Some(id)) => {
|
||||
let busy = crate::project_lock::held(&project_id).is_some();
|
||||
match stopped_probe_policy(busy, snapshot_exists) {
|
||||
StoppedProbe::Commit => {
|
||||
match mig::manifest_from_stopped_container_cached(id).await {
|
||||
Ok(m) => Ok(m),
|
||||
// **Never let a failed commit cost an answer the
|
||||
// snapshot could have given.** Before stopped
|
||||
// containers were readable at all, a stopped project
|
||||
// fell straight through to its snapshot, so surfacing
|
||||
// this error where the snapshot exists would make the
|
||||
// banner *worse* than it was — and the ways this fails
|
||||
// are the ones where the fallback matters most: a full
|
||||
// disk (the commit has to allocate the whole writable
|
||||
// layer; the snapshot probe allocates nothing) and a
|
||||
// 409 from an operation that claimed the project after
|
||||
// the check above.
|
||||
Err(e) if snapshot_exists => {
|
||||
log::warn!(
|
||||
"Probing the stopped container for project {} failed ({}) — \
|
||||
falling back to its snapshot image, which may lag it",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
} else {
|
||||
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
StoppedProbe::SnapshotInstead => {
|
||||
log::info!(
|
||||
"Project {} is claimed by another operation — probing its snapshot image \
|
||||
rather than committing the container",
|
||||
project_id
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
}
|
||||
StoppedProbe::Defer => Err(PROJECT_BUSY.to_string()),
|
||||
}
|
||||
}
|
||||
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
|
||||
// `container_running` is `Some` exactly when `container_id` is, so the
|
||||
// two arms above are the only ones those variants can reach. This arm
|
||||
// is `ProbeSource::Nothing` — and now *only* that: it used to also
|
||||
// swallow every stopped container, which is the bug.
|
||||
(_, _) => Err(NOTHING_TO_PROBE.to_string()),
|
||||
};
|
||||
|
||||
let (from_manifest, base_manifest) = match from_manifest {
|
||||
@@ -1964,6 +2113,59 @@ mod tests {
|
||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_probed_rather_than_reported_missing() {
|
||||
// The regression: a container that exists but is stopped, with no
|
||||
// snapshot ever taken, read as "nothing to compare against".
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), false),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
|
||||
// The snapshot lags the container by everything installed since the
|
||||
// last commit, in both states.
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(true), true),
|
||||
ProbeSource::RunningContainer
|
||||
);
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), true),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
|
||||
assert_eq!(pick_probe_source(None, true), ProbeSource::Snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
|
||||
// The one case the "no container or snapshot image yet" message may
|
||||
// still describe.
|
||||
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
|
||||
assert_eq!(stopped_probe_policy(false, false), StoppedProbe::Commit);
|
||||
assert_eq!(stopped_probe_policy(false, true), StoppedProbe::Commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
|
||||
// The snapshot lags, but a stale answer beats failing someone's Start.
|
||||
assert_eq!(
|
||||
stopped_probe_policy(true, true),
|
||||
StoppedProbe::SnapshotInstead
|
||||
);
|
||||
// Nothing to fall back to: say so instead of committing anyway.
|
||||
assert_eq!(stopped_probe_policy(true, false), StoppedProbe::Defer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
|
||||
@@ -8,8 +8,10 @@ pub mod help_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod notes_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod settings_export_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::models::Note;
|
||||
use crate::storage::notes_store;
|
||||
|
||||
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||
/// recently edited.
|
||||
///
|
||||
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||
/// of the same list — cannot drift into two different orders.
|
||||
#[tauri::command]
|
||||
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||
let mut notes = notes_store::load(&project_id)?;
|
||||
notes.sort_by(|a, b| {
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||
});
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
/// Insert or replace one note.
|
||||
///
|
||||
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||
/// here is editing one note.
|
||||
#[tauri::command]
|
||||
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||
notes_store::upsert(&project_id, note)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||
notes_store::delete(&project_id, ¬e_id)
|
||||
}
|
||||
@@ -722,6 +722,15 @@ pub async fn remove_project(
|
||||
// holding an entire snapshot image that nothing will ever reference again.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// A project's notes are the one piece of its state that is purely the
|
||||
// user's prose, so removal takes them with it rather than leaving an
|
||||
// orphan file keyed by an id nothing will ever look up again. Logged and
|
||||
// not propagated: an orphaned notes file is harmless, and a project that
|
||||
// cannot be removed is not.
|
||||
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
|
||||
log::warn!("Could not remove notes for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
// Stop and remove container if it exists. Everything named in `report`
|
||||
// below is what will be unreachable the moment this function drops the
|
||||
// project record — see [`ProjectRemovalReport`] and
|
||||
@@ -1027,7 +1036,6 @@ fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc
|
||||
#[tauri::command]
|
||||
pub async fn update_project(
|
||||
project: serde_json::Value,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
// Taken as raw JSON, then deserialised, for one reason: a secret field that
|
||||
@@ -1089,37 +1097,57 @@ pub async fn update_project(
|
||||
// [`crate::models::validate_env_vars_update`].
|
||||
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||
|
||||
project.container_id = stored.container_id;
|
||||
project.status = stored.status;
|
||||
project.created_at = stored.created_at;
|
||||
restore_store_owned_fields(&mut project, &stored);
|
||||
project.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
store_secrets_for_project(&project, &explicitly_cleared)?;
|
||||
let updated = state.projects_store.update(project)?;
|
||||
|
||||
// `auth_bridge_enabled` can arrive through this generic save as well as
|
||||
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
||||
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
||||
// when nothing is running, so this is safe on every project save.
|
||||
if updated.auth_bridge_enabled {
|
||||
if let Some(ref container_id) = updated.container_id {
|
||||
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
updated.id.clone(),
|
||||
container_id.clone(),
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.auth_bridge.stop(&updated.id).await;
|
||||
// Nothing reconciles the *running* auth bridge here any more, and there is
|
||||
// nothing left for such a step to do. This command can no longer change
|
||||
// `auth_bridge_enabled` at all (see [`restore_store_owned_fields`]), so a
|
||||
// reconcile could only ever re-assert what was already true. The paths that
|
||||
// do change it each own their own side effect: `set_auth_bridge_enabled`
|
||||
// starts or stops the bridge itself, [`start_project_container`] arms it
|
||||
// when the container comes up, and `reconcile_project_statuses` re-arms it
|
||||
// for every already-running container at launch. The version of this that
|
||||
// re-asserted on every save is what turned a stale flag in a payload into a
|
||||
// restarted bridge.
|
||||
state.projects_store.update(project)
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
/// Restore onto `project` the fields whose value belongs to the store rather
|
||||
/// than to whoever is saving the project. See the comment above `stored` in
|
||||
/// [`update_project`] for `container_id`, `status` and `created_at`.
|
||||
///
|
||||
/// **Both feature flags are in here, for one reason that covers them equally:
|
||||
/// neither ever arrives through this command as an edit.** Each has a
|
||||
/// dedicated setter — [`crate::browser_view::commands::set_browser_view_enabled`]
|
||||
/// and [`crate::commands::auth_bridge_commands::set_auth_bridge_enabled`] —
|
||||
/// and that setter is the only control the UI offers for it. Neither is wired
|
||||
/// into the Config tab's `save`: the browser view's toggle lives in the Browser
|
||||
/// tab, and `AuthBridgeRow`'s switch calls `set_auth_bridge_enabled` directly
|
||||
/// even though it is rendered *in* the Config tab, because that tab's editors
|
||||
/// are disabled while the container runs and the bridge is precisely the thing
|
||||
/// a user needs to flip while a login is hanging.
|
||||
///
|
||||
/// So the flags in an incoming payload are never a choice — they are whatever
|
||||
/// the frontend was told when it loaded the project, and the setters do not
|
||||
/// write their new value back into frontend app state. Every unrelated save
|
||||
/// (a renamed session, an env var, a mount name) carries that snapshot back.
|
||||
/// Taking it would silently undo a toggle made since.
|
||||
///
|
||||
/// This restored only `browser_view_enabled` before, on the stated belief that
|
||||
/// the Config tab edited `auth_bridge_enabled` through this save. It does not.
|
||||
/// The consequence was specific: a user turns the bridge off — having been told
|
||||
/// a bridged port is unauthenticated and reachable by any local process — then
|
||||
/// closes a renamed terminal tab, and the stale `true` in that save re-persisted
|
||||
/// and restarted the bridge.
|
||||
fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
|
||||
project.container_id = stored.container_id.clone();
|
||||
project.status = stored.status.clone();
|
||||
project.browser_view_enabled = stored.browser_view_enabled;
|
||||
project.auth_bridge_enabled = stored.auth_bridge_enabled;
|
||||
project.created_at = stored.created_at.clone();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2177,4 +2205,89 @@ mod tests {
|
||||
// Changing it to a different root is a change, and refused.
|
||||
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
|
||||
}
|
||||
// ── Fields a generic save does not get to write ───────────────────────
|
||||
|
||||
/// A project as the store holds it, plus the copy the frontend is about to
|
||||
/// save back: same record, one unrelated edit, and the flags as they were
|
||||
/// when the frontend last loaded it.
|
||||
fn stored_and_stale_payload() -> (Project, Project) {
|
||||
let mut stored = Project::new("demo".to_string(), Vec::new());
|
||||
stored.container_id = Some("abc123".to_string());
|
||||
stored.status = ProjectStatus::Running;
|
||||
|
||||
let mut payload = stored.clone();
|
||||
payload.container_id = None;
|
||||
payload.status = ProjectStatus::Stopped;
|
||||
payload
|
||||
.renamed_session_names
|
||||
.insert("s1".to_string(), "build".to_string());
|
||||
|
||||
(stored, payload)
|
||||
}
|
||||
|
||||
/// The regression. The user turns the auth bridge off — the switch calls
|
||||
/// `set_auth_bridge_enabled`, which persists `false` and stops the bridge,
|
||||
/// and writes nothing back into the frontend's copy of the project. Every
|
||||
/// holder of that copy still has `auth_bridge_enabled: true`, and the next
|
||||
/// unrelated save (closing a renamed terminal tab) posts it back. That save
|
||||
/// must not re-enable the bridge.
|
||||
#[test]
|
||||
fn a_stale_auth_bridge_flag_in_a_save_cannot_re_enable_a_disabled_bridge() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.auth_bridge_enabled = false;
|
||||
payload.auth_bridge_enabled = true;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(
|
||||
!payload.auth_bridge_enabled,
|
||||
"a save must not be able to turn the bridge back on: the stored value is the user's"
|
||||
);
|
||||
// The edit the save was actually for still goes through.
|
||||
assert_eq!(
|
||||
payload.renamed_session_names.get("s1").map(String::as_str),
|
||||
Some("build")
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror image, and the reason the serde default going to `true`
|
||||
/// made this worse: a pre-existing record with no `auth_bridge_enabled`
|
||||
/// key reads as enabled, so the stale payload is `true` for every project
|
||||
/// that predates the field. A user who has *not* turned the bridge off is
|
||||
/// equally entitled to have the store's answer win.
|
||||
#[test]
|
||||
fn an_enabled_bridge_is_left_enabled_by_the_same_rule() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.auth_bridge_enabled = true;
|
||||
payload.auth_bridge_enabled = false;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(payload.auth_bridge_enabled);
|
||||
}
|
||||
|
||||
/// The flag that was already restored, kept under test beside the one that
|
||||
/// was not — the two are owned by their setters for the same reason and
|
||||
/// must not drift apart again.
|
||||
#[test]
|
||||
fn a_stale_browser_view_flag_cannot_undo_the_panes_toggle_either() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
stored.browser_view_enabled = true;
|
||||
payload.browser_view_enabled = false;
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert!(payload.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_handle_status_and_creation_time_still_come_from_the_store() {
|
||||
let (stored, mut payload) = stored_and_stale_payload();
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
assert_eq!(payload.container_id.as_deref(), Some("abc123"));
|
||||
assert_eq!(payload.status, ProjectStatus::Running);
|
||||
assert_eq!(payload.created_at, stored.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
||||
Ok(state.settings_store.get())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = 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,
|
||||
&settings.global_custom_env_vars,
|
||||
&incoming.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
@@ -40,14 +45,37 @@ pub async fn update_settings(
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
settings.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(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
incoming.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
// Third host path this struct owns, same reasoning: any project with
|
||||
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||
// here is a read-write bind mount of whatever it names into every such
|
||||
// project's container.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"Docker socket path",
|
||||
before.docker_socket_path.as_deref(),
|
||||
incoming.docker_socket_path.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
@@ -122,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
GatewayAction::StopIfRunning => {
|
||||
log::info!("Model gateway disabled in settings — stopping the container");
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
||||
log::error!(
|
||||
"Failed to stop the model gateway after it was disabled: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
GatewayAction::RestartIfRunning => {
|
||||
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_image(
|
||||
image_name: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
docker::pull_image(&image_name, move |msg| {
|
||||
let _ = app_handle.emit("image-pull-progress", msg);
|
||||
@@ -334,7 +362,10 @@ mod tests {
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.enabled = false;
|
||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
||||
assert_eq!(
|
||||
gateway_action(&before, &after),
|
||||
GatewayAction::StopIfRunning
|
||||
);
|
||||
// Still true when it was already off — a stray running container is
|
||||
// still a container that shouldn't be up.
|
||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||
//! out of scope: per-project settings, per-project secrets, and anything
|
||||
//! living in a project's Docker volumes.
|
||||
//!
|
||||
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||
//! host path string is the exact shape of bug that produced this app's past
|
||||
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||
//! settings_import` resolves the chosen path itself and remembers it
|
||||
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||
//! the same file without the path ever crossing back over IPC.
|
||||
//!
|
||||
//! The *decrypted payload* is not cached between preview and apply — the
|
||||
//! password the frontend passes to each call is what it already held for
|
||||
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||
//! keeps the plaintext itself — export/import secrets included — around for
|
||||
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||
//!
|
||||
//! **This is new attack surface**: a settings export is a file one person
|
||||
//! can hand another and ask them to import, together with a password, and
|
||||
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||
//! wholesale — see the module doc on `models::settings_export` for the
|
||||
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||
//! and treat that as the standing example of the class of thing to keep
|
||||
//! checking for here, not a one-off fixed bug.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{secure, settings_crypto};
|
||||
use crate::AppState;
|
||||
|
||||
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||
/// whether the file it's about to re-read is the same one the user actually
|
||||
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||
/// on what gets applied — without this, a file replaced on disk between the
|
||||
/// two calls (this app's own stated threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||
/// apply silently different content than what the confirmation dialog showed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingSettingsImport {
|
||||
path: PathBuf,
|
||||
ciphertext_hash: [u8; 32],
|
||||
}
|
||||
|
||||
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(data).into()
|
||||
}
|
||||
|
||||
const FILE_EXTENSION: &str = "triplec";
|
||||
|
||||
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||
/// attacker who can just try a three-character password directly.
|
||||
const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
||||
fn suggested_export_name() -> String {
|
||||
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||
// earlier file just because the save dialog defaults to the same name.
|
||||
format!(
|
||||
"triple-c-settings-{}.{}",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
FILE_EXTENSION
|
||||
)
|
||||
}
|
||||
|
||||
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Export Triple-C settings")
|
||||
.set_file_name(suggested)
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.save_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Import Triple-C settings")
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.pick_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||
/// export with the web-terminal token blanked out of it — see the module
|
||||
/// doc comment on `models::settings_export` for why that field cannot
|
||||
/// travel through `settings` like the rest of this struct.
|
||||
///
|
||||
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||
/// treated as "nothing to export" for that one entry rather than aborting
|
||||
/// the whole export, matching how the rest of this app degrades a keychain
|
||||
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||
/// rather than surfacing it as a hard failure.
|
||||
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||
let mut settings = current;
|
||||
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||
|
||||
let secrets = ExportedSecrets {
|
||||
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||
web_terminal_access_token,
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
/// rather than a "failed" toast for a plain cancel.
|
||||
#[tauri::command]
|
||||
pub async fn export_settings(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||
// close as this pair of languages allows to the frontend's `.length`
|
||||
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||
// characters, which no reasonable password touches.
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(format!(
|
||||
"Use a password of at least {} characters.",
|
||||
MIN_PASSWORD_LEN
|
||||
));
|
||||
}
|
||||
|
||||
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||
if secrets.is_empty() {
|
||||
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||
}
|
||||
|
||||
let payload = SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
};
|
||||
|
||||
let plaintext = Zeroizing::new(
|
||||
serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||
);
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||
|
||||
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||
/// preview (counts and presence flags only — never a secret value) for a
|
||||
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||
///
|
||||
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||
/// against — does **not** remember the decrypted payload itself, so the
|
||||
/// password must be supplied again to actually apply it — seeing the preview
|
||||
/// is not the same as committing to it. The hash exists so it also can't be
|
||||
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||
/// proceed if the file on disk no longer matches what was just previewed.
|
||||
#[tauri::command]
|
||||
pub async fn preview_settings_import(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<SettingsImportPreview>, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to open a settings export.".to_string());
|
||||
}
|
||||
|
||||
let Some(path) = pick_import_open_path(&window).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||
});
|
||||
|
||||
Ok(Some(preview))
|
||||
}
|
||||
|
||||
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||
/// required, not just encouraged, since it is the only place a user is told
|
||||
/// what an import is about to touch before it touches it. That requirement
|
||||
/// is only real if the file can't change out from under it, so this also
|
||||
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||
/// two calls (this feature's own threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) must not be able
|
||||
/// to apply silently different content than what the confirmation dialog
|
||||
/// showed.
|
||||
///
|
||||
/// Global settings are replaced wholesale — an import is "restore this
|
||||
/// environment," not a field-by-field merge. Global secrets are handled
|
||||
/// differently and on purpose: **only secrets actually present in the
|
||||
/// import are written**; a secret the export doesn't have is left alone on
|
||||
/// this machine rather than cleared, because an absent secret in the export
|
||||
/// means "the source machine never had this configured," not "delete this
|
||||
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||
/// for that (signing out of shared auth, clearing the gateway key).
|
||||
///
|
||||
/// Order matters here, twice over.
|
||||
///
|
||||
/// First: the imported settings are **validated before any secret is
|
||||
/// written**, using the same checks `update_settings` itself runs
|
||||
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||
/// host path used to be caught only when `update_settings` ran, by which
|
||||
/// point the three keychain secrets below were already overwritten with the
|
||||
/// file's, each with a fresh rotation id, silently flagging every project
|
||||
/// container for recreation — while the error the user saw talked only
|
||||
/// about the rejected setting and said nothing about the credentials that
|
||||
/// had already moved. Failing this check first makes a rejected import
|
||||
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||
/// mean.
|
||||
///
|
||||
/// Second, among the things that *do* get written: secrets are restored
|
||||
/// **before** the settings replace runs (which is what triggers
|
||||
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||
/// the final key material rather than racing it — restoring the other way
|
||||
/// round left a real window where the running gateway and the keychain
|
||||
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||
/// nudges a running gateway container to recreate itself whenever a secret
|
||||
/// this import carried was actually written — otherwise the running
|
||||
/// container keeps serving the old key material indefinitely while every
|
||||
/// project container is handed the new one.
|
||||
///
|
||||
/// A keychain write failing is reported back rather than only logged: an
|
||||
/// import that silently restores two of three secrets but not the third
|
||||
/// must not read as unqualified success.
|
||||
///
|
||||
/// The pending import is only cleared on success. A failure here (rejected
|
||||
/// by the validation above, a stale-file mismatch, or some other error)
|
||||
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||
/// making them pick the file and re-enter the password again — the
|
||||
/// preview's job was confirming *what* to import, not spending the one
|
||||
/// attempt at applying it.
|
||||
#[tauri::command]
|
||||
pub async fn apply_settings_import(
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SettingsImportOutcome, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to import settings.".to_string());
|
||||
}
|
||||
|
||||
let pending = state
|
||||
.pending_settings_import
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||
|
||||
let encrypted = std::fs::read(&pending.path)
|
||||
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||
return Err(
|
||||
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
|
||||
let current = state.settings_store.get();
|
||||
|
||||
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||
// the keychain, so "leave an absent secret alone" has to be done by
|
||||
// hand here: carry the destination's current token forward when the
|
||||
// import doesn't have one, instead of letting the wholesale replace
|
||||
// below blank it (every export writes `None` there — see
|
||||
// `split_settings_and_secrets`).
|
||||
let mut settings = payload.settings;
|
||||
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||
.or_else(|| current.web_terminal.access_token.clone());
|
||||
|
||||
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the shared Claude login: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||
match secure::store_gateway_api_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway provider API key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings.push(format!(
|
||||
"Could not restore the gateway provider API key: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||
match secure::store_gateway_master_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway master key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore the gateway master key: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
|
||||
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||
// *shape* — port, provider, base URL, models — because that's what's
|
||||
// rendered into the container's config. A secret changing with the shape
|
||||
// held constant is invisible to it, so a running gateway container would
|
||||
// otherwise keep serving the old key material forever after an import
|
||||
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||
// (which does include the secret rotation id) means the *next* unrelated
|
||||
// settings save would suddenly and confusingly recreate it instead.
|
||||
if gateway_secret_changed && saved.gateway.enabled {
|
||||
match crate::docker::gateway::gateway_container_presence().await {
|
||||
Ok((true, true)) => {
|
||||
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||
{
|
||||
log::error!(
|
||||
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||
}
|
||||
}
|
||||
|
||||
state.pending_settings_import.lock().await.take();
|
||||
|
||||
Ok(SettingsImportOutcome {
|
||||
settings: saved,
|
||||
secret_restore_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||
/// of the payload is even worth attempting to parse.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FormatVersionProbe {
|
||||
format_version: u32,
|
||||
}
|
||||
|
||||
/// Read and decrypt an export file at `path`, then parse it — see
|
||||
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||
/// exists now for tests that don't need that.
|
||||
#[cfg(test)]
|
||||
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let encrypted =
|
||||
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
read_and_decrypt_bytes(&encrypted, password)
|
||||
}
|
||||
|
||||
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||
/// format version **before** attempting to deserialize the full payload.
|
||||
///
|
||||
/// That ordering is not just tidiness: a version bump that isn't
|
||||
/// deserialize-compatible (a field's type changes, not just a new
|
||||
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||
/// before the version check ever ran, surfacing a raw parse error instead
|
||||
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||
/// errors quote the offending value inline. This file is not attacker
|
||||
/// content in the usual sense (it must still decrypt under the right
|
||||
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||
/// so neither error path below ever interpolates what `serde_json`
|
||||
/// actually says — only a fixed, generic message.
|
||||
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||
|
||||
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||
return Err(format!(
|
||||
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||
Update Triple-C before importing it.",
|
||||
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_blank_treats_whitespace_only_as_absent() {
|
||||
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||
assert_eq!(non_blank(Some("".to_string())), None);
|
||||
assert_eq!(non_blank(None), None);
|
||||
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||
// What `apply_settings_import` compares against the pinned hash from
|
||||
// `preview_settings_import` to detect a file swapped out from under a
|
||||
// pending import — this only defends anything if identical bytes
|
||||
// always hash identically and any change to those bytes changes the
|
||||
// hash.
|
||||
let bytes = b"pretend this is an encrypted export file";
|
||||
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||
|
||||
let mut tampered = bytes.to_vec();
|
||||
tampered[0] ^= 0xFF;
|
||||
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||
}
|
||||
|
||||
fn write_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
payload: &SettingsExportPayload,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||
}
|
||||
|
||||
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||
/// deliberately not shape-compatible, which the typed helper above can't
|
||||
/// produce at all.
|
||||
fn write_raw_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
let plaintext = serde_json::to_vec(value).unwrap();
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, &encrypted).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(
|
||||
secrets.web_terminal_access_token,
|
||||
Some("super-secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(secrets.web_terminal_access_token, None);
|
||||
}
|
||||
|
||||
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||
SettingsExportPayload {
|
||||
format_version,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings: AppSettings::default(),
|
||||
secrets: ExportedSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-settings-export-test-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||
// future version could easily have changed `settings` from an object
|
||||
// to something else) as well as newer — so this only passes under
|
||||
// the probe-first ordering. Parsing the full struct first (the old
|
||||
// behavior) would fail on the shape mismatch and never reach the
|
||||
// version check, producing the "unexpected shape" message instead of
|
||||
// "newer version" / "Update Triple-C".
|
||||
let dir = temp_dir("newer-format");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "9.9.9",
|
||||
"settings": "this-app-version-stores-settings-differently",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||
assert!(err.contains("Update Triple-C"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_at_the_current_format_is_accepted() {
|
||||
let dir = temp_dir("current-format");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||
// A `format_version` the probe accepts, but a `settings` field of
|
||||
// the wrong *type* rather than just a missing field — this is what
|
||||
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||
// struct AppSettings" error that quotes the offending value
|
||||
// verbatim. That value here stands in for plaintext that, in a real
|
||||
// export, could be a live credential — the assertion below is only
|
||||
// meaningful against a fixture that actually exercises serde's
|
||||
// value-quoting behavior, which a merely-missing-field fixture does
|
||||
// not.
|
||||
let dir = temp_dir("malformed");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "0.4.14",
|
||||
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(
|
||||
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||
"leaked plaintext into the error: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("doesn't look like a valid settings export"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||
let dir = temp_dir("wrong-password");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||
assert!(
|
||||
err.contains("Wrong password"),
|
||||
"unexpected message: {}",
|
||||
err
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
///
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||
/// WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
let settings = state.settings_store.get();
|
||||
build_claude_terminal_cmd(
|
||||
project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
session_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell line run immediately before `exec claude` in every Claude terminal
|
||||
/// session.
|
||||
///
|
||||
/// `container/entrypoint.sh` already runs `claude update` when the container
|
||||
/// starts, but containers here use a stop/start (and often just keep running)
|
||||
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
||||
/// it per session is what keeps a week-old container current.
|
||||
///
|
||||
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
||||
/// network, npm registry down) so a session always opens, and `timeout 60`
|
||||
/// bounds how long a user waits for a terminal.
|
||||
///
|
||||
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
||||
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
||||
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
||||
/// races two updaters against the same `~/.claude/bin` install, as does
|
||||
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
||||
/// behind a friendly message and the very next line (`exec claude`) would run
|
||||
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
||||
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
||||
/// other holder just updated, so there is nothing left to do.
|
||||
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
||||
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
||||
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
||||
);
|
||||
|
||||
/// Single-quote one argument for interpolation into a shell script string.
|
||||
fn shell_quote_arg(arg: &str) -> String {
|
||||
format!(" '{}'", arg.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||
/// profile rather than the whole [`AppState`].
|
||||
fn build_claude_terminal_cmd(
|
||||
project: &Project,
|
||||
global_aws_profile: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default();
|
||||
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
if let Some(name) = session_name {
|
||||
if !name.is_empty() {
|
||||
cmd.push("-n".to_string());
|
||||
cmd.push(name.to_string());
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||
.unwrap_or_default();
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one (same escaping style as name_flag above).
|
||||
let permission_flags: String = permission_args
|
||||
.iter()
|
||||
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
@@ -75,9 +114,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||
use crate::models::Project;
|
||||
|
||||
/// A dropped file must be named the way the *user* named it.
|
||||
///
|
||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||
@@ -338,6 +382,122 @@ mod tests {
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
/// A `Project` with only the fields these tests care about set; the rest
|
||||
/// come through serde so the test does not have to track every field.
|
||||
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "Test",
|
||||
"paths": [],
|
||||
"container_id": null,
|
||||
"status": "running",
|
||||
"backend": backend,
|
||||
"bedrock_config": bedrock_config,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"full_permissions": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"created_at": "now",
|
||||
"updated_at": "now"
|
||||
}))
|
||||
.expect("test project deserializes")
|
||||
}
|
||||
|
||||
/// Every Claude session updates the CLI before launching it.
|
||||
///
|
||||
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||
/// containers are long-lived, so a stale CLI is the normal case without
|
||||
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||
/// wrapper rather than a bare `claude` argv.
|
||||
#[test]
|
||||
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
assert_eq!(cmd[1], "-c");
|
||||
assert!(
|
||||
cmd[2].contains(UPDATE_PRELUDE),
|
||||
"plain path must run the update prelude: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||
// The update has to happen *before* the exec, which never returns.
|
||||
assert!(
|
||||
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(
|
||||
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||
"the update must stay time-bounded and non-fatal"
|
||||
);
|
||||
}
|
||||
|
||||
/// The session name is interpolated into a shell script, so a quote in it
|
||||
/// must not break out of its single-quoted argument.
|
||||
#[test]
|
||||
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("anthropic", serde_json::Value::Null),
|
||||
None,
|
||||
Some("Bob's tab; rm -rf /"),
|
||||
);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||
"session name must be single-quote escaped: {}",
|
||||
cmd[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Permission flags travel the same escaped path, and an empty name adds
|
||||
/// no `-n` at all.
|
||||
#[test]
|
||||
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||
"got: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||
}
|
||||
|
||||
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
||||
/// prelude, immediately before the exec.
|
||||
#[test]
|
||||
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("bedrock", serde_json::json!({
|
||||
"auth_method": "profile",
|
||||
"aws_region": "us-east-1",
|
||||
"aws_profile": "acme",
|
||||
"model_id": null,
|
||||
"disable_prompt_caching": false
|
||||
})),
|
||||
None,
|
||||
Some("it's fine"),
|
||||
);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
let script = &cmd[2];
|
||||
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
||||
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
||||
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
||||
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
||||
assert!(
|
||||
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
script
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
|
||||
/// container makes. The reaper's only handle on a leftover — see
|
||||
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
|
||||
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
|
||||
|
||||
/// The throwaway image a staleness probe of a **stopped** container commits to.
|
||||
///
|
||||
/// **Unique per call**, and both halves of the name earn their place: the
|
||||
/// container id prefix makes a leftover traceable in `docker images`, and the
|
||||
/// counter makes two overlapping probes independent.
|
||||
///
|
||||
/// An earlier version of this was deliberately *stable* per container, on the
|
||||
/// theory that the next probe would move the tag off an abandoned image and
|
||||
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
|
||||
/// over. A container id does not survive a recreate, so for most leftovers
|
||||
/// there is no "next probe of the same container" and the image was stranded
|
||||
/// permanently; and a stable name made two concurrent probes fight over one
|
||||
/// tag, where whichever finished first force-removed the image the other was
|
||||
/// still reading and turned a healthy project into a bogus `probe_error`.
|
||||
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
|
||||
/// is what collects the leftovers instead.
|
||||
pub fn get_probe_image_name(container_id: &str) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let short: String = container_id.chars().take(12).collect();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{}{}-{}-{}:latest",
|
||||
PROBE_IMAGE_PREFIX,
|
||||
short,
|
||||
nanos,
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Commit a **stopped** container's filesystem to a throwaway image, returning
|
||||
/// its name. The caller owns the image and must remove it.
|
||||
///
|
||||
/// This exists so a stopped project can be read at all. `docker exec` needs a
|
||||
/// running container and the snapshot image is not a checkpoint — see
|
||||
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
|
||||
/// this there is no way to see inside a project that is merely stopped.
|
||||
///
|
||||
/// ## Why it is tagged at all
|
||||
///
|
||||
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
|
||||
/// that `docker commit` copies off the container is exactly the pair
|
||||
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
|
||||
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
|
||||
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
|
||||
/// `commit_container` hands back `id: None` every time and there is no
|
||||
/// reference left to probe. Neither existing commit site notices, because both
|
||||
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
|
||||
///
|
||||
/// So the image needs a name, a tagged image is not dangling, and the sweep
|
||||
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
|
||||
/// is, and [`get_probe_image_name`] carries the rest of that argument.
|
||||
///
|
||||
/// ## What is in the image, and what is not
|
||||
///
|
||||
/// `pause: false` because nothing is running — pausing a stopped container is
|
||||
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
|
||||
/// passes `false`.
|
||||
///
|
||||
/// Secrets are blanked from the env for the same reason
|
||||
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
|
||||
/// full ENV into the image, and "it only lives a few seconds" is not a property
|
||||
/// this function can promise after a crash.
|
||||
///
|
||||
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
|
||||
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
|
||||
/// because a commit stacks a layer and never rewrites one — but that scrub is a
|
||||
/// `docker exec`, which is exactly what a stopped container cannot serve, and
|
||||
/// scrubbing is not wanted anyway: the probe's whole job is to report the
|
||||
/// filesystem as it actually is. What makes it acceptable is that this copies
|
||||
/// bytes that are *already on this disk* in the container's own writable layer,
|
||||
/// into an image that is never pushed, never created from, and reaped — so it
|
||||
/// duplicates data inside one trust domain rather than widening it. That
|
||||
/// argument depends on the reaping actually happening; treat
|
||||
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
|
||||
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_probe_image_name(container_id);
|
||||
let (repo, tag) = image_name
|
||||
.rsplit_once(':')
|
||||
.map(|(r, t)| (r.to_string(), t.to_string()))
|
||||
.expect("get_probe_image_name always emits a tag");
|
||||
|
||||
docker
|
||||
.commit_container(
|
||||
CommitContainerOptions {
|
||||
container: container_id.to_string(),
|
||||
repo,
|
||||
tag,
|
||||
pause: false,
|
||||
..Default::default()
|
||||
},
|
||||
Config::<String> {
|
||||
env: Some(blanked_secret_env()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
|
||||
|
||||
Ok(image_name)
|
||||
}
|
||||
|
||||
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
||||
/// name in [`SECRET_ENV_KEYS`].
|
||||
fn env_holds_a_secret(env: &[String]) -> bool {
|
||||
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||
}
|
||||
|
||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
||||
/// only has the image name (the project record is already gone by then).
|
||||
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
|
||||
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
|
||||
/// (which only has the image name — the project record is already gone by
|
||||
/// then), and the staleness probe's throwaway commit, which has only an id.
|
||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
log::info!("Removed image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
@@ -4464,6 +4577,29 @@ mod tests {
|
||||
assert!(env_holds_a_secret(&env));
|
||||
}
|
||||
|
||||
/// The probe image's name must be **unique per call**. A stable name was
|
||||
/// tried and is wrong twice over: a container id does not survive a
|
||||
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
|
||||
/// next probe of the same container"; and two concurrent probes sharing one
|
||||
/// tag means whichever finishes first force-removes the image the other is
|
||||
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
|
||||
#[test]
|
||||
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
|
||||
let id = "75993e6d5e1ab473b029a408c5ff0339";
|
||||
let a = get_probe_image_name(id);
|
||||
let b = get_probe_image_name(id);
|
||||
assert_ne!(a, b, "two probes of one container must not share a tag");
|
||||
|
||||
// The prefix is the reaper's only handle on a leftover, so every name
|
||||
// has to carry it — and it must not be the snapshot namespace, which is
|
||||
// what a project is rebuilt from.
|
||||
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
|
||||
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
|
||||
// Traceable back to its container, which is the point of the prefix.
|
||||
assert!(a.contains("75993e6d5e1a"), "{}", a);
|
||||
assert!(a.ends_with(":latest"), "{}", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
||||
let clean = SnapshotScrubReport {
|
||||
|
||||
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove throwaway images left behind by a staleness probe of a stopped
|
||||
/// container — [`super::container::commit_container_for_probe`]'s commits.
|
||||
///
|
||||
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
|
||||
/// gives no image id back from a commit and there has to be something to probe.
|
||||
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
|
||||
/// — which collects every other kind of orphan this app can leave — will never
|
||||
/// see one. Without this, a probe that dies between its commit and its own
|
||||
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
|
||||
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
|
||||
/// UI to find it either. That is the one leak in this app with no floor on it,
|
||||
/// so this runs at startup beside [`reap_probe_containers`].
|
||||
///
|
||||
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
|
||||
/// filter, so a second copy of the app probing a project on the same daemon has
|
||||
/// images matching this glob, and removing one mid-capture fails that probe with
|
||||
/// "No such image" — the bogus `probe_error` the staleness work exists to get
|
||||
/// rid of. In-process state cannot see the other instance, so age is the only
|
||||
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
|
||||
/// `find` over a root filesystem, not a multi-minute job.
|
||||
///
|
||||
/// Never fails the caller. Housekeeping, like every other sweep here.
|
||||
pub async fn reap_probe_images() {
|
||||
use bollard::image::{ListImagesOptions, RemoveImageOptions};
|
||||
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::warn!("Could not reap leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filters = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
|
||||
)]);
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for image in images {
|
||||
// Unlike a container summary, an image summary always carries a
|
||||
// `Created`, so there is no unknown-age case to defend against here.
|
||||
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
|
||||
log::info!(
|
||||
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
|
||||
to another Triple-C instance's live probe",
|
||||
image.repo_tags,
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// By **tag**, never by image id. A `force` removal by id untags an
|
||||
// image everywhere, so an id that happens to carry another name loses
|
||||
// that name too — which is how a test fixture that tagged
|
||||
// `alpine:latest` into this namespace deleted the user's alpine. A real
|
||||
// leftover has exactly the one probe tag, so removing the tag removes
|
||||
// the image; anything else keeps whatever other names it has.
|
||||
for tag in image
|
||||
.repo_tags
|
||||
.iter()
|
||||
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
|
||||
{
|
||||
log::info!("Removing leftover probe image {}", tag);
|
||||
if let Err(e) = docker
|
||||
.remove_image(
|
||||
tag,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
|
||||
Ok(parse_manifest(&out))
|
||||
}
|
||||
|
||||
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||
/// the container's `FinishedAt` at the time it was captured.
|
||||
///
|
||||
/// **Sound because a stopped container's writable layer cannot change.** Nothing
|
||||
/// can write to it while it is not running, so a manifest captured after it
|
||||
/// stopped stays true until it is started again — and `FinishedAt` moves on
|
||||
/// every stop, which is what makes the key exact rather than merely plausible.
|
||||
///
|
||||
/// This exists because `get_container_staleness` is called from a `useEffect`
|
||||
/// that fires whenever the container settles, so simply opening a stopped
|
||||
/// project's Overview probes it. Uncached that meant a `docker commit` of the
|
||||
/// whole writable layer per visit — measured at 44 s on a real project — where
|
||||
/// before this feature the same visit cost one throwaway container or nothing at
|
||||
/// all. A regression like that is not worth the answer it buys.
|
||||
///
|
||||
/// Capped, because a `Manifest` of a real container is a few MB: this only has
|
||||
/// to serve "the project whose page is open", so a handful of entries is the
|
||||
/// whole working set and the oldest is dropped past that.
|
||||
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
|
||||
Option<Vec<(String, String, Manifest)>>,
|
||||
> = std::sync::Mutex::new(None);
|
||||
|
||||
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
|
||||
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
|
||||
|
||||
/// `FinishedAt` for a container, the cache's validity token. `None` when it
|
||||
/// cannot be read, which is never treated as a hit.
|
||||
async fn container_finished_at(container_id: &str) -> Option<String> {
|
||||
let docker = get_docker().ok()?;
|
||||
docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()?
|
||||
.state?
|
||||
.finished_at
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
|
||||
/// when the container has not been started since it was taken.
|
||||
///
|
||||
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
|
||||
pub async fn manifest_from_stopped_container_cached(
|
||||
container_id: &str,
|
||||
) -> Result<Manifest, String> {
|
||||
let finished_at = container_finished_at(container_id).await;
|
||||
|
||||
if let Some(token) = &finished_at {
|
||||
let guard = STOPPED_MANIFEST_CACHE.lock();
|
||||
if let Ok(cache) = guard {
|
||||
if let Some(entries) = cache.as_ref() {
|
||||
if let Some((_, _, manifest)) = entries
|
||||
.iter()
|
||||
.find(|(id, tok, _)| id == container_id && tok == token)
|
||||
{
|
||||
log::debug!(
|
||||
"Reusing the cached manifest for stopped container {}",
|
||||
container_id
|
||||
);
|
||||
return Ok(manifest.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = manifest_from_stopped_container(container_id).await?;
|
||||
|
||||
// Only cacheable if the container's state could be read at all; an unknown
|
||||
// `FinishedAt` means there is no token that could later be compared.
|
||||
if let Some(token) = finished_at {
|
||||
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
|
||||
let entries = cache.get_or_insert_with(Vec::new);
|
||||
entries.retain(|(id, _, _)| id != container_id);
|
||||
entries.push((container_id.to_string(), token, manifest.clone()));
|
||||
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
|
||||
entries.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container.
|
||||
///
|
||||
/// Commits the container's writable layer to a throwaway image, probes that,
|
||||
/// and removes it. This is as current as [`manifest_from_container`] — it reads
|
||||
/// the same filesystem — and it is why a stopped project no longer has to fall
|
||||
/// back to its snapshot image, which may not exist at all and lags the
|
||||
/// container by everything installed since the last commit when it does.
|
||||
///
|
||||
/// The image is removed on every path, including a failed probe. See
|
||||
/// [`super::container::commit_container_for_probe`] for what a crash in the
|
||||
/// window between the two costs, and why it is bounded.
|
||||
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
|
||||
let image = super::container::commit_container_for_probe(container_id).await?;
|
||||
|
||||
let manifest = manifest_from_image(&image)
|
||||
.await
|
||||
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
|
||||
|
||||
if let Err(e) = super::container::remove_image_by_name(&image).await {
|
||||
log::warn!(
|
||||
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
|
||||
collects it at the next app start; the orphan sweep never will, because it is tagged",
|
||||
image,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
manifest
|
||||
}
|
||||
|
||||
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
|
||||
///
|
||||
/// Deliberately the **ID**, not a repo digest: locally built images and custom
|
||||
@@ -2146,4 +2353,258 @@ mod tests {
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
|
||||
// ── Live Docker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// The cache serves a second read of an unchanged stopped container, and —
|
||||
/// the half that matters — stops serving it the moment the container is
|
||||
/// started and stopped again. If invalidation were wrong this would report a
|
||||
/// filesystem the project no longer has, and a migration would be planned
|
||||
/// against it.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_manifest_cache
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let first = format!("/opt/cache-marker-a-{}", std::process::id());
|
||||
let second = format!("/opt/cache-marker-b-{}", std::process::id());
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 600",
|
||||
]);
|
||||
let cleanup = || {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", &id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let cold = manifest_from_stopped_container_cached(&id).await;
|
||||
let cold_ms = t0.elapsed().as_millis();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let warm = manifest_from_stopped_container_cached(&id).await;
|
||||
let warm_ms = t1.elapsed().as_millis();
|
||||
|
||||
// Restart, change the filesystem, stop again — `FinishedAt` moves.
|
||||
docker_cli(&["start", &id]);
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
let after_restart = manifest_from_stopped_container_cached(&id).await;
|
||||
|
||||
cleanup();
|
||||
|
||||
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
|
||||
|
||||
let cold = cold.expect("cold read");
|
||||
let warm = warm.expect("warm read");
|
||||
let after_restart = after_restart.expect("read after restart");
|
||||
|
||||
assert!(has(&cold, &first), "cold read missed {}", first);
|
||||
assert!(has(&warm, &first), "warm read missed {}", first);
|
||||
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
|
||||
assert!(
|
||||
warm_ms * 5 < cold_ms.max(5),
|
||||
"the second read cost {} ms against a cold {} ms — it re-committed \
|
||||
instead of using the cache",
|
||||
warm_ms,
|
||||
cold_ms
|
||||
);
|
||||
|
||||
// The restart must have invalidated it: the new directory has to show up.
|
||||
assert!(
|
||||
has(&after_restart, &second),
|
||||
"a restart did not invalidate the cache — {} is missing, so this is \
|
||||
a stale manifest of a filesystem the container no longer has",
|
||||
second
|
||||
);
|
||||
assert!(has(&after_restart, &first), "the restart lost {}", first);
|
||||
}
|
||||
|
||||
/// The reaper finds a leftover probe image by prefix and — crucially —
|
||||
/// refuses to remove a young one, because that image may be another
|
||||
/// Triple-C instance's live probe. Only a real daemon can say whether the
|
||||
/// `reference=` glob matches the names `get_probe_image_name` produces.
|
||||
///
|
||||
/// The fixture is **committed**, not tagged and not built. An image's
|
||||
/// `Created` is its own, not its tag's, so tagging something already on disk
|
||||
/// into this namespace yields a fixture the reaper is right to call ancient
|
||||
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
|
||||
/// one looks ancient too. A commit stamps *now*, verified against Engine
|
||||
/// 29.6, which is also how real probe images get their age.
|
||||
///
|
||||
/// Both of those mistakes were made here first, and one of them deleted an
|
||||
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
|
||||
/// tag rather than by image id.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture reaper_spares
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
|
||||
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
|
||||
use std::process::Command;
|
||||
|
||||
fn docker_out(args: &[&str]) -> std::process::Output {
|
||||
Command::new("docker").args(args).output().expect("docker CLI")
|
||||
}
|
||||
|
||||
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "alpine:latest".to_string());
|
||||
let name = crate::docker::container::get_probe_image_name("reapertest01234");
|
||||
|
||||
// A never-started container is enough to commit from, and leaves the
|
||||
// daemon's run state alone entirely.
|
||||
let created = docker_out(&["create", &base, "true"]);
|
||||
assert!(
|
||||
created.status.success(),
|
||||
"could not create the fixture container from {}: {}",
|
||||
base,
|
||||
String::from_utf8_lossy(&created.stderr)
|
||||
);
|
||||
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
|
||||
|
||||
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
|
||||
let _ = docker_out(&["rm", "-f", &cid]);
|
||||
assert!(
|
||||
committed.status.success(),
|
||||
"could not commit the fixture image: {}",
|
||||
String::from_utf8_lossy(&committed.stderr)
|
||||
);
|
||||
|
||||
reap_probe_images().await;
|
||||
|
||||
let still_there = Command::new("docker")
|
||||
.args(["image", "inspect", &name])
|
||||
.output()
|
||||
.expect("docker image inspect")
|
||||
.status
|
||||
.success();
|
||||
|
||||
let _ = Command::new("docker").args(["rmi", &name]).output();
|
||||
|
||||
assert!(
|
||||
still_there,
|
||||
"a probe image committed seconds ago was reaped — that is another \
|
||||
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
|
||||
);
|
||||
}
|
||||
|
||||
/// A *stopped* container is readable, and what comes back is its writable
|
||||
/// layer rather than the image it was created from. This is the whole point
|
||||
/// of the function: the base image cannot answer it, and the project may
|
||||
/// well have no snapshot image at all.
|
||||
///
|
||||
/// Also asserts the throwaway commit leaves nothing behind, which no unit
|
||||
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
|
||||
/// the probe image is *tagged*, so a leak never shows up as a dangling
|
||||
/// image and a dangling-set assertion here would pass either way.
|
||||
///
|
||||
/// Ignored because it needs Docker and commits a container; run it with
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_container
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn a_stopped_container_is_read_from_its_writable_layer() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
fn probe_images() -> Vec<String> {
|
||||
let mut ids: Vec<String> = docker_cli(&[
|
||||
"images", "-q",
|
||||
"--filter",
|
||||
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
|
||||
])
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
|
||||
let marker = format!("/opt/probe-marker-{}", std::process::id());
|
||||
|
||||
// Another instance's live probe images are allowed to exist; what must
|
||||
// hold is that this probe adds none of its own.
|
||||
let before = probe_images();
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 300",
|
||||
]);
|
||||
let cleanup = |id: &str| {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let result = manifest_from_stopped_container(&id).await;
|
||||
|
||||
cleanup(&id);
|
||||
|
||||
let manifest = result.expect("a stopped container must be probeable");
|
||||
assert!(
|
||||
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
|
||||
"the probe read the image, not the container's writable layer: {} missing",
|
||||
marker
|
||||
);
|
||||
// Non-empty package sets prove the probe script really ran, rather than
|
||||
// parsing an empty transcript into an empty-but-Ok manifest.
|
||||
assert!(
|
||||
!manifest.apt_manual.is_empty(),
|
||||
"apt-mark showmanual came back empty, so the probe did not run"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
probe_images(),
|
||||
before,
|
||||
"the throwaway probe image was not cleaned up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod logging;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod url_open;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -29,6 +30,21 @@ pub struct AppState {
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
/// The file `preview_settings_import` last decrypted successfully, held
|
||||
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||
/// without the frontend ever passing a host path back to Rust as an
|
||||
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||
/// for why that direction specifically is the one this app treats as
|
||||
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||
/// nothing here holds a decrypted secret in memory for longer than one
|
||||
/// command's execution.
|
||||
///
|
||||
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||
/// changed underneath the pending import — otherwise confirming a
|
||||
/// preview is not actually binding on what gets applied.
|
||||
pub pending_settings_import:
|
||||
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -222,6 +238,7 @@ pub fn run() {
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
lifecycle,
|
||||
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
@@ -247,12 +264,20 @@ pub fn run() {
|
||||
// logged warning rather than a failed start.
|
||||
//
|
||||
// Ordering matters. Probes are removed first because a probe holds
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// an image open and the sweep will not force — both the probe
|
||||
// containers and the probe images, the latter being the one orphan
|
||||
// the sweep can never reach on its own; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
// Probe *images* too, and for a sharper reason: a probe
|
||||
// container merely pins an image the sweep then refuses to
|
||||
// touch, whereas a leftover probe image is tagged and so
|
||||
// nothing else in this app can ever collect it. See
|
||||
// `reap_probe_images`.
|
||||
crate::docker::reap_probe_images().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
@@ -454,6 +479,10 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Notes
|
||||
commands::notes_commands::list_notes,
|
||||
commands::notes_commands::save_note,
|
||||
commands::notes_commands::delete_note,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
@@ -494,6 +523,10 @@ pub fn run() {
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
commands::settings_export_commands::preview_settings_import,
|
||||
commands::settings_export_commands::apply_settings_import,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
@@ -520,6 +553,9 @@ pub fn run() {
|
||||
commands::update_commands::check_image_update,
|
||||
// Help
|
||||
commands::help_commands::get_help_content,
|
||||
// Opening a link in the host browser (see `url_open` for why this
|
||||
// is not `@tauri-apps/plugin-opener` on Linux)
|
||||
url_open::open_url_external,
|
||||
// Install helper
|
||||
commands::install_helper_commands::detect_install_options,
|
||||
commands::install_helper_commands::run_docker_install,
|
||||
@@ -902,7 +938,6 @@ mod tests {
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
|
||||
+115
-12
@@ -3,10 +3,19 @@
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
/// 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
|
||||
@@ -26,12 +35,27 @@
|
||||
/// 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. That includes
|
||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||
/// safe direction to be wrong in.
|
||||
/// 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`
|
||||
@@ -39,14 +63,93 @@
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
///
|
||||
/// That leak is now plugged rather than merely documented: `url_open` hands
|
||||
/// the opener a child environment with this variable (and the AppImage's own
|
||||
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
|
||||
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
|
||||
/// children no longer inherit it.
|
||||
#[cfg(target_os = "linux")]
|
||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||
/// already set to. Split from the mutation so it can be tested without
|
||||
/// touching process-wide environment state from a parallel test runner.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DmabufAction {
|
||||
/// Not set by the user — apply the workaround.
|
||||
Disable,
|
||||
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||
/// way to express "enabled" is for the variable not to exist.
|
||||
Remove,
|
||||
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||
LeaveAlone,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||
match current {
|
||||
None => DmabufAction::Disable,
|
||||
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||
_ => DmabufAction::LeaveAlone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
let current = std::env::var(DMABUF_VAR).ok();
|
||||
match dmabuf_action(current.as_deref()) {
|
||||
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||
DmabufAction::LeaveAlone => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::{dmabuf_action, DmabufAction};
|
||||
|
||||
#[test]
|
||||
fn unset_gets_the_workaround() {
|
||||
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||
// The bug this replaces: these all previously read as "user set it,
|
||||
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||
// it only checks presence. There was no way to ask for the GPU path.
|
||||
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::Remove,
|
||||
"{value:?} should opt out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_values_are_left_alone() {
|
||||
for value in ["1", "true", "yes", "anything"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::LeaveAlone,
|
||||
"{value:?} should be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||
// environment this app was started with, and the workaround below is one
|
||||
// of the things that must not leak into it (see triple-c#34). Anything
|
||||
// added here that mutates the environment belongs after this line.
|
||||
triple_c_lib::url_open::capture_pristine_environment();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
|
||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||
///
|
||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||
/// The addon therefore loads successfully and then renders every frame on
|
||||
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||
/// disabled on Linux.
|
||||
///
|
||||
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||
/// the zero value has to mean "we choose", not "off", or every existing
|
||||
/// settings file would silently pin the answer at whatever the default was
|
||||
/// the day it was written.
|
||||
#[serde(default)]
|
||||
pub terminal_gpu_rendering: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
pub mod project;
|
||||
pub mod settings_export;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
pub use settings_export::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||
/// session.
|
||||
///
|
||||
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||
/// classification decision at writing time is one the user is least willing to
|
||||
/// make, and it would turn one pane into two features.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Pinned notes sort first, then by `updated_at` descending.
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(title: String, body: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title,
|
||||
body,
|
||||
pinned: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `auth_bridge_enabled` defaults to **on**, and the default is what makes
|
||||
/// `claude login` work at all.
|
||||
///
|
||||
/// The login flow binds a *random* ephemeral loopback port inside the
|
||||
/// container and then sends the host's browser to `127.0.0.1:<that port>`.
|
||||
/// On the host nothing is listening there, so the callback lands on a closed
|
||||
/// port and the CLI waits for a redirect that can never arrive. The bridge
|
||||
/// mirrors the container's loopback listeners onto the same host port, which
|
||||
/// is the only thing that closes that loop — so off-by-default made a hang the
|
||||
/// out-of-the-box experience.
|
||||
///
|
||||
/// Returning `true` from a `#[serde(default)]` helper (rather than flipping the
|
||||
/// constructor alone) is deliberate: existing `projects.json` records were
|
||||
/// written before this field existed, or while it was off, and an absent key is
|
||||
/// what the default is read for. A project that wants the old behaviour turns
|
||||
/// the toggle off, which persists an explicit `false`.
|
||||
fn default_auth_bridge_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How much autonomy Claude Code is granted inside the container.
|
||||
///
|
||||
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||
@@ -336,17 +356,30 @@ pub struct Project {
|
||||
pub sandbox_mode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// The auth bridge: while the container runs, its loopback listeners are
|
||||
/// mirrored onto the host's loopback so browser OAuth callbacks
|
||||
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// Purely host-side — it deliberately has no container-recreation label,
|
||||
/// because toggling it changes nothing about the container itself.
|
||||
#[serde(default)]
|
||||
///
|
||||
/// **On by default**, and opt-*out* rather than opt-in — see
|
||||
/// [`default_auth_bridge_enabled`] for why the default is the feature.
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
pub auth_bridge_enabled: bool,
|
||||
/// Opt in to the browser-view pane, which watches and takes over the
|
||||
/// browser Claude drives with Playwright inside the container. Purely
|
||||
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||
/// container-recreation label.
|
||||
///
|
||||
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
|
||||
/// rather than keeping its own copy, so the pane comes back the way it was
|
||||
/// left. Off by default, and unlike the auth bridge it stays that way — a
|
||||
/// view costs a container exec, a Node daemon and a host port, and a
|
||||
/// container without Playwright cannot serve one at all.
|
||||
///
|
||||
/// Durable does **not** mean auto-started: nothing brings a viewer up on
|
||||
/// app start, so a project left enabled reports `enabled` with a state of
|
||||
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
|
||||
#[serde(default)]
|
||||
pub browser_view_enabled: bool,
|
||||
/// Grant the container what a VPN client needs to build a tunnel:
|
||||
@@ -639,7 +672,7 @@ impl Project {
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: false,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
auth_bridge_enabled: default_auth_bridge_enabled(),
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: default_use_shared_auth_token(),
|
||||
@@ -885,4 +918,69 @@ mod tests {
|
||||
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(round_tripped, partial);
|
||||
}
|
||||
|
||||
// ── The host-side per-project toggles ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
|
||||
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
|
||||
// record written by a shipped binary and has no `auth_bridge_enabled`
|
||||
// key at all. Without this, every existing project keeps hanging on
|
||||
// `claude login` until its owner finds the toggle.
|
||||
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
assert!(project.auth_bridge_enabled);
|
||||
|
||||
// The browser view is the other way round and must stay so: it costs a
|
||||
// Node daemon, a container exec loop and a host port, and most
|
||||
// containers have no Playwright to serve it with.
|
||||
assert!(!project.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_auth_bridge_off_survives_the_default() {
|
||||
// Opt-out has to be expressible, or the toggle does nothing across a
|
||||
// restart. An explicit `false` in the file beats the default.
|
||||
let json = r#"{ "auth_bridge_enabled": false }"#;
|
||||
#[derive(Deserialize)]
|
||||
struct JustTheFlag {
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
auth_bridge_enabled: bool,
|
||||
}
|
||||
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
|
||||
assert!(!parsed.auth_bridge_enabled);
|
||||
|
||||
// And a saved project always writes the key, so the choice is pinned
|
||||
// rather than re-defaulted on the next load.
|
||||
let mut p = Project::new("demo".to_string(), Vec::new());
|
||||
p.auth_bridge_enabled = false;
|
||||
let round_tripped: Project =
|
||||
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert!(!round_tripped.auth_bridge_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
assert!(p.auth_bridge_enabled);
|
||||
assert!(!p.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
|
||||
// `ProjectsStore::new` runs every record through this before
|
||||
// deserialising. If it inserted either key — even as `false` — the
|
||||
// serde default above would never be consulted for an existing project
|
||||
// and this change would be a no-op on exactly the projects it is for.
|
||||
let legacy = serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"path": "/home/u/demo",
|
||||
});
|
||||
let migrated = Project::migrate_from_value(legacy);
|
||||
let obj = migrated.as_object().unwrap();
|
||||
assert!(obj.contains_key("paths"), "the migration should still do its own job");
|
||||
assert!(!obj.contains_key("auth_bridge_enabled"));
|
||||
assert!(!obj.contains_key("browser_view_enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod migration_store;
|
||||
pub mod notes_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_crypto;
|
||||
pub mod settings_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Host-side persistence for per-project notes.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||
//! free-function shape as `migration_store` — no struct, nothing in
|
||||
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||
//! caches the project list; a store that reads and writes the file per call
|
||||
//! has nothing to cache and nothing to guard.
|
||||
//!
|
||||
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||
//! there would mean the whole project list is rewritten per edit, and a note
|
||||
//! save racing a Config save would silently drop one of them.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Note;
|
||||
|
||||
/// The version stamped into every notes file this build writes.
|
||||
const NOTES_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// What is actually on disk: a version envelope around the notes.
|
||||
///
|
||||
/// The list is wrapped rather than written bare because the wrapper costs
|
||||
/// nothing today and cannot be added cheaply later — once files exist in the
|
||||
/// field, every reader has to sniff two shapes forever. `version` is written
|
||||
/// and read back but nothing branches on it yet: it is the hook a future
|
||||
/// format change hangs off, and its value is only useful if it has been there
|
||||
/// since the first file.
|
||||
///
|
||||
/// Not in `models/` and not exposed over IPC: the frontend receives
|
||||
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
|
||||
/// storage detail rather than part of the IPC contract.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ProjectNotes {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
notes: Vec<Note>,
|
||||
}
|
||||
|
||||
/// Serialises the read-modify-write half of an upsert or delete.
|
||||
///
|
||||
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||
/// those interleaving would lose whichever note was written first. The read
|
||||
/// path does not take it.
|
||||
fn write_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||
pub fn notes_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the notes directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||
|
||||
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||
load_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||
upsert_in(¬es_dir()?, project_id, note)
|
||||
}
|
||||
|
||||
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
delete_in(¬es_dir()?, project_id, note_id)
|
||||
}
|
||||
|
||||
/// Remove a project's notes file entirely. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
// ── Implementation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a project's notes. A missing file is an empty list.
|
||||
///
|
||||
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||
/// that project with no way out through the UI; deleting instead would destroy
|
||||
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||
/// corruption cannot overwrite the first — which is the one taken before
|
||||
/// anything rewrote the file, and therefore the one worth having — and capped,
|
||||
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
|
||||
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||
match parse(&data) {
|
||||
Ok(notes) => Ok(notes),
|
||||
Err(e) => {
|
||||
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||
log::error!(
|
||||
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||
left in place{}",
|
||||
project_id,
|
||||
e,
|
||||
kept.describe()
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a notes file: the versioned envelope, or a bare array.
|
||||
///
|
||||
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
|
||||
/// only ever on a development build, but a developer's own notes are still
|
||||
/// prose nothing else holds a copy of, and the alternative is `load_in`
|
||||
/// declaring a perfectly readable file corrupt. It is read, never written: the
|
||||
/// first save rewrites the file with an envelope.
|
||||
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
|
||||
match serde_json::from_str::<ProjectNotes>(data) {
|
||||
Ok(file) => Ok(file.notes),
|
||||
// Report the envelope's error, not the array's — the envelope is the
|
||||
// shape this store writes, so its message is the one that describes
|
||||
// what is actually wrong with the file.
|
||||
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt notes file are kept.
|
||||
///
|
||||
/// Timestamping fixes "a second corruption overwrote the first" and introduces
|
||||
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
|
||||
/// mount — every project switch, every dock-follows-tab change, every sub-tab
|
||||
/// toggle. A file that is *persistently* unparseable (the normal case, since
|
||||
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
|
||||
/// prose every time the clock's second changed. Nothing ever reads them back
|
||||
/// and nothing ever removed them.
|
||||
///
|
||||
/// Four is enough for the only use there is: a human looking at what the file
|
||||
/// held. Same constant, same reasoning as `migration_store`.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
|
||||
/// whether a file exists.
|
||||
///
|
||||
/// Three outcomes, and they must not be conflated. Folding "already kept
|
||||
/// enough" into success and then saying "a copy was kept" names a file that
|
||||
/// was never created — which is what someone reads before going to look for
|
||||
/// their data.
|
||||
enum Kept {
|
||||
Copied(PathBuf),
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere(PathBuf),
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
fn describe(&self) -> String {
|
||||
match self {
|
||||
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.display()),
|
||||
// The earliest copies are the ones worth having, so the cap keeps
|
||||
// those and drops this one. Say so, rather than implying a file
|
||||
// exists.
|
||||
Kept::EnoughAlready(n) => format!(
|
||||
" (no copy kept — {} earlier copies of this file are already saved alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a copy of an unreadable notes file is kept.
|
||||
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file already exist.
|
||||
///
|
||||
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||
/// implemented by writing a file and deleting it again on every pass — and so
|
||||
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||
/// whatever produced the corruption.
|
||||
///
|
||||
/// A directory that cannot be listed answers "not full": failing open costs at
|
||||
/// most one extra file, and failing closed would drop the very first copy of
|
||||
/// prose nothing else has kept.
|
||||
fn corrupt_backups_full(path: &Path) -> bool {
|
||||
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||
return false;
|
||||
};
|
||||
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||
// this can never match another project's copies or an unrelated `.bak`.
|
||||
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||
})
|
||||
.count()
|
||||
>= MAX_CORRUPT_BACKUPS
|
||||
}
|
||||
|
||||
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
|
||||
let backup = corrupt_backup_path(path, now);
|
||||
if backup.exists() {
|
||||
return Kept::AlreadyThere(backup);
|
||||
}
|
||||
if corrupt_backups_full(path) {
|
||||
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
|
||||
}
|
||||
match fs::copy(path, &backup) {
|
||||
Ok(_) => Kept::Copied(backup),
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace one note, leaving the rest untouched.
|
||||
///
|
||||
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||
/// `updated_at` is stamped here for the same reason.
|
||||
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||
Some(existing) => {
|
||||
note.created_at = existing.created_at.clone();
|
||||
*existing = note.clone();
|
||||
}
|
||||
None => notes.push(note.clone()),
|
||||
}
|
||||
save_all(dir, project_id, ¬es)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||
/// retry a delete whose result it never saw.
|
||||
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
let before = notes.len();
|
||||
notes.retain(|n| n.id != note_id);
|
||||
if notes.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
save_all(dir, project_id, ¬es)
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = notes_path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove notes: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write the whole list.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||
/// applied and the data not written — a truncated file, produced by the code
|
||||
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||
/// the directory after it, since the rename is directory metadata. Notes are
|
||||
/// prose the user typed and nothing else holds a copy.
|
||||
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
let file = ProjectNotes {
|
||||
version: NOTES_FORMAT_VERSION,
|
||||
notes: notes.to_vec(),
|
||||
};
|
||||
let data = serde_json::to_string_pretty(&file)
|
||||
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file =
|
||||
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||
///
|
||||
/// Best effort only where it is meaningless: Windows has no directory handle
|
||||
/// to sync and returns an error for the attempt, so a failure is logged rather
|
||||
/// than propagated. The file's own `sync_all` carries the data and is not best
|
||||
/// effort.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
log::debug!(
|
||||
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-notes-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.contains(".corrupt-"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_notes_directory() {
|
||||
// The id arrives over IPC. It must not be able to steer the write.
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize("a\\b"), "a_b");
|
||||
// A real UUID must survive untouched, or every note file would move
|
||||
// the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||
let dir = temp_dir("missing");
|
||||
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upserted_note_round_trips() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
assert_eq!(saved.id, note.id);
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||
let dir = temp_dir("replace");
|
||||
let mut note = Note::new("Title".into(), "first".into());
|
||||
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
note.body = "second".into();
|
||||
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||
assert_eq!(loaded[0].body, "second");
|
||||
assert_ne!(
|
||||
saved.created_at, "1999-01-01T00:00:00Z",
|
||||
"created_at is owned by the store, not by whatever the webview sent"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||
let dir = temp_dir("delete");
|
||||
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
|
||||
// Idempotent: removing what is already gone is not an error, because
|
||||
// the UI can retry a delete it never saw the result of.
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||
// Same reasoning as migration_store: a corrupt file must not make the
|
||||
// tab permanently unusable, and the bytes must not be destroyed.
|
||||
let dir = temp_dir("corrupt");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||
assert!(path.exists(), "the unreadable file is left in place");
|
||||
|
||||
assert_eq!(
|
||||
corrupt_copies(&dir).len(),
|
||||
1,
|
||||
"the bytes must be kept exactly once"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
|
||||
// The envelope costs nothing now and cannot be added cheaply once
|
||||
// files exist in the field, so the very first file has to carry it.
|
||||
let dir = temp_dir("envelope");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
|
||||
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["notes"][0]["body"], "b");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
|
||||
// Only a development build ever wrote this shape, but declaring a
|
||||
// perfectly readable file corrupt is the one outcome this store exists
|
||||
// to avoid. It is read, never written back.
|
||||
let dir = temp_dir("legacy");
|
||||
let note = Note::new("Deploy".into(), "one\ntwo".into());
|
||||
std::fs::write(
|
||||
notes_path_in(&dir, "p1"),
|
||||
serde_json::to_string(&vec![note.clone()]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
let copies = corrupt_copies(&dir);
|
||||
assert!(copies.is_empty(), "a readable file must not be copied aside");
|
||||
|
||||
// The next write upgrades it in place.
|
||||
upsert_in(&dir, "p1", note).unwrap();
|
||||
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||
assert!(raw.contains("\"version\""));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_are_capped_rather_than_one_per_second() {
|
||||
// `list_notes` runs on every panel mount, so an unrepaired file would
|
||||
// otherwise mint a full copy of the user's prose every time the
|
||||
// clock's second changed.
|
||||
let dir = temp_dir("cap");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let base = chrono::Utc::now();
|
||||
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
|
||||
let at = base + chrono::Duration::seconds(i);
|
||||
let kept = keep_corrupt_copy(&path, &at);
|
||||
if i < MAX_CORRUPT_BACKUPS as i64 {
|
||||
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
|
||||
"copy {} should be refused by the cap",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_read_in_the_same_second_does_not_re_copy() {
|
||||
let dir = temp_dir("samesecond");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let at = chrono::Utc::now();
|
||||
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
|
||||
assert!(matches!(
|
||||
keep_corrupt_copy(&path, &at),
|
||||
Kept::AlreadyThere(_)
|
||||
));
|
||||
assert_eq!(corrupt_copies(&dir).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_line_never_claims_a_backup_that_was_not_written() {
|
||||
// A message that invents a backup is worse than no message: it is what
|
||||
// someone reads before going to look for their data.
|
||||
let dir = temp_dir("honesty");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
|
||||
assert!(copied.contains("a copy is at"));
|
||||
|
||||
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
|
||||
assert!(refused.contains("no copy kept"));
|
||||
assert!(!refused.contains("a copy is at"));
|
||||
|
||||
let failed = Kept::Failed("permission denied".into()).describe();
|
||||
assert!(failed.contains("could not keep a copy"));
|
||||
assert!(!failed.contains("a copy is at"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_leaves_no_temp_file_behind() {
|
||||
let dir = temp_dir("tmp");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||
let dir = temp_dir("clear");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
assert!(notes_path_in(&dir, "p1").exists());
|
||||
|
||||
clear_in(&dir, "p1").unwrap();
|
||||
assert!(!notes_path_in(&dir, "p1").exists());
|
||||
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||
// `remove_project` must not be able to fail because a project simply
|
||||
// never had any notes — an orphaned notes file is harmless, a project
|
||||
// that cannot be removed is not.
|
||||
let dir = temp_dir("removal");
|
||||
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,21 @@ impl ProjectsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Granular setter for the browser view's opt-in, for the same reason
|
||||
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
|
||||
/// the Config tab may be holding an older copy of the whole record.
|
||||
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
p.browser_view_enabled = enabled;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Project {} not found", project_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
@@ -338,4 +353,61 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
|
||||
/// is the real user's; the fields are right here, so the granular setters
|
||||
/// can be exercised against a directory the test owns.
|
||||
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
|
||||
ProjectsStore {
|
||||
projects: Mutex::new(projects),
|
||||
file_path: dir.join("projects.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
|
||||
// The point of the whole exercise: before this the flag lived in a
|
||||
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
|
||||
let dir = temp_dir("browser-view");
|
||||
let project = Project::new("demo".to_string(), Vec::new());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
assert!(store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
// Durable, not merely in memory — this is what a restart reads.
|
||||
let on_disk: Vec<Project> =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
|
||||
assert!(on_disk[0].browser_view_enabled);
|
||||
|
||||
store.set_browser_view_enabled(&id, false).unwrap();
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_granular_toggle_leaves_every_other_field_alone() {
|
||||
// Why these setters exist at all: the Config tab can be holding an
|
||||
// older copy of the whole record while the pane flips one flag.
|
||||
let dir = temp_dir("granular");
|
||||
let mut project = Project::new("demo".to_string(), Vec::new());
|
||||
project.claude_instructions = Some("keep me".to_string());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
store.set_auth_bridge_enabled(&id, false).unwrap();
|
||||
|
||||
let saved = store.get(&id).unwrap();
|
||||
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
|
||||
assert!(saved.browser_view_enabled);
|
||||
assert!(!saved.auth_bridge_enabled);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
if let Some(existing) = get_gateway_master_key()? {
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Read the gateway master key without minting one if none exists yet.
|
||||
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||
/// effect the read half of that function must not have — settings export
|
||||
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||
/// one exists".
|
||||
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||
.filter(|k| !k.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
store_gateway_master_key(&key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an exact given gateway master key, replacing any previous one.
|
||||
///
|
||||
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||
/// restoring the *same* key an export captured is the point — projects on
|
||||
/// the destination machine may not exist yet, but a project migrated or
|
||||
/// re-added later that still has the old key pasted into its config must
|
||||
/// keep working against it. Blank input is rejected rather than silently
|
||||
/// stored, matching every other `store_*` function in this module.
|
||||
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Password-based encryption for the settings export/import file — see
|
||||
//! triple-c#35.
|
||||
//!
|
||||
//! The exported payload can carry live credentials (the shared Claude OAuth
|
||||
//! token, the gateway provider/master keys — see
|
||||
//! `commands::settings_export_commands`), so this is not encryption for its
|
||||
//! own sake; a wrong or missing key here is a real credential leak, not a
|
||||
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
|
||||
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
|
||||
//! any reasonable iteration count is not), and AES-256-GCM is what actually
|
||||
//! encrypts — authenticated, so a wrong password is detected by a failed tag
|
||||
//! check rather than producing silent garbage.
|
||||
//!
|
||||
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
|
||||
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
|
||||
//! the clear right here, on purpose. The salt's only job is to make two
|
||||
//! exports with the same password derive different keys (defeats a
|
||||
//! precomputed-table attack against the password alone); the nonce's job is
|
||||
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
|
||||
//! because a fresh random value is drawn for each, on every call to
|
||||
//! [`encrypt`].
|
||||
//!
|
||||
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
|
||||
//! associated data, not just placed alongside the ciphertext — free to do,
|
||||
//! and it makes tampering with any header byte fail the same authentication
|
||||
//! check the ciphertext gets, by construction rather than as a side effect
|
||||
//! of the salt/nonce also feeding key derivation and the cipher.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Identifies the file as a Triple-C settings export and pins the format —
|
||||
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
|
||||
/// new magic value, not a silent reinterpretation of old bytes.
|
||||
const MAGIC: &[u8; 4] = b"TCX1";
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
|
||||
|
||||
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
|
||||
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
|
||||
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
|
||||
/// this runs once per export/import rather than on every request, so trading
|
||||
/// roughly a second of wall time for real brute-force resistance costs
|
||||
/// nothing a user would notice.
|
||||
fn argon2_params() -> Params {
|
||||
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
|
||||
}
|
||||
|
||||
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
|
||||
/// when it drops rather than left in freed memory for whatever reuses that
|
||||
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
|
||||
/// tree via `aes-gcm`) for material that exists only to decrypt live
|
||||
/// credentials.
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
|
||||
let mut key = Zeroizing::new([0u8; KEY_LEN]);
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
|
||||
/// file's bytes (header + ciphertext) — see the module doc for the layout.
|
||||
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
let key = derive_key(password, &salt)?;
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut header = Vec::with_capacity(HEADER_LEN);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.extend_from_slice(&salt);
|
||||
header.extend_from_slice(&nonce_bytes);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
// The header (magic + salt + nonce) is authenticated as associated data
|
||||
// even though none of it is secret: it costs nothing extra here, and it
|
||||
// means tampering with any header byte is caught by the same tag check
|
||||
// that already covers the ciphertext, by construction rather than as a
|
||||
// side effect of the header also feeding key/nonce derivation.
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut out = header;
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
|
||||
/// wrong password is deliberately generic ("wrong password, or the file is
|
||||
/// corrupted") rather than distinguishing the two: GCM's authentication tag
|
||||
/// fails to verify for the wrong key on essentially any ciphertext, so there
|
||||
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
|
||||
/// and guessing would be worse than saying so.
|
||||
///
|
||||
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
|
||||
/// this recovers is the whole settings-plus-secrets payload, so it gets the
|
||||
/// same "wipe it when it drops" treatment as the derived key in
|
||||
/// [`derive_key`].
|
||||
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
|
||||
}
|
||||
if &data[..MAGIC.len()] != MAGIC {
|
||||
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
|
||||
}
|
||||
let header = &data[..HEADER_LEN];
|
||||
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
|
||||
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let key = derive_key(password, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
|
||||
let plaintext = b"{\"settings\": \"whatever\"}";
|
||||
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
|
||||
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(&*decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_fails_rather_than_returning_garbage() {
|
||||
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
|
||||
let result = decrypt(&encrypted, "wrong password");
|
||||
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
|
||||
// If this ever failed it would mean the salt or nonce stopped being
|
||||
// randomized — either one repeating is a real security regression
|
||||
// (a fixed salt lets an attacker precompute against the password
|
||||
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
|
||||
// outright), not just a cosmetic one.
|
||||
let a = encrypt(b"same plaintext", "same password").unwrap();
|
||||
let b = encrypt(b"same plaintext", "same password").unwrap();
|
||||
assert_ne!(a, b, "two independent exports must not be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
|
||||
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
|
||||
let last = encrypted.len() - 1;
|
||||
encrypted[last] ^= 0xFF;
|
||||
assert!(decrypt(&encrypted, "a password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
|
||||
assert!(decrypt(b"short", "any password").is_err());
|
||||
assert!(decrypt(b"", "any password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_the_wrong_magic_is_rejected() {
|
||||
let mut encrypted = encrypt(b"payload", "password").unwrap();
|
||||
encrypted[0] = b'X';
|
||||
assert!(decrypt(&encrypted, "password").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
//! Opening a URL in the *host's* browser — the half of triple-c#34 where
|
||||
//! "Open" appeared to do nothing on Linux.
|
||||
//!
|
||||
//! # Why this module exists rather than `openUrl` from `@tauri-apps/plugin-opener`
|
||||
//!
|
||||
//! The plugin's Linux path shells out to `xdg-open`, and the child inherits
|
||||
//! this process's environment verbatim. Inside an AppImage that environment is
|
||||
//! not the user's — it is the AppImage's, and it is actively hostile to any
|
||||
//! program that is not the one the bundle was built for:
|
||||
//!
|
||||
//! - linuxdeploy's `AppRun`/`AppRun.wrapped` prepends the bundle's own
|
||||
//! directories to `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `PYTHONPATH`,
|
||||
//! `PERLLIB`, `QT_PLUGIN_PATH` and `GSETTINGS_SCHEMA_DIR`.
|
||||
//! - `linuxdeploy-plugin-gtk`'s hook adds `GTK_PATH`, `GTK_EXE_PREFIX`,
|
||||
//! `GTK_DATA_PREFIX`, `GTK_IM_MODULE_FILE`, `GIO_MODULE_DIR` and
|
||||
//! `GDK_PIXBUF_MODULE_FILE`.
|
||||
//! - `scripts/finalize-appimage.sh` installs one more hook of our own
|
||||
//! (`triple-c-wayland-fallback.sh`) that can prepend
|
||||
//! `$APPDIR/usr/lib/wayland-fallback` to `LD_LIBRARY_PATH`.
|
||||
//! - `main.rs` sets `WEBKIT_DISABLE_DMABUF_RENDERER` process-wide, and the
|
||||
//! comment there has flagged this leak for a while: it reaches whatever the
|
||||
//! app spawns afterwards.
|
||||
//!
|
||||
//! A browser that is *already running* is unaffected — `xdg-open` just hands
|
||||
//! the URL to the existing instance over D-Bus/IPC and the new process exits.
|
||||
//! A **cold-launched** browser loads our bundled GTK/glib/pixbuf stack against
|
||||
//! the host's, aborts before it ever paints, and `xdg-open` has already
|
||||
//! returned 0. From the app's point of view the click did nothing. That is the
|
||||
//! reported symptom, and it is why the bug only reproduces for some people.
|
||||
//!
|
||||
//! # What this does instead
|
||||
//!
|
||||
//! `open_url_external` re-validates the URL (see below) and spawns the opener
|
||||
//! with a **sanitized child environment**. Sanitizing is
|
||||
//! [`sanitize_child_env`], a pure function over two maps so it can be tested
|
||||
//! without touching process-wide state:
|
||||
//!
|
||||
//! 1. If the AppImage saved the pre-launch value under a `*_ORIG` /
|
||||
//! `APPIMAGE_ORIGINAL_*` name, restore that. Restoring a saved original is
|
||||
//! strictly better than unsetting, because the user may genuinely have had
|
||||
//! an `LD_LIBRARY_PATH` of their own.
|
||||
//! 2. Otherwise, if the variable differs from the value this process started
|
||||
//! with, restore the start-up value. That is what undoes *our own*
|
||||
//! `std::env::set_var` — `main.rs` snapshots the environment via
|
||||
//! [`capture_pristine_environment`] before any mutation runs.
|
||||
//! 3. Otherwise, drop only the entries that point inside `$APPDIR`, keeping
|
||||
//! the rest of the list intact. Blanket-unsetting would also discard
|
||||
//! whatever the user's session had set; this removes exactly the
|
||||
//! bundle's own contribution.
|
||||
//!
|
||||
//! Nothing is invented: a variable the pristine environment did not have and
|
||||
//! that does not point into `$APPDIR` is left alone, so outside an AppImage
|
||||
//! (`cargo tauri dev`, a distro build) this is very close to a no-op.
|
||||
//!
|
||||
//! # Portal vs. `xdg-open`
|
||||
//!
|
||||
//! `org.freedesktop.portal.OpenURI` would sidestep both the environment leak
|
||||
//! *and* a missing `x-scheme-handler/https` association, but reaching it means
|
||||
//! a D-Bus client — `zbus` and its async stack — as a new dependency for one
|
||||
//! call, on the only platform where we ship a single self-contained binary.
|
||||
//! It also only helps where a portal is running, which is precisely the
|
||||
//! desktop-environment case in which `xdg-open` already works once the
|
||||
//! environment is clean. The environment *is* the bug here, so the cheap fix
|
||||
//! is the complete one. `gio open` is kept as a second candidate because it
|
||||
//! goes through GIO's own handler lookup rather than `xdg-open`'s shell
|
||||
//! heuristics, which covers most of what the portal would have covered.
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! The URL reaching this command originates in an **untrusted container** (see
|
||||
//! `app/src/lib/urlRelay.ts`). The frontend validates with `sanitizeRelayUrl`,
|
||||
//! but a compromised webview can call this command directly, so the rules are
|
||||
//! mirrored here and enforced again: `http`/`https` only, a non-empty host, no
|
||||
//! embedded credentials, no control characters or whitespace, and a length
|
||||
//! cap. The URL is never passed through a shell — `std::process::Command` with
|
||||
//! explicit arguments, so there is no word-splitting, no globbing and no
|
||||
//! metacharacter to escape.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use url::Url;
|
||||
|
||||
/// Hard cap on a URL we will hand to the OS. Mirrors `MAX_RELAY_URL_LENGTH`
|
||||
/// in `app/src/lib/urlRelay.ts`.
|
||||
const MAX_URL_LEN: usize = 8192;
|
||||
|
||||
/// The environment this process was started with, captured before anything
|
||||
/// mutates it. See [`capture_pristine_environment`].
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
static PRISTINE_ENV: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
|
||||
/// Record the environment as it was at process start.
|
||||
///
|
||||
/// Must be called from `main()` **before** any `std::env::set_var` — today
|
||||
/// that means before `apply_webkit_wayland_workaround()`, which is the only
|
||||
/// mutation in the tree. Calling it twice is harmless; the first call wins.
|
||||
///
|
||||
/// This is the only reliable source of truth for "what did the user actually
|
||||
/// have?" for variables *we* set. It cannot recover what `AppRun` overwrote
|
||||
/// before `main()` ran — that is what the `*_ORIG` and `$APPDIR` rules in
|
||||
/// [`sanitize_child_env`] are for.
|
||||
pub fn capture_pristine_environment() {
|
||||
let _ = PRISTINE_ENV.set(std::env::vars().collect());
|
||||
}
|
||||
|
||||
/// Variables an AppImage launcher is known to override, and that break a
|
||||
/// cold-launched child that is not this app.
|
||||
///
|
||||
/// `PATH` is in the list for the same reason as the rest: `AppRun` prepends
|
||||
/// `$APPDIR/usr/bin`, and resolving `xdg-open` (or anything the browser's own
|
||||
/// wrapper script calls) out of the bundle is its own failure mode.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
const SANITIZED_VARS: &[&str] = &[
|
||||
"GDK_PIXBUF_MODULEDIR",
|
||||
"GDK_PIXBUF_MODULE_FILE",
|
||||
"GIO_MODULE_DIR",
|
||||
"GSETTINGS_SCHEMA_DIR",
|
||||
"GTK_DATA_PREFIX",
|
||||
"GTK_EXE_PREFIX",
|
||||
"GTK_IM_MODULE_FILE",
|
||||
"GTK_PATH",
|
||||
"LD_LIBRARY_PATH",
|
||||
"PATH",
|
||||
"PERLLIB",
|
||||
"PYTHONPATH",
|
||||
"QT_PLUGIN_PATH",
|
||||
"XDG_DATA_DIRS",
|
||||
// Set by `main.rs`, not by AppRun — rule 2 (the pristine snapshot) is what
|
||||
// removes it, since the pristine environment almost never has it.
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
];
|
||||
|
||||
/// What to do to one variable in the child: `Some(value)` sets it, `None`
|
||||
/// removes it.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
type EnvChange = (String, Option<String>);
|
||||
|
||||
/// True when `entry` is `appdir` itself or a path inside it.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn is_inside(entry: &str, appdir: &str) -> bool {
|
||||
let appdir = appdir.trim_end_matches('/');
|
||||
if appdir.is_empty() {
|
||||
return false;
|
||||
}
|
||||
entry == appdir || entry.strip_prefix(appdir).is_some_and(|r| r.starts_with('/'))
|
||||
}
|
||||
|
||||
/// Drop the `$APPDIR` entries from a colon-separated list, keeping order and
|
||||
/// keeping everything else.
|
||||
///
|
||||
/// Single-valued variables (`GDK_PIXBUF_MODULE_FILE`, say) are just lists of
|
||||
/// one, so they need no separate case: a value inside `$APPDIR` filters down
|
||||
/// to nothing and the variable is removed.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
|
||||
let kept: Vec<&str> = value
|
||||
.split(':')
|
||||
.filter(|entry| !entry.is_empty() && !is_inside(entry, appdir))
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(kept.join(":"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the changes that turn `current` into an environment safe to hand a
|
||||
/// cold-launched host program.
|
||||
///
|
||||
/// Pure on purpose — `current` and `pristine` are passed in rather than read
|
||||
/// from the process, so the rules can be tested without a global mutex around
|
||||
/// the environment. Returns changes sorted by variable name so assertions are
|
||||
/// deterministic.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn sanitize_child_env(
|
||||
current: &BTreeMap<String, String>,
|
||||
pristine: &BTreeMap<String, String>,
|
||||
appdir: Option<&str>,
|
||||
) -> Vec<EnvChange> {
|
||||
let mut changes: Vec<EnvChange> = Vec::new();
|
||||
|
||||
for var in SANITIZED_VARS {
|
||||
let now = current.get(*var);
|
||||
|
||||
// 1. A saved original always wins. Both spellings are checked because
|
||||
// which one exists depends on the launcher: linuxdeploy's AppRun
|
||||
// and the various `AppRun.wrapped` generations have used each.
|
||||
// An empty saved value means "it was unset", not "set it to empty".
|
||||
let saved = current
|
||||
.get(&format!("{var}_ORIG"))
|
||||
.or_else(|| current.get(&format!("APPIMAGE_ORIGINAL_{var}")));
|
||||
if let Some(saved) = saved {
|
||||
let restored = if saved.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(saved.clone())
|
||||
};
|
||||
if restored.as_ref() != now {
|
||||
changes.push((var.to_string(), restored));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. We changed it ourselves after start-up — put back what was there.
|
||||
let at_start = pristine.get(*var);
|
||||
if at_start != now {
|
||||
changes.push((var.to_string(), at_start.cloned()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Polluted before `main()` ran, with nothing saved. Remove the
|
||||
// bundle's own entries and keep the user's.
|
||||
let (Some(now), Some(appdir)) = (now, appdir) else {
|
||||
continue;
|
||||
};
|
||||
let stripped = strip_appdir_entries(now, appdir);
|
||||
if stripped.as_deref() != Some(now.as_str()) {
|
||||
changes.push((var.to_string(), stripped));
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
changes
|
||||
}
|
||||
|
||||
/// Whether `candidate` holds a character that disqualifies it before parsing.
|
||||
///
|
||||
/// Mirrors `hasForbiddenChar` in `app/src/lib/urlRelay.ts`, and for the same
|
||||
/// reasons: C0/C1 controls and whitespace are invisible in the UI and are
|
||||
/// stripped rather than rejected by some URL parsers, and quote characters are
|
||||
/// illegal in a URL per RFC 3986 while being exactly what an argument-splitting
|
||||
/// opener downstream would act on. Written as a scan over code points rather
|
||||
/// than a regex so the control ranges cannot be mangled by an editing tool.
|
||||
fn has_forbidden_char(candidate: &str) -> bool {
|
||||
candidate.chars().any(|ch| {
|
||||
let code = ch as u32;
|
||||
code <= 0x20
|
||||
|| code == 0x7f
|
||||
|| (0x80..=0x9f).contains(&code)
|
||||
|| ch == '"'
|
||||
|| ch == '\''
|
||||
|| ch == '`'
|
||||
|| ch.is_whitespace()
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a URL an untrusted source asked the host to open.
|
||||
///
|
||||
/// Returns the normalized URL, or a message safe to show the user. The message
|
||||
/// never echoes the input: it is the input that is untrusted, and this error
|
||||
/// is rendered in a toast.
|
||||
fn validate_external_url(raw: &str) -> Result<String, String> {
|
||||
// Rust's `trim` strips slightly more than JavaScript's (NEL, U+0085, for
|
||||
// one), so a string the frontend would have rejected can reach the parser
|
||||
// here with its edges shaved. That only ever removes outer whitespace —
|
||||
// everything that survives still has to pass every check below — so the
|
||||
// divergence cannot widen what gets opened.
|
||||
let candidate = raw.trim();
|
||||
|
||||
if candidate.is_empty() {
|
||||
return Err("Refused to open an empty URL.".to_string());
|
||||
}
|
||||
if candidate.len() > MAX_URL_LEN {
|
||||
return Err(format!(
|
||||
"Refused to open a URL longer than {MAX_URL_LEN} characters."
|
||||
));
|
||||
}
|
||||
if has_forbidden_char(candidate) {
|
||||
return Err(
|
||||
"Refused to open a URL containing whitespace, quotes or control characters."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(candidate).map_err(|_| "Refused to open a malformed URL.".to_string())?;
|
||||
|
||||
// Scheme allowlist. Nothing else, ever — `file:`, `javascript:`, `data:`
|
||||
// and every registered protocol handler stay out of reach of the
|
||||
// container. The scheme is safe to interpolate: the parser restricts it to
|
||||
// ASCII alphanumerics, `+`, `-` and `.`.
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
return Err(format!(
|
||||
"Refused to open a {}: URL — only http and https are allowed.",
|
||||
parsed.scheme()
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_none_or(str::is_empty) {
|
||||
return Err("Refused to open a URL with no host.".to_string());
|
||||
}
|
||||
// `https://claude.ai@evil.tld/x` reads as claude.ai anywhere the string is
|
||||
// truncated, and navigates to evil.tld.
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err("Refused to open a URL containing embedded credentials.".to_string());
|
||||
}
|
||||
|
||||
let normalized = parsed.to_string();
|
||||
if normalized.len() > MAX_URL_LEN {
|
||||
return Err(format!(
|
||||
"Refused to open a URL longer than {MAX_URL_LEN} characters."
|
||||
));
|
||||
}
|
||||
// A normalized http(s) URL is ASCII by construction — the host is
|
||||
// punycoded and everything after it is percent-encoded. Asserting it means
|
||||
// nothing non-ASCII can reach an `execvp` argument, whatever the parser
|
||||
// decides to do in a future version.
|
||||
if !normalized.is_ascii() {
|
||||
return Err("Refused to open a URL with non-ASCII characters.".to_string());
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Openers to try, in order, each as (program, leading arguments).
|
||||
///
|
||||
/// `xdg-open` first because it is what the desktop expects to be asked and
|
||||
/// honours the user's `mimeapps.list`. `gio open` second: it is present
|
||||
/// wherever glib is (which, for a GTK app's host, is everywhere) and resolves
|
||||
/// the handler through GIO rather than `xdg-open`'s shell heuristics, so it
|
||||
/// still works when the `x-scheme-handler/https` association `xdg-open` looks
|
||||
/// for is missing or points at something broken.
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPENERS: &[(&str, &[&str])] = &[("xdg-open", &[]), ("gio", &["open"])];
|
||||
|
||||
/// How long a candidate opener is given to fail before it is assumed to have
|
||||
/// worked.
|
||||
///
|
||||
/// `xdg-open` usually returns immediately (it hands the URL to a running
|
||||
/// browser and exits), but in its generic fallback mode it *is* the browser's
|
||||
/// parent and stays alive for the session. So "still running" cannot be read
|
||||
/// as failure, and "exited non-zero quickly" is the only negative signal there
|
||||
/// is — though not, on its own, a trustworthy one. See
|
||||
/// [`exit_code_means_nothing_was_launched`].
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// Whether a non-zero exit says the opener certainly launched nothing, and so
|
||||
/// that the next candidate can be tried without risking a second tab.
|
||||
///
|
||||
/// The loop used to treat every quick non-zero exit as "it did nothing" and
|
||||
/// fall through. That is safe for most of `xdg-open`'s documented codes — 1
|
||||
/// (syntax), 2 (file not found) and 3 (a required tool could not be found) are
|
||||
/// all statements that it never got as far as launching a handler, and 3 is the
|
||||
/// missing-association case `gio open` is in [`OPENERS`] for. 127 is the same
|
||||
/// statement made by a shell, which is how a `$BROWSER` or `x-www-browser`
|
||||
/// wrapper naming a program that does not exist comes back.
|
||||
///
|
||||
/// Code 4 is the one that cannot be read that way, and it is the catch-all:
|
||||
/// "the action failed" also covers a handler that *was* launched and then
|
||||
/// returned non-zero. A browser that takes the URL, opens the tab in an already
|
||||
/// running instance and exits non-zero for its own reasons ends up here, as
|
||||
/// does a wrapper script that does its job and then returns the exit status of
|
||||
/// something else. Falling through on that hands the same URL to a second
|
||||
/// opener: two tabs for one click, and for an OAuth link two authorize
|
||||
/// requests.
|
||||
///
|
||||
/// So anything not recognised below — 4, an unfamiliar code, or a death by
|
||||
/// signal (`code()` is `None`) — ends the loop rather than continuing it. The
|
||||
/// caller is told the opener failed, which is the honest report of an
|
||||
/// ambiguous outcome, and no second request is made on the user's behalf. Note
|
||||
/// what this costs: an opener that genuinely failed with code 4 no longer falls
|
||||
/// through to `gio`, so a user whose `xdg-open` fails that way sees an error
|
||||
/// where they previously might have got a tab.
|
||||
///
|
||||
/// This is reasoning from `xdg-open`'s documented exit codes, not from an
|
||||
/// observed double-open in this app.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn exit_code_means_nothing_was_launched(code: Option<i32>) -> bool {
|
||||
matches!(code, Some(1 | 2 | 3 | 127))
|
||||
}
|
||||
|
||||
/// Spawn `url` with an opener, under a sanitized environment.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_with_clean_env(url: &str) -> Result<(), String> {
|
||||
let current: BTreeMap<String, String> = std::env::vars().collect();
|
||||
let pristine = PRISTINE_ENV.get().cloned().unwrap_or_else(|| current.clone());
|
||||
let appdir = current.get("APPDIR").cloned();
|
||||
let changes = sanitize_child_env(¤t, &pristine, appdir.as_deref());
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for (program, leading) in OPENERS {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(*leading).arg(url);
|
||||
// The bundle's own identity is not the child's business either, and a
|
||||
// browser that re-execs itself through a wrapper script can pick these
|
||||
// up.
|
||||
for var in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] {
|
||||
command.env_remove(var);
|
||||
}
|
||||
for (key, value) in &changes {
|
||||
match value {
|
||||
Some(value) => command.env(key, value),
|
||||
None => command.env_remove(key),
|
||||
};
|
||||
}
|
||||
// Detached: the opener must not inherit our stdio, or a browser
|
||||
// writing to stderr keeps a pipe to us open for the session.
|
||||
command
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
|
||||
// A spawn failure — `ErrorKind::NotFound` for an opener that is not
|
||||
// installed, `PermissionDenied` for one that cannot be executed — is
|
||||
// the unambiguous case: nothing ran, so nothing was opened, and the
|
||||
// next candidate is free to try.
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
failures.push(format!("{program}: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
std::thread::sleep(OPENER_GRACE);
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if !status.success() => {
|
||||
failures.push(format!("{program} exited with {status}"));
|
||||
// A program that *ran* is not a program that did nothing.
|
||||
if !exit_code_means_nothing_was_launched(status.code()) {
|
||||
return Err(format!(
|
||||
"Could not confirm the link opened. Tried: {}. It may have opened anyway \
|
||||
— check your browser before trying again.",
|
||||
failures.join("; ")
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
failures.push(format!("{program}: could not be waited on: {err}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Still running (it is the browser's parent) — reap it off-thread so it
|
||||
// does not become a zombie for the life of the app.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not open the link. Tried: {}. Check that xdg-utils is installed and that a default browser is set.",
|
||||
failures.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Open `url` in the user's browser.
|
||||
///
|
||||
/// On Linux this goes through [`spawn_with_clean_env`] rather than
|
||||
/// `@tauri-apps/plugin-opener`, for the AppImage reasons in this module's
|
||||
/// documentation (triple-c#34). macOS and Windows keep the plugin's path —
|
||||
/// neither has the environment problem, and `open`/`ShellExecute` are the
|
||||
/// right calls there — but they are reached through this same command so the
|
||||
/// frontend has one call site with one set of validation rules.
|
||||
///
|
||||
/// Errors are returned rather than logged-and-swallowed: "Open" silently doing
|
||||
/// nothing is the bug being fixed, so the failure has to be something the UI
|
||||
/// can show.
|
||||
#[tauri::command]
|
||||
pub async fn open_url_external(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
let validated = validate_external_url(&url)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = &app;
|
||||
tauri::async_runtime::spawn_blocking(move || spawn_with_clean_env(&validated))
|
||||
.await
|
||||
.map_err(|err| format!("Could not open the link: {err}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_url(validated, None::<&str>)
|
||||
.map_err(|err| format!("Could not open the link: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── URL re-validation ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn plain_http_and_https_urls_are_accepted() {
|
||||
for url in [
|
||||
"https://claude.ai/",
|
||||
"http://localhost:1420/callback?code=abc",
|
||||
"https://example.com/path#frag",
|
||||
] {
|
||||
assert!(validate_external_url(url).is_ok(), "{url} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_returned_normalized() {
|
||||
assert_eq!(
|
||||
validate_external_url("https://Example.COM").unwrap(),
|
||||
"https://example.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_http_and_https_survive() {
|
||||
for url in [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,<script>",
|
||||
"ftp://example.com/x",
|
||||
"vscode://foo/bar",
|
||||
"mailto:someone@example.com",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_credentials_are_refused() {
|
||||
for url in [
|
||||
"https://claude.ai@evil.tld/x",
|
||||
"https://user:pass@example.com/",
|
||||
"https://:pass@example.com/",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_whitespace_are_refused() {
|
||||
// `\n` in particular: parsers that strip it would turn the first of
|
||||
// these into a `javascript:` URL.
|
||||
for url in [
|
||||
"java\nscript:alert(1)",
|
||||
"https://example.com/\u{7f}",
|
||||
"https://example.com/\u{85}x",
|
||||
"https://example.com/a b",
|
||||
"https://example.com/\u{00a0}x",
|
||||
"https://example.com/\"",
|
||||
"https://example.com/'",
|
||||
"https://example.com/`",
|
||||
] {
|
||||
assert!(
|
||||
validate_external_url(url).is_err(),
|
||||
"{url:?} must not be openable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_oversized_are_refused() {
|
||||
assert!(validate_external_url("").is_err());
|
||||
assert!(validate_external_url(" ").is_err());
|
||||
let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
|
||||
assert!(validate_external_url(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_is_required() {
|
||||
assert!(validate_external_url("https://").is_err());
|
||||
assert!(validate_external_url("http://:8080/").is_err());
|
||||
// Not a missing host: WHATWG's "special authority ignore slashes"
|
||||
// state eats the third slash, so this is the host `path` in both
|
||||
// `new URL()` and here. Asserted so the parity is on the record.
|
||||
assert_eq!(
|
||||
validate_external_url("http:///path").unwrap(),
|
||||
"http://path/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_never_echo_the_input() {
|
||||
// The input is attacker-controlled and the message goes into a toast.
|
||||
let err = validate_external_url("file:///home/someone/.ssh/id_rsa").unwrap_err();
|
||||
assert!(!err.contains("id_rsa"), "message leaked the input: {err}");
|
||||
}
|
||||
|
||||
// ── Environment sanitization ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn appdir_entries_are_stripped_and_the_users_own_are_kept() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib:/opt/mine/lib"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_abc/usr/share:/usr/share"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![
|
||||
(
|
||||
"LD_LIBRARY_PATH".to_string(),
|
||||
Some("/opt/mine/lib".to_string())
|
||||
),
|
||||
("XDG_DATA_DIRS".to_string(), Some("/usr/share".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_that_is_entirely_appdir_is_removed() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
|
||||
(
|
||||
"GDK_PIXBUF_MODULE_FILE",
|
||||
"/tmp/.mount_abc/usr/lib/gdk-pixbuf/loaders.cache",
|
||||
),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![
|
||||
("GDK_PIXBUF_MODULE_FILE".to_string(), None),
|
||||
("GTK_PATH".to_string(), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_original_is_restored_rather_than_unset() {
|
||||
// Restoring beats unsetting: the user may have had one of their own.
|
||||
for saved_as in ["LD_LIBRARY_PATH_ORIG", "APPIMAGE_ORIGINAL_LD_LIBRARY_PATH"] {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
|
||||
(saved_as, "/home/someone/lib"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![(
|
||||
"LD_LIBRARY_PATH".to_string(),
|
||||
Some("/home/someone/lib".to_string())
|
||||
)],
|
||||
"{saved_as} should be restored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_saved_original_means_it_was_unset() {
|
||||
let current = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
|
||||
("LD_LIBRARY_PATH_ORIG", ""),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, ¤t, Some("/tmp/.mount_abc"));
|
||||
assert_eq!(changes, vec![("LD_LIBRARY_PATH".to_string(), None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_set_var_is_undone_from_the_pristine_snapshot() {
|
||||
// The leak `main.rs` documents: we set this after start-up, so the
|
||||
// start-up snapshot is what says it should not exist at all.
|
||||
let pristine = map(&[("HOME", "/home/someone")]);
|
||||
let current = map(&[
|
||||
("HOME", "/home/someone"),
|
||||
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
|
||||
]);
|
||||
let changes = sanitize_child_env(¤t, &pristine, None);
|
||||
assert_eq!(
|
||||
changes,
|
||||
vec![("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_the_user_set_themselves_is_left_alone() {
|
||||
let pristine = map(&[("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]);
|
||||
let current = pristine.clone();
|
||||
assert!(sanitize_child_env(¤t, &pristine, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_an_appimage_nothing_is_touched() {
|
||||
let env = map(&[
|
||||
("PATH", "/usr/bin:/bin"),
|
||||
("LD_LIBRARY_PATH", "/opt/mine/lib"),
|
||||
("XDG_DATA_DIRS", "/usr/share"),
|
||||
]);
|
||||
assert!(
|
||||
sanitize_child_env(&env, &env, None).is_empty(),
|
||||
"a dev build or distro build must not have its environment rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_invented_for_variables_that_were_never_set() {
|
||||
let env = map(&[("APPDIR", "/tmp/.mount_abc")]);
|
||||
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_that_merely_looks_like_appdir_is_not_stripped() {
|
||||
// `/tmp/.mount_abc-other` is not inside `/tmp/.mount_abc`.
|
||||
let env = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_abc-other/lib"),
|
||||
]);
|
||||
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_slash_on_appdir_still_matches() {
|
||||
let env = map(&[
|
||||
("APPDIR", "/tmp/.mount_abc/"),
|
||||
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
|
||||
]);
|
||||
let changes = sanitize_child_env(&env, &env, Some("/tmp/.mount_abc/"));
|
||||
assert_eq!(changes, vec![("GTK_PATH".to_string(), None)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod opener_fallback_tests {
|
||||
use super::*;
|
||||
|
||||
/// The codes `xdg-open` documents as "nothing was launched". Falling
|
||||
/// through to the next opener on these is what keeps `gio open` reachable
|
||||
/// for the case it was added for: no usable `x-scheme-handler/https`
|
||||
/// association.
|
||||
#[test]
|
||||
fn the_codes_that_mean_no_handler_ran_fall_through() {
|
||||
for code in [1, 2, 3, 127] {
|
||||
assert!(
|
||||
exit_code_means_nothing_was_launched(Some(code)),
|
||||
"exit {code} means the opener never launched anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression this guards: `xdg-open` returns 4 both when it could not
|
||||
/// act and when the handler it launched returned non-zero — including a
|
||||
/// browser that had already opened the tab. Trying `gio open` next would
|
||||
/// open it a second time, which for an OAuth URL is a second authorize
|
||||
/// request.
|
||||
#[test]
|
||||
fn an_exit_that_may_follow_a_successful_open_does_not_fall_through() {
|
||||
assert!(!exit_code_means_nothing_was_launched(Some(4)));
|
||||
for code in [5, 7, 126, 255] {
|
||||
assert!(
|
||||
!exit_code_means_nothing_was_launched(Some(code)),
|
||||
"exit {code} is not a documented 'did nothing', so it must not be assumed to be one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Killed by a signal: `code()` is `None` and the outcome is unknowable,
|
||||
/// so it is treated like any other unrecognised exit.
|
||||
#[test]
|
||||
fn a_death_by_signal_does_not_fall_through() {
|
||||
assert!(!exit_code_means_nothing_was_launched(None));
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
|
||||
writer_handle.abort();
|
||||
}
|
||||
|
||||
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
|
||||
/// copied so the web terminal cannot drift from it — a duplicated `const` with
|
||||
/// a "keep these identical" comment is only as good as the next reader.
|
||||
use crate::commands::terminal_commands::UPDATE_PRELUDE;
|
||||
|
||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
// The args are interpolated into a shell script string below, so
|
||||
// single-quote each one.
|
||||
let permission_flags: String = permission_args
|
||||
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
@@ -260,9 +267,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
import NotesDock from "./components/layout/NotesDock";
|
||||
import TerminalView from "./components/terminal/TerminalView";
|
||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<NotesDock />
|
||||
</div>
|
||||
<StatusBar stt={stt} />
|
||||
<ToastHost />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||
import { openUrlExternal } from "../lib/tauri-commands";
|
||||
import { useDocker } from "../hooks/useDocker";
|
||||
import Modal from "./ui/Modal";
|
||||
import Button from "./ui/Button";
|
||||
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
||||
const handleOpenDocs = async () => {
|
||||
if (!options) return;
|
||||
try {
|
||||
await openUrl(options.docs_url);
|
||||
await openUrlExternal(options.docs_url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open docs URL:", e);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../store/appState";
|
||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import type { PermissionMode } from "../../lib/types";
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -195,11 +196,10 @@ export default function MainTabs() {
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
return sessionDisplayName(
|
||||
session,
|
||||
projects.find((p) => p.id === session.projectId),
|
||||
);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
@@ -358,13 +358,7 @@ export default function MainTabs() {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const displayLabel = sessionDisplayName(session, project);
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NotesDock from "./NotesDock";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
vi.mock("../notes/NotesDockPanel", () => ({
|
||||
default: ({ projectId }: { projectId: string }) => (
|
||||
<div data-testid="panel">{`panel:${projectId}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let state: Record<string, unknown> = {};
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
isHomeTab: (k: string) => k.startsWith("home:"),
|
||||
isTerminalTab: (k: string) => k.startsWith("term:"),
|
||||
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
|
||||
// The mocked store module still needs to supply the width constants the
|
||||
// dock imports from it for the separator's aria-value attributes.
|
||||
NOTES_DOCK_MIN_WIDTH: 260,
|
||||
NOTES_DOCK_MAX_WIDTH: 720,
|
||||
}));
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: "s1",
|
||||
projectId: "p9",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
notesDockOpen: true,
|
||||
setNotesDockOpen: vi.fn(),
|
||||
toggleNotesDock: vi.fn(),
|
||||
notesDockWidth: 352,
|
||||
setNotesDockWidth: vi.fn(),
|
||||
activeTabKey: null,
|
||||
sessions: [session],
|
||||
projects: [{ id: "p9", name: "api" } as unknown as Project],
|
||||
};
|
||||
});
|
||||
|
||||
describe("NotesDock", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
state.notesDockOpen = false;
|
||||
const { container } = render(<NotesDock />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("follows a project home tab", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
|
||||
});
|
||||
|
||||
it("follows the project of the active terminal tab", () => {
|
||||
// The dock exists to be visible while the agent runs, so a terminal tab
|
||||
// must resolve to its project, not to nothing.
|
||||
state.activeTabKey = "term:s1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
|
||||
});
|
||||
|
||||
it("explains itself when no project is active", () => {
|
||||
state.activeTabKey = null;
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing for a terminal whose session has gone", () => {
|
||||
state.activeTabKey = "term:vanished";
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at the stored width", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
state.notesDockWidth = 420;
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
|
||||
});
|
||||
|
||||
it("has a keyboard-reachable resize handle", () => {
|
||||
// Drag is a mouse gesture; a separator that only responds to pointer
|
||||
// events is unusable without one.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
|
||||
// The dock sits on the right edge, so dragging or pressing left grows it
|
||||
// and right shrinks it. Asserting only "was called" would pass even if
|
||||
// the branches were swapped or the sign inverted.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
isTerminalTab,
|
||||
tabKeyId,
|
||||
NOTES_DOCK_MIN_WIDTH,
|
||||
NOTES_DOCK_MAX_WIDTH,
|
||||
} from "../../store/appState";
|
||||
import NotesDockPanel from "../notes/NotesDockPanel";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
* Notes beside whatever is on screen.
|
||||
*
|
||||
* Project Home and Terminal are sibling top-level tabs, so notes living only
|
||||
* in a sub-tab would be hidden exactly when the agent is running — which is
|
||||
* when a note is worth sending. The dock is the answer to that.
|
||||
*
|
||||
* **It takes space from inside the window and never resizes it.** Growing the
|
||||
* OS window was tried and rejected on evidence: honoured under XWayland,
|
||||
* silently corrupting under native Wayland, where `outer_position()` returns a
|
||||
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
|
||||
* §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s
|
||||
* ResizeObserver already reflows xterm and resizes the container PTY.
|
||||
*/
|
||||
export default function NotesDock() {
|
||||
const {
|
||||
notesDockOpen,
|
||||
setNotesDockOpen,
|
||||
notesDockWidth,
|
||||
setNotesDockWidth,
|
||||
activeTabKey,
|
||||
sessions,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
setNotesDockOpen: s.setNotesDockOpen,
|
||||
notesDockWidth: s.notesDockWidth,
|
||||
setNotesDockWidth: s.setNotesDockWidth,
|
||||
activeTabKey: s.activeTabKey,
|
||||
sessions: s.sessions,
|
||||
})),
|
||||
);
|
||||
|
||||
// Dragging the separator. Pointer capture rather than window listeners, so
|
||||
// the drag survives the pointer crossing the terminal — which swallows
|
||||
// events — and ends correctly if the button is released outside the window.
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const handle = e.currentTarget;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX;
|
||||
const startWidth = notesDockWidth;
|
||||
// The dock is on the right, so dragging left widens it.
|
||||
const onMove = (move: PointerEvent) =>
|
||||
setNotesDockWidth(startWidth + (startX - move.clientX));
|
||||
const onUp = () => {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
handle.removeEventListener("pointermove", onMove);
|
||||
handle.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
handle.addEventListener("pointermove", onMove);
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 64 : 16;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth + step);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth - step);
|
||||
}
|
||||
};
|
||||
|
||||
if (!notesDockOpen) return null;
|
||||
|
||||
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||
// is the project it belongs to.
|
||||
let projectId: string | null = null;
|
||||
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||
projectId = tabKeyId(activeTabKey);
|
||||
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
|
||||
projectId =
|
||||
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Notes"
|
||||
style={{ width: `${notesDockWidth}px` }}
|
||||
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
|
||||
>
|
||||
{/* Separator, not decoration: it carries a role and arrow keys, because
|
||||
a resize that only answers to a drag is unavailable to anyone not
|
||||
using a mouse. */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize notes panel"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={notesDockWidth}
|
||||
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
|
||||
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
|
||||
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{projectId ? (
|
||||
<NotesDockPanel projectId={projectId} />
|
||||
) : (
|
||||
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
|
||||
Open a project or a terminal to see its notes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function StatusBar({ stt }: Props) {
|
||||
const {
|
||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||
terminalAtBottom, scrollActiveToBottom,
|
||||
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
projects: s.projects,
|
||||
@@ -18,8 +18,10 @@ export default function StatusBar({ stt }: Props) {
|
||||
terminalHasSelection: s.terminalHasSelection,
|
||||
activeSessionId: s.activeSessionId,
|
||||
sttEnabled: s.appSettings?.stt?.enabled,
|
||||
terminalAtBottom: s.terminalAtBottom,
|
||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
toggleNotesDock: s.toggleNotesDock,
|
||||
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||
releaseActiveMouse: s.releaseActiveMouse,
|
||||
}))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
@@ -58,17 +60,26 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
{/* Right-aligned controls: mouse release + Notes + STT mic */}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{activeSessionId && !terminalAtBottom && (
|
||||
{activeSessionId && terminalMouseCaptured && (
|
||||
<button
|
||||
onClick={() => scrollActiveToBottom()}
|
||||
data-mouse-release="true"
|
||||
onClick={() => releaseActiveMouse()}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Scroll the terminal to the latest output"
|
||||
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
|
||||
>
|
||||
Jump to Current ↓
|
||||
🖱 Mouse captured — release
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleNotesDock}
|
||||
aria-pressed={notesDockOpen}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Show or hide the notes panel beside the current tab"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
{sttEnabled && activeSessionId && (
|
||||
<SttButton
|
||||
state={stt.state}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onBodyChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and body, saved when a field loses focus.
|
||||
*
|
||||
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||
* so there is no moment where the text on screen is not the text that would be
|
||||
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||
* than nearly true.
|
||||
*/
|
||||
export default function NoteEditor({
|
||||
projectId,
|
||||
title,
|
||||
body,
|
||||
onTitleChange,
|
||||
onBodyChange,
|
||||
onCommit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||
{/* Wraps rather than overflows. The two buttons are a group with a fixed
|
||||
appetite (~190px) and the title field can shrink only so far, so in a
|
||||
narrow dock the title takes the first row and the buttons the second.
|
||||
Without the wrap the group is simply clipped by the dock's
|
||||
`overflow-hidden`, which puts Delete off-window with no scrollbar to
|
||||
reach it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-24 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* The live editor text, not `note.body` — what is on screen is what
|
||||
gets sent. */}
|
||||
<SendToAgentButton projectId={projectId} body={body} />
|
||||
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => onBodyChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||
prompt — you press Enter.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const onTitleChange = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") =>
|
||||
render(
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selectedId}
|
||||
title={title}
|
||||
onTitleChange={onTitleChange}
|
||||
onCommit={onCommit}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("NoteSwitcher", () => {
|
||||
it("edits the title in place, committing on blur", () => {
|
||||
setup([note()]);
|
||||
const field = screen.getByLabelText("Note title");
|
||||
expect(field).toHaveValue("Deploy steps");
|
||||
|
||||
fireEvent.change(field, { target: { value: "Deploy steps v2" } });
|
||||
expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2");
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(field);
|
||||
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the other notes out of the way until asked for", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports whether the list is open", () => {
|
||||
setup([note()]);
|
||||
const trigger = screen.getByRole("button", { name: /switch note/i });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("marks the current note as the selected option", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas");
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a note and closes", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names an untitled note rather than showing an empty row", () => {
|
||||
setup([note({ title: " " })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Notes are addressed by id, never by title. Two untitled notes are the
|
||||
// ordinary case, and a title-keyed list would collapse them into one row.
|
||||
it("lists two notes that share a title as two options", () => {
|
||||
setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
|
||||
const options = screen.getAllByRole("option", { name: "Untitled note" });
|
||||
expect(options).toHaveLength(2);
|
||||
|
||||
fireEvent.click(options[1]);
|
||||
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||
});
|
||||
|
||||
it("closes on Escape without selecting anything", () => {
|
||||
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
export const UNTITLED = "Untitled note";
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
selectedId: string;
|
||||
title: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row that both names the current note and switches to another.
|
||||
*
|
||||
* The dock has no room for a permanent list of titles, so the title field
|
||||
* doubles as the label of what is open and the chevron beside it holds the
|
||||
* rest. Renaming therefore needs no separate affordance.
|
||||
*
|
||||
* Two honest controls rather than one `role="combobox"`: a text field and a
|
||||
* button that opens a listbox. A real combobox owes its listbox keyboard
|
||||
* navigation, active-descendant tracking and an input that filters — none of
|
||||
* which this needs, and half of which is worse than not claiming the role.
|
||||
*
|
||||
* `OverflowMenu` is deliberately not reused here despite the shape being
|
||||
* close. It keys its items by label, and notes are addressed by id: two
|
||||
* untitled notes are the ordinary case and would collapse into one row.
|
||||
*/
|
||||
export default function NoteSwitcher({
|
||||
notes,
|
||||
selectedId,
|
||||
title,
|
||||
onTitleChange,
|
||||
onCommit,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
onBlur={onCommit}
|
||||
placeholder="Note title"
|
||||
aria-label="Note title"
|
||||
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true" className="leading-none text-[10px]">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Notes"
|
||||
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
|
||||
would put an implicit `listitem` between the listbox and its
|
||||
options, which is not a child role a listbox owns. */}
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={n.id === selectedId}
|
||||
onClick={() => {
|
||||
onSelect(n.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
|
||||
n.id === selectedId
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
|
||||
: "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendProps: Record<string, unknown>[] = [];
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
sendProps.push(props);
|
||||
return <button type="button">Send to agent</button>;
|
||||
},
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendProps.length = 0;
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesDockPanel", () => {
|
||||
it("says it is loading rather than flashing an empty state", () => {
|
||||
loading = true;
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a first note when the project has none", async () => {
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// The point of the redesign: the dock spends its height on the note being
|
||||
// written, not on a permanent list of the ones that are not.
|
||||
it("shows one note at a time, the rest behind the switcher", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
|
||||
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the note picked from the list", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("careful");
|
||||
});
|
||||
|
||||
it("saves the body when it loses focus, and not before", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
|
||||
it("opens the note it just created", async () => {
|
||||
notes = [note()];
|
||||
createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
|
||||
const view = render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
|
||||
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||
|
||||
notes = [note(), note({ id: "n9", title: "" })];
|
||||
view.rerender(<NotesDockPanel projectId="p1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Note title")).toHaveValue(""),
|
||||
);
|
||||
});
|
||||
|
||||
// The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
|
||||
// panel, so both of these are load-bearing rather than cosmetic.
|
||||
it("sends from a full-width bar whose menu opens upward", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
|
||||
expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
|
||||
});
|
||||
|
||||
it("sends what is on screen, not what was last saved", () => {
|
||||
notes = [note()];
|
||||
render(<NotesDockPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), {
|
||||
target: { value: "edited but not blurred" },
|
||||
});
|
||||
|
||||
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteSwitcher from "./NoteSwitcher";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import Button from "../ui/Button";
|
||||
import OverflowMenu from "../ui/OverflowMenu";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes at dock width.
|
||||
*
|
||||
* Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
|
||||
* of titles beside the editor; the dock cannot, and shrinking that layout
|
||||
* spends its height on chrome — a title strip, a wrapped button row and a
|
||||
* paragraph of help — for a body that ends up a few words wide.
|
||||
*
|
||||
* So the dock shows exactly one note. The title row names it and switches to
|
||||
* another, the actions that are not writing live in the overflow menu, and
|
||||
* everything left over is the body. Roughly 240px of height comes back.
|
||||
*
|
||||
* What the two surfaces share is the part that must not drift: `useNotes` for
|
||||
* the cache and its write ordering, and `useNoteDraft` for when a keystroke
|
||||
* becomes a save. Only the layout is different.
|
||||
*/
|
||||
export default function NotesDockPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
Keep reminders here, and send any of them straight to a running Claude
|
||||
session.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<NoteSwitcher
|
||||
notes={notes}
|
||||
selectedId={selected.id}
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
onCommit={commit}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
{/* Renders nothing while idle, so it costs no width until it matters. */}
|
||||
<SaveIndicator state={saveState} />
|
||||
<OverflowMenu
|
||||
label="Note actions"
|
||||
items={[
|
||||
{ label: "New note", onSelect: () => void onCreate() },
|
||||
{
|
||||
label: "Delete note",
|
||||
danger: true,
|
||||
onSelect: () => void deleteNote(selected.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={commit}
|
||||
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||
aria-label="Note body"
|
||||
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
|
||||
/>
|
||||
|
||||
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
|
||||
{/* The live draft, not `selected.body` — what is on screen is what gets
|
||||
sent. `dropUp` because the dock clips its own overflow. */}
|
||||
<SendToAgentButton
|
||||
projectId={projectId}
|
||||
body={body}
|
||||
fullWidth
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import NotesDockPanel from "./NotesDockPanel";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Two panels, one project — the configuration the app actually runs in.
|
||||
*
|
||||
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
|
||||
* and the dock follows the active tab's project, so opening the dock over a
|
||||
* Project Home tab mounts both for the *same* project. Every other notes test
|
||||
* mounts exactly one, which is precisely the configuration in which a
|
||||
* per-panel cache looks correct: it is only with two that an edit made in one
|
||||
* is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
|
||||
* here; the cache is what is under test.
|
||||
*
|
||||
* The two are different components on purpose, which is exactly why this test
|
||||
* pairs them rather than mounting the same one twice: the layouts diverged,
|
||||
* and the cache and draft rules they share are what must not.
|
||||
*/
|
||||
|
||||
const files: Record<string, Note[]> = {};
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
listNotes: async (p: string) => [...(files[p] ?? [])],
|
||||
saveNote: async (p: string, n: Note) => {
|
||||
const list = files[p] ?? (files[p] = []);
|
||||
const at = list.findIndex((x) => x.id === n.id);
|
||||
if (at === -1) list.unshift(n);
|
||||
else list[at] = n;
|
||||
return n;
|
||||
},
|
||||
deleteNote: async (p: string, id: string) => {
|
||||
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: () => <button type="button">Send to agent</button>,
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The tab and the dock, mounted together the way `App` mounts them. */
|
||||
function renderBothSurfaces() {
|
||||
render(
|
||||
<>
|
||||
<div data-testid="tab">
|
||||
<NotesPanel projectId="p1" />
|
||||
</div>
|
||||
<div data-testid="dock">
|
||||
<NotesDockPanel projectId="p1" />
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
return {
|
||||
tab: () => within(screen.getByTestId("tab")),
|
||||
dock: () => within(screen.getByTestId("dock")),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(files)) delete files[key];
|
||||
files.p1 = [note()];
|
||||
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||
});
|
||||
|
||||
describe("the tab and the dock both open on one project", () => {
|
||||
it("shows an edit made in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
|
||||
// The other surface's list *and* its editor, not just one of them.
|
||||
await waitFor(() =>
|
||||
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
|
||||
);
|
||||
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("does not write one surface's stale copy over the other's edit", async () => {
|
||||
// The reported repro: edit in the dock, then go back to the tab and edit
|
||||
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
|
||||
// and the dock's edit was gone from disk with no error and no indicator.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const dockTitle = dock().getByLabelText("Note title");
|
||||
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||
fireEvent.blur(dockTitle);
|
||||
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "two" } });
|
||||
fireEvent.blur(tabBody);
|
||||
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("two"));
|
||||
expect(files.p1).toHaveLength(1);
|
||||
expect(files.p1[0].title).toBe("Deploy steps v2");
|
||||
});
|
||||
|
||||
it("reads the project once for both surfaces", async () => {
|
||||
// Two panels are two `useNotes`, but the in-flight flag is per project, so
|
||||
// mounting the dock over an open Notes tab does not re-read the file.
|
||||
const listNotes = vi.spyOn(
|
||||
await import("../../lib/tauri-commands"),
|
||||
"listNotes",
|
||||
);
|
||||
renderBothSurfaces();
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
|
||||
);
|
||||
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||
listNotes.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps text the user is part-way through typing when the other surface saves", async () => {
|
||||
// Showing a remote edit must never mean discarding an unsaved local one.
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
const tabBody = tab().getByLabelText("Note body");
|
||||
fireEvent.change(tabBody, { target: { value: "half-typed" } });
|
||||
|
||||
const dockBody = dock().getByLabelText("Note body");
|
||||
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
|
||||
fireEvent.blur(dockBody);
|
||||
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
|
||||
|
||||
expect(tabBody).toHaveValue("half-typed");
|
||||
});
|
||||
|
||||
it("falls back to another note when the selected one is deleted", async () => {
|
||||
// The claim a differently-named test in NotesPanel.test.tsx used to make
|
||||
// and could not keep: `useNotes` is mocked there and its list never
|
||||
// changes, so the fallback was invisible. Here the list is real.
|
||||
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
const { tab } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
|
||||
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
|
||||
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
|
||||
expect(files.p1).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a note created in one surface in the other", async () => {
|
||||
const { tab, dock } = renderBothSurfaces();
|
||||
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||
|
||||
// The dock keeps New behind its overflow menu — its height belongs to the
|
||||
// note being written, not to a button row.
|
||||
fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
|
||||
fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
|
||||
);
|
||||
expect(files.p1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import NotesPanel from "./NotesPanel";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
const saveNote = vi.fn(async () => true);
|
||||
const deleteNote = vi.fn(async () => true);
|
||||
const createNote = vi.fn();
|
||||
let notes: Note[] = [];
|
||||
let loading = false;
|
||||
|
||||
vi.mock("../../hooks/useNotes", () => ({
|
||||
useNotes: () => ({
|
||||
notes,
|
||||
loading,
|
||||
saveState: { status: "idle", error: null },
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SendToAgentButton", () => ({
|
||||
default: ({ body }: { body: string }) => (
|
||||
<button type="button" data-testid="send">{`send:${body}`}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy steps",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notes = [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
describe("NotesPanel", () => {
|
||||
it("invites the user to start when there are no notes", () => {
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists notes by title and selects the first", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
||||
});
|
||||
|
||||
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
||||
notes = [note({ title: "" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the editor when another note is selected", () => {
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
||||
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
||||
});
|
||||
|
||||
it("saves on blur, not on every keystroke", async () => {
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
const body = screen.getByLabelText("Note body");
|
||||
|
||||
fireEvent.change(body, { target: { value: "edited" } });
|
||||
expect(saveNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(body);
|
||||
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "n1", body: "edited" }),
|
||||
));
|
||||
});
|
||||
|
||||
it("does not save on blur when nothing changed", async () => {
|
||||
// Clicking through notes to read them must not write the file.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.blur(screen.getByLabelText("Note body"));
|
||||
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands the live editor text to the send button, not the last saved copy", () => {
|
||||
// Sending what is on screen is the whole contract: no transform on the way
|
||||
// out except the newline substitution.
|
||||
notes = [note()];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
||||
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||
});
|
||||
|
||||
it("asks the hook to delete the selected note", async () => {
|
||||
// Only the call: `useNotes` is mocked here and the mocked list never
|
||||
// changes, so nothing in this file can exercise what the panel selects
|
||||
// afterwards. The fallback is covered against the real hook in
|
||||
// NotesPanel.shared.test.tsx.
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNotes } from "../../hooks/useNotes";
|
||||
import { useNoteDraft } from "./useNoteDraft";
|
||||
import NoteEditor from "./NoteEditor";
|
||||
import Button from "../ui/Button";
|
||||
import SaveIndicator from "../ui/SaveIndicator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const UNTITLED = "Untitled note";
|
||||
|
||||
/**
|
||||
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||
* two cannot drift into different behaviour.
|
||||
*
|
||||
* Master/detail: titles beside the editor when there is room, stacked above it
|
||||
* when there is not. That is a **container** query, not a viewport one, because
|
||||
* the two surfaces differ in width while sharing a viewport — the dock opens at
|
||||
* 352px and the tab is the width of the main area. A `md:` breakpoint would
|
||||
* read the window and give both the same answer, which is the wrong answer for
|
||||
* one of them.
|
||||
*
|
||||
* The threshold is arithmetic, not taste: side by side needs the 192px list,
|
||||
* plus an editor wide enough for its own action row (~280px), plus the divider.
|
||||
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
|
||||
* first stop that clears it.
|
||||
*
|
||||
* The editor holds draft text locally and commits on blur, which is how every
|
||||
* other editable field in the app behaves (`ClaudeInstructionsEditor`, the
|
||||
* Config tab).
|
||||
*/
|
||||
export default function NotesPanel({ projectId }: Props) {
|
||||
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||
useNotes(projectId);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||
[notes, selectedId],
|
||||
);
|
||||
|
||||
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||
selected,
|
||||
saveNote,
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
const note = await createNote();
|
||||
if (note) setSelectedId(note.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
New note
|
||||
</Button>
|
||||
<SaveIndicator state={saveState} />
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||
No notes yet. Keep reminders here, and send any of them straight to a
|
||||
running Claude session.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col @lg:flex-row">
|
||||
{/* Stacked: a capped strip of titles above the editor, so the note
|
||||
being written keeps most of the height. Side by side: a full-height
|
||||
column of the fixed width the editor's arithmetic assumes. */}
|
||||
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||
selected?.id === n.id
|
||||
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{n.title.trim() || UNTITLED}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex-1 min-w-0">
|
||||
{selected && (
|
||||
<NoteEditor
|
||||
projectId={projectId}
|
||||
title={title}
|
||||
body={body}
|
||||
onTitleChange={setTitle}
|
||||
onBodyChange={setBody}
|
||||
onCommit={commit}
|
||||
onDelete={() => void deleteNote(selected.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import SendToAgentButton from "./SendToAgentButton";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
const sendInput = vi.fn(async () => {});
|
||||
let sessions: TerminalSession[] = [];
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({ sessions, sendInput }),
|
||||
}));
|
||||
|
||||
const setActiveTabKey = vi.fn();
|
||||
const requestTerminalFocus = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
let projects: Project[] = [];
|
||||
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) =>
|
||||
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||
{
|
||||
getState: () => ({
|
||||
projects,
|
||||
setActiveTabKey,
|
||||
requestTerminalFocus,
|
||||
pushToast,
|
||||
}),
|
||||
},
|
||||
),
|
||||
terminalTabKey: (id: string) => `term:${id}`,
|
||||
}));
|
||||
|
||||
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessions = [];
|
||||
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
||||
});
|
||||
|
||||
describe("SendToAgentButton", () => {
|
||||
// Unavailable, not `disabled`: the reason a note cannot be sent is the whole
|
||||
// content of these states, and native `disabled` announces it to nobody.
|
||||
it("says why it cannot send when the project has no running session", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(
|
||||
"No running Claude session for this project",
|
||||
);
|
||||
});
|
||||
|
||||
it("says why it cannot send an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAccessibleDescription("Nothing to send — this note is empty");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session belongs to another project", () => {
|
||||
sessions = [session({ projectId: "other" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is unavailable when the only session is a bash tab", () => {
|
||||
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
||||
// shell is never a target.
|
||||
sessions = [session({ sessionType: "bash" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send to agent/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
|
||||
// guard this swap would turn a greyed-out button into a live one.
|
||||
it("sends nothing when activated while unavailable", () => {
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
|
||||
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
|
||||
});
|
||||
|
||||
it("focuses the terminal it sent to, so the user watches it land", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
|
||||
});
|
||||
|
||||
it("offers a menu of display names when several sessions are open", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
projects = [
|
||||
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
|
||||
];
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
|
||||
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
|
||||
});
|
||||
|
||||
it("reports a failed send rather than looking like it worked", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("session closed"));
|
||||
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does nothing for an empty note", () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the session menu upward when it sits at the foot of the dock", async () => {
|
||||
sessions = [session({ id: "s1" }), session({ id: "s2" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
// Anchored to the button's top edge, not below it: the dock clips its own
|
||||
// overflow, so a downward menu at the bottom edge is invisible.
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||
});
|
||||
|
||||
// Switching to the tab is not enough. When the dock is open beside the
|
||||
// terminal it sends to, that terminal is already the active tab, so
|
||||
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||
// this button, one click short of the Enter the user came to press.
|
||||
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||
});
|
||||
|
||||
it("leaves focus alone when the send failed", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("focuses the session picked from the menu, not the first one", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState, terminalTabKey } from "../../store/appState";
|
||||
import { toClaudePayload } from "../../lib/claudeInput";
|
||||
import { sessionDisplayName } from "../../lib/sessionName";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
body: string;
|
||||
/**
|
||||
* Open the session menu above the button instead of below. The dock puts
|
||||
* this at its foot, and the dock clips its own overflow, so a downward menu
|
||||
* there is drawn outside the panel and never seen.
|
||||
*/
|
||||
dropUp?: boolean;
|
||||
/** Fill the row. The dock's send bar is the width of the dock. */
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a note into a running Claude session's prompt.
|
||||
*
|
||||
* Three behaviours by target count: none disables the button, one sends
|
||||
* straight there, several ask which. It never guesses — the note goes to a
|
||||
* session the user named, or to the only one there is.
|
||||
*
|
||||
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
|
||||
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
|
||||
*/
|
||||
export default function SendToAgentButton({
|
||||
projectId,
|
||||
body,
|
||||
dropUp = false,
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const { sessions, sendInput } = useTerminal();
|
||||
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
|
||||
useAppState(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
requestTerminalFocus: s.requestTerminalFocus,
|
||||
pushToast: s.pushToast,
|
||||
})),
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
sessions.filter(
|
||||
(s) => s.projectId === projectId && s.sessionType === "claude",
|
||||
),
|
||||
[sessions, projectId],
|
||||
);
|
||||
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const hasBody = body.trim().length > 0;
|
||||
const unavailable = targets.length === 0 || !hasBody;
|
||||
|
||||
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const send = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setMenuOpen(false);
|
||||
try {
|
||||
// No trailing CR: the note lands in the prompt and the user presses
|
||||
// Enter. Newlines become ESC+CR so it arrives as one message rather
|
||||
// than one prompt per line.
|
||||
await sendInput(sessionId, toClaudePayload(body));
|
||||
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||
// text still went.
|
||||
setActiveTabKey(terminalTabKey(sessionId));
|
||||
// Switching tabs is not the same as taking focus, and when the dock is
|
||||
// open beside the terminal it just sent to, that tab is already the
|
||||
// active one — so nothing above moves the caret off this button. The
|
||||
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||
requestTerminalFocus(sessionId);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not send the note to the agent",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
// The target is resolved at click time and pinned for the whole send, the
|
||||
// hazard `useSTT` guards against by capturing its session at record start:
|
||||
// the list can change while the request is in flight.
|
||||
if (targets.length === 1) {
|
||||
void send(targets[0].id);
|
||||
return;
|
||||
}
|
||||
setMenuOpen((open) => !open);
|
||||
}, [targets, send]);
|
||||
|
||||
const title = !hasBody
|
||||
? "Nothing to send — this note is empty"
|
||||
: targets.length === 0
|
||||
? "No running Claude session for this project"
|
||||
: "Put this note into the agent's prompt (you press Enter)";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={fullWidth ? "md" : "sm"}
|
||||
className={fullWidth ? "w-full" : ""}
|
||||
// Not `disabled`: every one of these reasons is information, and
|
||||
// `disabled` takes the button — reason and all — out of the
|
||||
// accessibility tree. `Button` guards the click for us.
|
||||
unavailable={unavailable}
|
||||
unavailableReason={title}
|
||||
onClick={onClick}
|
||||
aria-haspopup={targets.length > 1 ? "menu" : undefined}
|
||||
aria-expanded={targets.length > 1 ? menuOpen : undefined}
|
||||
title={title}
|
||||
>
|
||||
Send to agent
|
||||
</Button>
|
||||
{menuOpen && targets.length > 1 && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
|
||||
dropUp ? "bottom-full mb-1" : "mt-1"
|
||||
}`}
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{targets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => void send(s.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
{sessionDisplayName(s, project)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Note } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Draft text for the note being edited, committed when a field loses focus.
|
||||
*
|
||||
* This is the half the dock and the tab must never disagree on, so it lives
|
||||
* here rather than in either layout. The two surfaces differ in how they show
|
||||
* notes; they must not differ in when a keystroke becomes a save.
|
||||
*
|
||||
* The draft is "untouched" exactly while it still matches what was last copied
|
||||
* out of the store, which is what lets an edit made on the *other* surface
|
||||
* reach this one's editor without ever discarding half-typed text.
|
||||
*/
|
||||
export function useNoteDraft(
|
||||
selected: Note | null,
|
||||
saveNote: (note: Note) => Promise<unknown>,
|
||||
) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||
id: null,
|
||||
title: "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
// Re-seed on a change of note, and on a change to the *stored* text of the
|
||||
// note already open — the second case is the dock and the tab showing one
|
||||
// project at once.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
seeded.current = { id: null, title: "", body: "" };
|
||||
setTitle("");
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
const untouched =
|
||||
title === seeded.current.title && body === seeded.current.body;
|
||||
if (seeded.current.id !== selected.id || untouched) {
|
||||
seeded.current = {
|
||||
id: selected.id,
|
||||
title: selected.title,
|
||||
body: selected.body,
|
||||
};
|
||||
setTitle(selected.title);
|
||||
setBody(selected.body);
|
||||
}
|
||||
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const commit = () => {
|
||||
if (!selected) return;
|
||||
// Reading is not editing: clicking through notes must not rewrite the file.
|
||||
if (title === selected.title && body === selected.body) return;
|
||||
// Mark the draft as matching what was just committed, so the store update
|
||||
// this save produces reads as "no change" rather than as a stale re-seed.
|
||||
seeded.current = { id: selected.id, title, body };
|
||||
void saveNote({ ...selected, title, body });
|
||||
};
|
||||
|
||||
return { title, body, setTitle, setBody, commit };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
|
||||
const add = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({ add }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||
function deferred() {
|
||||
let resolve!: (v: unknown) => void;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fillValidForm() {
|
||||
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||
target: { value: "my-project" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||
target: { value: "/home/user/my-project" },
|
||||
});
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||
}
|
||||
|
||||
describe("AddProjectDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("adds the project with the name and folder entered", async () => {
|
||||
add.mockResolvedValue({ id: "p1" });
|
||||
const onClose = vi.fn();
|
||||
render(<AddProjectDialog onClose={onClose} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith("my-project", [
|
||||
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||
]),
|
||||
);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
// Native `disabled` would remove the button from the accessibility tree
|
||||
// exactly when it has something to say.
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
expect(submitButton()).not.toBeDisabled();
|
||||
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
fireEvent.click(submitButton());
|
||||
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||
fireEvent.keyDown(submitButton(), { key: " " });
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
// Enter in a text field submits a form regardless of the submit button's
|
||||
// state, so the handler has to guard itself too.
|
||||
// Modal portals to document.body, so the form is not under `container`.
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
fireEvent.submit(form!);
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("leaves the submit button plainly available when idle", () => {
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||
expect(submitButton()).toHaveAccessibleDescription("");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||
// is in flight, and Enter inside a text field submits the form without
|
||||
// touching the button at all. Both routes end here, so the guard does too.
|
||||
if (loading) return;
|
||||
if (!name.trim()) {
|
||||
setError("Project name is required");
|
||||
return;
|
||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form={formId}
|
||||
unavailable={loading}
|
||||
unavailableReason="The project is being added. Wait for it to finish."
|
||||
title={
|
||||
loading
|
||||
? "The project is being added. Wait for it to finish."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? "Adding…" : "Add Project"}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
||||
});
|
||||
|
||||
it("only allows opening a terminal while the container runs", () => {
|
||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
// Native `disabled` would drop the button out of the accessibility tree
|
||||
// and out of the tab order, taking the reason with it.
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops aria-disabled once the container is running", () => {
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("shows container progress inline rather than in a blocking modal", () => {
|
||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
||||
import { useAppState, homeTabKey } from "../../store/appState";
|
||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { useUnavailable } from "../ui/unavailable";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
|
||||
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||
// attribute that hides the button — and the reason — from anyone not using a
|
||||
// mouse and eyes.
|
||||
const terminal = useUnavailable({
|
||||
unavailable: !isRunning,
|
||||
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||
onClick: () => openClaudeTerminal(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => openClaudeTerminal()}
|
||||
title={`Open a Claude terminal for ${project.name}`}
|
||||
{...terminal.controlProps}
|
||||
title={
|
||||
isRunning
|
||||
? `Open a Claude terminal for ${project.name}`
|
||||
: `${project.name} is not running. Start it to open a terminal.`
|
||||
}
|
||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
||||
<line x1="13" y1="15" x2="17" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
{terminal.reasonNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
setBrowserViewMatchWindow,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import OpenPageDialog from "./OpenPageDialog";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
@@ -338,7 +339,7 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// Prefer the probe: it is the fresher of the two, and it is the one that
|
||||
// reflects an install that just finished.
|
||||
const probed = detection ?? status.detection;
|
||||
const ready = isUsable(probed);
|
||||
const ready = isBrowserViewUsable(probed);
|
||||
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
||||
// apt package, so it never shows up in `browsers`, and a container that has
|
||||
// it is not missing a browser.
|
||||
@@ -539,11 +540,6 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
|
||||
function isUsable(d: PlaywrightDetection | null): boolean {
|
||||
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||
*
|
||||
@@ -627,7 +623,7 @@ function Setup({
|
||||
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||
}) {
|
||||
const busy = job !== null;
|
||||
const havePackages = isUsable(detection);
|
||||
const havePackages = isBrowserViewUsable(detection);
|
||||
const missing = missingParts(detection);
|
||||
const browsers = detection?.browsers ?? [];
|
||||
const chrome = detection?.chrome_channel ?? null;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Project } from "../../../lib/types";
|
||||
import NotesPanel from "../../notes/NotesPanel";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes as a Project Home sub-tab.
|
||||
*
|
||||
* The same panel the dock shows. This is the roomy view for writing; the dock
|
||||
* is the one that stays visible while the agent works.
|
||||
*/
|
||||
export default function NotesTab({ project }: Props) {
|
||||
return (
|
||||
<div className="h-full min-h-0">
|
||||
<NotesPanel projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import AutomationTab from "./AutomationTab";
|
||||
import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import NotesTab from "./NotesTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
@@ -28,6 +29,7 @@ const TABS = [
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "files", label: "Files" },
|
||||
{ id: "browser", label: "Browser" },
|
||||
{ id: "notes", label: "Notes" },
|
||||
] as const;
|
||||
|
||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
{tab === "browser" && (
|
||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||
)}
|
||||
{tab === "notes" && <NotesTab project={project} />}
|
||||
</div>
|
||||
|
||||
{showMigration && (
|
||||
|
||||
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
|
||||
hasClaudeToken: vi.fn(),
|
||||
clearClaudeToken: vi.fn(),
|
||||
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
||||
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
|
||||
}));
|
||||
|
||||
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
||||
|
||||
const openUrl = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: (...args: unknown[]) => openUrl(...args),
|
||||
}));
|
||||
const openUrlExternal = vi.fn();
|
||||
|
||||
/** Captured event handlers, keyed by event name, so tests can emit. */
|
||||
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
||||
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
|
||||
|
||||
const link = await screen.findByRole("link", { name: url });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
|
||||
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
|
||||
});
|
||||
|
||||
it("ignores output belonging to a different project", async () => {
|
||||
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
|
||||
|
||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
});
|
||||
|
||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||
@@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => {
|
||||
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a hyperlink belonging to a different project", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { cancelClaudeToken } from "../../lib/tauri-commands";
|
||||
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openUrl(target);
|
||||
await openUrlExternal(target);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
authErrorMessage(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import ExportSettingsModal from "./ExportSettingsModal";
|
||||
|
||||
const exportSettings = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
exportSettings: (password: string) => exportSettings(password),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function fillPasswords(password: string, confirm: string) {
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
|
||||
}
|
||||
|
||||
describe("ExportSettingsModal", () => {
|
||||
it("keeps the submit button disabled until the passwords are long enough and match", () => {
|
||||
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||
const submit = screen.getByRole("button", { name: /choose where to save/i });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
fillPasswords("short", "short");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
|
||||
|
||||
fillPasswords("longenoughpassword", "different");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("exports with the entered password and shows success", async () => {
|
||||
exportSettings.mockResolvedValue(true);
|
||||
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
|
||||
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("closes quietly when the save dialog is dismissed", async () => {
|
||||
exportSettings.mockResolvedValue(false);
|
||||
const onClose = vi.fn();
|
||||
render(<ExportSettingsModal onClose={onClose} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error rather than closing when the export fails", async () => {
|
||||
exportSettings.mockRejectedValue("Disk is full");
|
||||
const onClose = vi.fn();
|
||||
render(<ExportSettingsModal onClose={onClose} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass } from "../ui/Field";
|
||||
import { exportSettings } from "../../lib/tauri-commands";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Password entry for exporting global settings. The save dialog itself opens
|
||||
* from Rust once a password is confirmed here — see the doc comment on
|
||||
* `commands::settings_export_commands` for why the host path never
|
||||
* round-trips through this component.
|
||||
*/
|
||||
export default function ExportSettingsModal({ onClose }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
|
||||
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
|
||||
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
|
||||
|
||||
const handleExport = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await exportSettings(password);
|
||||
if (saved) setDone(true);
|
||||
// `false` means the save dialog was dismissed — close quietly, same as
|
||||
// if the user had cancelled the modal itself.
|
||||
else onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Export settings"
|
||||
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
|
||||
widthClassName="w-[28rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
done ? (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => void handleExport()}
|
||||
disabled={!canSubmit || busy}
|
||||
>
|
||||
{busy ? "Exporting…" : "Choose where to save…"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{done ? (
|
||||
<p className="text-[13px] text-[var(--success)]">
|
||||
Settings exported. Keep the password somewhere safe — there is no way to recover
|
||||
the file without it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Confirm password">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
Use at least {MIN_PASSWORD_LENGTH} characters.
|
||||
</p>
|
||||
)}
|
||||
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import ImportSettingsModal from "./ImportSettingsModal";
|
||||
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
|
||||
|
||||
const previewSettingsImport = vi.fn();
|
||||
const applySettingsImport = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
previewSettingsImport: (password: string) => previewSettingsImport(password),
|
||||
applySettingsImport: (password: string) => applySettingsImport(password),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const samplePreview: SettingsImportPreview = {
|
||||
exported_at: "2026-08-27T00:00:00Z",
|
||||
app_version: "0.4.14",
|
||||
custom_env_var_count: 2,
|
||||
gateway_model_count: 0,
|
||||
has_claude_code_settings: false,
|
||||
has_claude_oauth_token: true,
|
||||
has_gateway_api_key: false,
|
||||
has_gateway_master_key: false,
|
||||
has_web_terminal_access_token: false,
|
||||
enables_web_terminal: false,
|
||||
ollama_base_url: null,
|
||||
llamacpp_base_url: null,
|
||||
openai_compatible_base_url: null,
|
||||
gateway_api_base: null,
|
||||
image_source: "registry",
|
||||
custom_image_name: null,
|
||||
};
|
||||
|
||||
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||
return { settings, secret_restore_warnings: secretRestoreWarnings };
|
||||
}
|
||||
|
||||
describe("ImportSettingsModal", () => {
|
||||
it("keeps 'Choose file' disabled until a password is entered", () => {
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows the preview and confirms with the same password used to open it", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
|
||||
const onImported = vi.fn();
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
|
||||
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct warning when the import would enable the web terminal", async () => {
|
||||
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
image_source: "custom",
|
||||
custom_image_name: "ghcr.io/attacker/triple-c:latest",
|
||||
});
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockResolvedValue(
|
||||
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
|
||||
);
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await screen.findByText(/2 global custom env vars/i);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes quietly when the file picker is dismissed", async () => {
|
||||
previewSettingsImport.mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows an error when the password is wrong rather than a blank preview", async () => {
|
||||
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error if applying the import fails, without claiming success", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockRejectedValue("Keychain write failed");
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await screen.findByText(/2 global custom env vars/i);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass } from "../ui/Field";
|
||||
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
|
||||
import { describeImport, describeImportWarnings } from "../../lib/settingsImportPreview";
|
||||
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
/** Fired once the import is actually applied, so the caller can refresh
|
||||
* whatever reads settings from the store. */
|
||||
onImported: (settings: AppSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two phases: enter the password and pick the file (backend resolves the
|
||||
* file dialog itself — see `commands::settings_export_commands`), then
|
||||
* confirm a preview before anything is actually applied. The same password
|
||||
* is reused for the second call rather than asking again; nothing about
|
||||
* that call needs a fresh secret; the backend just doesn't cache the
|
||||
* *decrypted payload* between the two.
|
||||
*/
|
||||
export default function ImportSettingsModal({ onClose, onImported }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
|
||||
|
||||
const handleChooseFile = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await previewSettingsImport(password);
|
||||
if (result) setPreview(result);
|
||||
else onClose(); // File picker dismissed.
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const outcome = await applySettingsImport(password);
|
||||
setApplied(true);
|
||||
setSecretWarnings(outcome.secret_restore_warnings);
|
||||
onImported(outcome.settings);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Import settings"
|
||||
description={
|
||||
preview
|
||||
? "Review what this file will change before applying it."
|
||||
: "Choose a Triple-C settings export and enter the password it was created with."
|
||||
}
|
||||
widthClassName="w-[28rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
applied ? (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
) : preview ? (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
|
||||
{busy ? "Importing…" : "Import"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => void handleChooseFile()}
|
||||
disabled={!password || busy}
|
||||
>
|
||||
{busy ? "Opening…" : "Choose file…"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{applied ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
|
||||
{secretWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
|
||||
{preview.app_version}.
|
||||
</p>
|
||||
{/* Warnings render before the replace list, deliberately: the list
|
||||
* below can run long, and the one thing here that most needs to
|
||||
* stay above the fold while scrolling is "this turns on a
|
||||
* network-listening service" or "this runs a different image" —
|
||||
* not a bullet buried among ordinary settings. */}
|
||||
{describeImportWarnings(preview).map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
|
||||
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
|
||||
{describeImport(preview).map((item) => (
|
||||
<li key={item} className="break-all">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Field label="Password">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,13 +15,17 @@ import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import AccordionSection from "../ui/AccordionSection";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import CertificateSettings from "./CertificateSettings";
|
||||
import ExportSettingsModal from "./ExportSettingsModal";
|
||||
import ImportSettingsModal from "./ImportSettingsModal";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const { appSettings, saveSettings, setAppSettings } = useSettings();
|
||||
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||
@@ -33,6 +37,8 @@ export default function SettingsPanel() {
|
||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
@@ -63,6 +69,14 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
terminal_gpu_rendering: value === "auto" ? null : value === "on",
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoCheckToggle = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||
@@ -238,6 +252,45 @@ export default function SettingsPanel() {
|
||||
<SttSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
|
||||
<SegmentedControl
|
||||
label="Terminal GPU rendering"
|
||||
value={
|
||||
appSettings?.terminal_gpu_rendering == null
|
||||
? "auto"
|
||||
: appSettings.terminal_gpu_rendering
|
||||
? "on"
|
||||
: "off"
|
||||
}
|
||||
onChange={handleGpuRenderingChange}
|
||||
segments={[
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
|
||||
? "On for this platform."
|
||||
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
|
||||
},
|
||||
{
|
||||
value: "on",
|
||||
label: "On",
|
||||
hint: "Always load the WebGL renderer.",
|
||||
},
|
||||
{
|
||||
value: "off",
|
||||
label: "Off",
|
||||
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Takes effect when a terminal tab is next switched to.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{appVersion && (
|
||||
@@ -269,6 +322,39 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Export your global settings and stored credentials (a shared Claude login,
|
||||
gateway keys) to one password-encrypted file, or restore them on a new machine.
|
||||
Project-specific settings and container data are never included.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Export settings…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Import settings…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
|
||||
|
||||
{showImportModal && (
|
||||
<ImportSettingsModal
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImported={(settings) => setAppSettings(settings)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInstructionsModal && (
|
||||
<ClaudeInstructionsModal
|
||||
instructions={globalInstructions}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "../../lib/types";
|
||||
import { openUrlExternal } from "../../lib/tauri-commands";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { formatBytes } from "../../lib/formatBytes";
|
||||
@@ -19,7 +19,7 @@ export default function UpdateDialog({
|
||||
}: Props) {
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await openUrl(url);
|
||||
await openUrlExternal(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open URL:", e);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import TerminalView, { supersedes } from "./TerminalView";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import {
|
||||
uploadHostFileToTerminal,
|
||||
openUrlExternal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import {
|
||||
chooseSignInTarget,
|
||||
resetBrowserSupportCache,
|
||||
} from "../../hooks/useSignInOpenTarget";
|
||||
import type { AuthBridgeStatus, PlaywrightDetection } from "../../lib/types";
|
||||
import { URL_TOAST_SELECTOR } from "./UrlToast";
|
||||
|
||||
/**
|
||||
* The window-wide native drag-drop listener, captured at registration.
|
||||
@@ -15,6 +24,18 @@ const dragDrop = vi.hoisted(() => ({
|
||||
handler: null as null | ((event: unknown) => unknown),
|
||||
}));
|
||||
|
||||
/**
|
||||
* What the project's container answers about itself.
|
||||
*
|
||||
* `TerminalView` asks two questions on mount — is the auth bridge live, and is
|
||||
* there a browser inside to open a page in — because together they decide which
|
||||
* of the URL toast's two buttons leads for a sign-in link.
|
||||
*/
|
||||
const containerEnv = vi.hoisted(() => ({
|
||||
bridge: { enabled: false, active_ports: [], conflicts: [] } as unknown,
|
||||
detection: null as unknown,
|
||||
}));
|
||||
|
||||
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
|
||||
const ptyOutput = vi.hoisted(() => ({
|
||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||
@@ -44,6 +65,9 @@ vi.mock("../../lib/tauri-commands", () => ({
|
||||
awsSsoRefresh: vi.fn(async () => {}),
|
||||
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
||||
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
||||
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
||||
openUrlExternal: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -53,10 +77,6 @@ vi.mock("@tauri-apps/api/event", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
||||
@@ -127,6 +147,14 @@ beforeEach(() => {
|
||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||
dragDrop.handler = null;
|
||||
ptyOutput.listeners.clear();
|
||||
vi.mocked(openUrlExternal).mockReset();
|
||||
vi.mocked(openUrlExternal).mockResolvedValue(undefined);
|
||||
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
|
||||
containerEnv.detection = null;
|
||||
// The Playwright probe is memoized across mounts (it is a container exec), so
|
||||
// a case that changes the answer has to drop what an earlier one cached.
|
||||
resetBrowserSupportCache();
|
||||
useAppState.setState({ toasts: [] });
|
||||
document.body.innerHTML = "";
|
||||
useAppState.setState({ sessions: [] });
|
||||
});
|
||||
@@ -370,15 +398,34 @@ describe("TerminalView — where a dropped file lands", () => {
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
||||
// The regression this file could not see. The toggle is `absolute top-2
|
||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
||||
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
|
||||
// The regression this file could not see. Chrome like the URL toast is a
|
||||
// *sibling* of the xterm host painted over the pane, so
|
||||
// `elementFromPoint` returns it rather than the host — and a gate asking
|
||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||
// with no message and no log line. jsdom never ran that branch.
|
||||
const view = await mountWithLayout();
|
||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
||||
stubElementFromPoint(toggle);
|
||||
//
|
||||
// The original fixture was the always-rendered "▼ Following" toggle. That
|
||||
// control is retired and the mouse-release button that could have replaced
|
||||
// it lives in the status bar now, so the toast is what stands in — it is
|
||||
// real chrome over the pane, which is the only property under test.
|
||||
await mountWithLayout();
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({
|
||||
payload: Array.from(
|
||||
new TextEncoder().encode(
|
||||
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
|
||||
),
|
||||
),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
const toast = document.querySelector(URL_TOAST_SELECTOR);
|
||||
if (!toast) throw new Error("URL toast not shown");
|
||||
stubElementFromPoint(toast);
|
||||
|
||||
await drop(780, 10);
|
||||
|
||||
@@ -538,3 +585,492 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A container with Playwright *and* a browser in the cache — i.e. one where
|
||||
* "In container" would actually open something.
|
||||
*/
|
||||
function usableDetection(
|
||||
over: Partial<PlaywrightDetection> = {},
|
||||
): PlaywrightDetection {
|
||||
return {
|
||||
node_version: "v22.11.0",
|
||||
playwright_version: "1.56.0",
|
||||
playwright_path: "/workspace/node_modules/playwright",
|
||||
playwright_cli: "/workspace/node_modules/playwright/cli.js",
|
||||
has_bind: true,
|
||||
cli_version: "1.56.0",
|
||||
cli_entry: "/workspace/node_modules/@playwright/cli/index.js",
|
||||
browsers: ["chromium-1200"],
|
||||
chrome_channel: null,
|
||||
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1200/chrome",
|
||||
chromium_executable_exists: true,
|
||||
script_playwright_version: "1.56.0",
|
||||
script_chromium_executable: null,
|
||||
script_chromium_executable_exists: false,
|
||||
searched: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const LIVE_BRIDGE: AuthBridgeStatus = {
|
||||
enabled: true,
|
||||
active_ports: [],
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
describe("chooseSignInTarget — which action leads for a sign-in link", () => {
|
||||
// The rule this replaced was "container, always", justified by the callback
|
||||
// listener living inside the container. Both halves of that justification
|
||||
// stopped being true: the auth bridge mirrors that listener onto the host,
|
||||
// and the container-side target is Playwright's pane, whose browsers are not
|
||||
// in the image.
|
||||
it("prefers the host browser whenever the bridge is live", () => {
|
||||
expect(chooseSignInTarget(LIVE_BRIDGE, usableDetection())).toBe("host-bridged");
|
||||
});
|
||||
|
||||
it("does not call a bridge live while it is holding a port conflict", () => {
|
||||
// Enabled and unable to catch the callback anyway — the one state where
|
||||
// "on" must not read as "will work".
|
||||
const conflicted: AuthBridgeStatus = {
|
||||
enabled: true,
|
||||
active_ports: [],
|
||||
conflicts: [{ port: 54545, reason: "already in use on the host" }],
|
||||
};
|
||||
expect(chooseSignInTarget(conflicted, usableDetection())).toBe("container");
|
||||
});
|
||||
|
||||
it("does not wait for a bridged port before trusting an enabled bridge", () => {
|
||||
// There is nothing to bridge until the CLI binds its listener, and that
|
||||
// races the URL reaching the transcript. Requiring a port would make the
|
||||
// default flip between two identical sign-ins.
|
||||
expect(chooseSignInTarget(LIVE_BRIDGE, null)).toBe("host-bridged");
|
||||
});
|
||||
|
||||
it("falls to the container only when it has a browser to open", () => {
|
||||
const off: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
expect(chooseSignInTarget(off, usableDetection())).toBe("container");
|
||||
// Not plain "host": with the bridge off and no browser inside, nothing is
|
||||
// carrying the callback, and the toast's hint has to say so rather than
|
||||
// promising a bridge. That distinction is the whole reason this answer is
|
||||
// three-valued.
|
||||
expect(chooseSignInTarget(off, null)).toBe("host-fallback");
|
||||
// Packages installed, cache empty — the fresh-project state, and the one
|
||||
// that used to be the silent default.
|
||||
expect(
|
||||
chooseSignInTarget(
|
||||
off,
|
||||
usableDetection({ browsers: [], chromium_executable_exists: false }),
|
||||
),
|
||||
).toBe("host-fallback");
|
||||
// Playwright too old to bind: the pane cannot show it either.
|
||||
expect(chooseSignInTarget(off, usableDetection({ has_bind: false }))).toBe(
|
||||
"host-fallback",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers the host *fallback* when nothing is known at all", () => {
|
||||
// "Unknown" must not read as "bridged". A status call that never answered
|
||||
// is not evidence that something will carry the callback home.
|
||||
expect(chooseSignInTarget(null, null)).toBe("host-fallback");
|
||||
});
|
||||
|
||||
it("separates a live bridge from the least-bad answer, though both lead with the host", () => {
|
||||
const off: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
// The two states the old two-valued answer collapsed together. Folding them
|
||||
// back into one is what let the toast tell a user with the bridge disabled
|
||||
// that the bridge would carry their callback.
|
||||
expect(chooseSignInTarget(LIVE_BRIDGE, null)).not.toBe(
|
||||
chooseSignInTarget(off, null),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — the sign-in default follows the project", () => {
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
function relaySequence(url: string): number[] {
|
||||
return Array.from(
|
||||
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
|
||||
);
|
||||
}
|
||||
|
||||
async function mountWithPrompt() {
|
||||
const view = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: relaySequence(SIGN_IN) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
function primaryLabel(): string | null {
|
||||
return document.querySelector<HTMLElement>(
|
||||
'[data-url-toast-primary="true"]',
|
||||
)?.textContent ?? null;
|
||||
}
|
||||
|
||||
function actionOrder(): (string | null)[] {
|
||||
return Array.from(document.querySelectorAll("button"))
|
||||
.map((b) => b.textContent)
|
||||
.filter((t) => t === "Open" || t === "In container");
|
||||
}
|
||||
|
||||
it("leads with the host browser when the auth bridge is on", async () => {
|
||||
containerEnv.bridge = LIVE_BRIDGE;
|
||||
containerEnv.detection = usableDetection();
|
||||
await mountWithPrompt();
|
||||
expect(primaryLabel()).toBe("Open");
|
||||
// Both are still offered — this changes which leads, never which exist.
|
||||
expect(actionOrder()).toEqual(["Open", "In container"]);
|
||||
});
|
||||
|
||||
it("leads with the container when the bridge is off and a browser is there", async () => {
|
||||
containerEnv.detection = usableDetection();
|
||||
await mountWithPrompt();
|
||||
expect(primaryLabel()).toBe("In container");
|
||||
expect(actionOrder()).toEqual(["In container", "Open"]);
|
||||
});
|
||||
|
||||
it("leads with the host on a fresh project, where neither is set up", async () => {
|
||||
// Playwright is deliberately not baked into the image, so this is what a
|
||||
// project looks like until someone presses install — and pointing the
|
||||
// default at it failed on every platform, silently.
|
||||
await mountWithPrompt();
|
||||
expect(primaryLabel()).toBe("Open");
|
||||
});
|
||||
|
||||
it("does not promise the auth bridge on a project that has it switched off", async () => {
|
||||
// The end-to-end version of the three-state answer: bridge off, no browser
|
||||
// inside. The host still leads, because it is the least bad of two answers
|
||||
// that can both fail — but the hint must not tell the user the bridge is
|
||||
// bringing their callback home, because there is no bridge. That hint is
|
||||
// what sent people to a host browser and a login that hung to its timeout.
|
||||
await mountWithPrompt();
|
||||
const hint = document.querySelector('[data-testid="url-toast-signin-hint"]');
|
||||
expect(hint?.textContent).toMatch(/nothing is set up/i);
|
||||
expect(hint?.textContent).not.toMatch(/what carries the callback/i);
|
||||
});
|
||||
|
||||
it("does promise it when the bridge is actually live", async () => {
|
||||
containerEnv.bridge = LIVE_BRIDGE;
|
||||
await mountWithPrompt();
|
||||
const hint = document.querySelector('[data-testid="url-toast-signin-hint"]');
|
||||
expect(hint?.textContent).toMatch(/auth bridge/i);
|
||||
expect(hint?.textContent).not.toMatch(/nothing is set up/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — a host open that fails says so", () => {
|
||||
const URL = "https://github.com/login/device?code=ABCD-EFGH";
|
||||
|
||||
function relaySequence(url: string): number[] {
|
||||
return Array.from(
|
||||
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
|
||||
);
|
||||
}
|
||||
|
||||
async function mountWithPrompt() {
|
||||
const view = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: relaySequence(URL) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
function openButton(): HTMLElement {
|
||||
const el = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Open",
|
||||
);
|
||||
if (!el) throw new Error("Open button not found");
|
||||
return el as HTMLElement;
|
||||
}
|
||||
|
||||
it("pushes a toast instead of a console line nobody reads", async () => {
|
||||
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
||||
await mountWithPrompt();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton());
|
||||
await Promise.resolve();
|
||||
});
|
||||
const toasts = useAppState.getState().toasts;
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].kind).toBe("error");
|
||||
expect(toasts[0].detail).toContain("no opener");
|
||||
});
|
||||
|
||||
it("keeps the prompt on screen, so the other route is still one click away", async () => {
|
||||
// Dismissing first is what this replaced: the toast vanished, nothing
|
||||
// opened, and the URL only existed in the container's transcript.
|
||||
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
||||
await mountWithPrompt();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton());
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(document.querySelector(URL_TOAST_SELECTOR)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("dismisses the prompt once the handoff actually succeeded", async () => {
|
||||
await mountWithPrompt();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton());
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — an open in flight must not blank a newer prompt", () => {
|
||||
// The window is real and is measured in hundreds of milliseconds, not in
|
||||
// microtasks: on Linux the opener sleeps `OPENER_GRACE` (400 ms, doubled when
|
||||
// `xdg-open` fails and `gio` is tried) before resolving. The container is free
|
||||
// to relay a second URL inside it — a `gh auth login` right after a
|
||||
// `claude login` is the ordinary way that happens — and the toast slot is
|
||||
// shared, so by the time the first open answers the slot may be holding a
|
||||
// prompt the user has never seen. Blanking it loses that URL for good: it
|
||||
// exists nowhere but the container's transcript.
|
||||
const URL_A = "https://github.com/login/device?code=AAAA-1111";
|
||||
const URL_B = "https://claude.ai/oauth/authorize?code=true&client_id=b";
|
||||
|
||||
function relaySequence(url: string): number[] {
|
||||
return Array.from(
|
||||
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
|
||||
);
|
||||
}
|
||||
|
||||
async function emitRelay(url: string) {
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: relaySequence(url) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function openButton(): HTMLElement {
|
||||
const el = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Open",
|
||||
);
|
||||
if (!el) throw new Error("Open button not found");
|
||||
return el as HTMLElement;
|
||||
}
|
||||
|
||||
function promptedUrl(): string | null {
|
||||
return (
|
||||
document
|
||||
.querySelector('[data-testid="url-toast-url"]')
|
||||
?.getAttribute("title") ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** An `openUrlExternal` that hangs until the test lets it finish. */
|
||||
function deferredOpen(): () => void {
|
||||
let finish: () => void = () => {};
|
||||
vi.mocked(openUrlExternal).mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = () => resolve();
|
||||
}),
|
||||
);
|
||||
return () => finish();
|
||||
}
|
||||
|
||||
it("keeps URL B's prompt when A's open resolves after B arrived", async () => {
|
||||
const finishOpen = deferredOpen();
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitRelay(URL_A);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton());
|
||||
});
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(URL_A);
|
||||
|
||||
// The container supersedes it while the opener is still inside its grace.
|
||||
await emitRelay(URL_B);
|
||||
expect(promptedUrl()).toBe(URL_B);
|
||||
|
||||
await act(async () => {
|
||||
finishOpen();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(document.querySelector(URL_TOAST_SELECTOR)).not.toBeNull();
|
||||
expect(promptedUrl()).toBe(URL_B);
|
||||
});
|
||||
|
||||
it("still dismisses when the slot is holding the prompt that was opened", async () => {
|
||||
// The other half of the guard: it must not turn "dismiss on success" into
|
||||
// "never dismiss". Same deferred open, nothing superseding it.
|
||||
const finishOpen = deferredOpen();
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitRelay(URL_A);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton());
|
||||
});
|
||||
expect(document.querySelector(URL_TOAST_SELECTOR)).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
finishOpen();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — focus on request", () => {
|
||||
/** Mount, then deliberately give focus away, so what the assertions below
|
||||
* observe is the *request* taking effect and never the focus `active`
|
||||
* already grants on mount. That distinction is the whole point: the notes
|
||||
* dock sends to a terminal whose tab is already active, where nothing
|
||||
* changes and no `active` effect re-runs. */
|
||||
async function mountAndBlur() {
|
||||
const view = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const elsewhere = document.createElement("button");
|
||||
document.body.appendChild(elsewhere);
|
||||
elsewhere.focus();
|
||||
expect(document.activeElement).toBe(elsewhere);
|
||||
return view;
|
||||
}
|
||||
|
||||
it("focuses the terminal named by the request", async () => {
|
||||
const view = await mountAndBlur();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||
});
|
||||
|
||||
it("ignores a request meant for another session", async () => {
|
||||
const view = await mountAndBlur();
|
||||
const before = document.activeElement;
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s2");
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(before);
|
||||
expect(document.activeElement).not.toBe(helperTextarea(view.container));
|
||||
});
|
||||
|
||||
it("clears the request, so a second send focuses again", async () => {
|
||||
const view = await mountAndBlur();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
|
||||
|
||||
const elsewhere = document.querySelector("button");
|
||||
(elsewhere as HTMLButtonElement).focus();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||
});
|
||||
});
|
||||
describe("TerminalView — releasing a captured mouse", () => {
|
||||
/** Feed raw bytes to the terminal as if the container had printed them, and
|
||||
* let xterm drain its write queue (it parses asynchronously). */
|
||||
async function emitBytes(text: string) {
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: Array.from(new TextEncoder().encode(text)) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
/** What the status bar would render from: the active terminal publishes the
|
||||
* capture state, and the release action, into the store. The control itself
|
||||
* lives in `StatusBar` — deliberately, so it never sits on top of the TUI
|
||||
* that is asking for the mouse. */
|
||||
function captured(): boolean {
|
||||
return useAppState.getState().terminalMouseCaptured;
|
||||
}
|
||||
|
||||
it("shows nothing while the container has not grabbed the mouse", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
|
||||
expect(captured()).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a release control once the container turns mouse tracking on", async () => {
|
||||
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
|
||||
// reported to the app. When the TUI that asked for it dies without
|
||||
// resetting the mode, xterm keeps routing moves to the PTY and drops text
|
||||
// selection — the freeze this control exists to break out of.
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
|
||||
await emitBytes("\x1b[?1003h\x1b[?1006h");
|
||||
|
||||
expect(captured()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the mode locally, without sending a byte to the container", async () => {
|
||||
// The reset is written into xterm's own parser, not onto the wire. The
|
||||
// program inside is usually gone; if it is not, it must not be told the
|
||||
// user pulled the mouse back, or a live TUI would just re-grab it.
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitBytes("\x1b[?1003h");
|
||||
terminalInput.mockClear();
|
||||
|
||||
// Exactly what the status-bar button's onClick does.
|
||||
const release = useAppState.getState().releaseActiveMouse;
|
||||
await act(async () => {
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// The published flag is bound to the live mode, so it going false *is* the
|
||||
// assertion that xterm's mouse tracking is back to "none".
|
||||
expect(captured()).toBe(false);
|
||||
expect(terminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitBytes("\x1b[?1002h");
|
||||
terminalInput.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
key: "X",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(captured()).toBe(false);
|
||||
// The chord must not also reach the container as input.
|
||||
expect(terminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
||||
import {
|
||||
awsSsoRefresh,
|
||||
openPageInContainerBrowser,
|
||||
openUrlExternal,
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
@@ -22,12 +23,14 @@ import {
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||
import UrlToast, {
|
||||
URL_TOAST_PRIMARY_SELECTOR,
|
||||
URL_TOAST_SELECTOR,
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
|
||||
interface Props {
|
||||
@@ -46,6 +49,22 @@ interface Props {
|
||||
*/
|
||||
export type PromptSource = "relay" | UrlSource;
|
||||
|
||||
/**
|
||||
* What the shared prompt slot holds.
|
||||
*
|
||||
* `seq` is identity: the slot is one long-lived place that several prompts pass
|
||||
* through, so "is this still the prompt I acted on?" cannot be answered by the
|
||||
* URL (the same link can legitimately be relayed twice) and must not be
|
||||
* answered by "is anything there?". It keys the toast for remounting *and*
|
||||
* guards the deferred dismissal — see `dismissUrlPromptIfCurrent`.
|
||||
*/
|
||||
interface UrlPrompt {
|
||||
url: string;
|
||||
label: string;
|
||||
source: PromptSource;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
/** Higher wins. Provenance, not recency. */
|
||||
const SOURCE_RANK: Record<PromptSource, number> = {
|
||||
heuristic: 0,
|
||||
@@ -95,9 +114,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
||||
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
||||
|
||||
const ssoBufferRef = useRef("");
|
||||
const ssoTriggeredRef = useRef(false);
|
||||
@@ -128,17 +148,22 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// replacing a first would otherwise mutate the toast in place, swapping the
|
||||
// text under a user who is mid-read and mid-click. Keying the toast on it
|
||||
// remounts the component, so a new URL is unmistakably a new prompt.
|
||||
const [urlPrompt, setUrlPrompt] = useState<{
|
||||
url: string;
|
||||
label: string;
|
||||
source: PromptSource;
|
||||
seq: number;
|
||||
} | null>(null);
|
||||
const [urlPrompt, setUrlPrompt] = useState<UrlPrompt | null>(null);
|
||||
const promptSeqRef = useRef(0);
|
||||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
||||
// Read by the long-lived keyboard listener below, which is registered once
|
||||
// and would otherwise close over the prompt as it was at mount.
|
||||
const urlPromptRef = useRef<{ url: string } | null>(null);
|
||||
/**
|
||||
* A mirror of the prompt slot, written *eagerly* by the two functions that
|
||||
* change it.
|
||||
*
|
||||
* Read by the long-lived keyboard listener below, which is registered once
|
||||
* and would otherwise close over the prompt as it was at mount — and by
|
||||
* {@link dismissUrlPromptIfCurrent}, which is the reason it is written on the
|
||||
* spot rather than from an effect. An effect-synced mirror lags the state it
|
||||
* mirrors by a commit, and the whole question that identity check answers is
|
||||
* "did a new prompt land while I was awaiting?" — a mirror that has not
|
||||
* caught up yet answers it wrong in exactly the window that matters.
|
||||
*/
|
||||
const urlPromptRef = useRef<UrlPrompt | null>(null);
|
||||
|
||||
/**
|
||||
* Empty the prompt slot, and put focus somewhere real if it was inside the
|
||||
@@ -153,10 +178,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
*/
|
||||
const dismissUrlPrompt = useCallback(() => {
|
||||
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
|
||||
urlPromptRef.current = null;
|
||||
setUrlPrompt(null);
|
||||
if (wasInside) termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dismiss, but only if the slot is still holding the prompt the caller
|
||||
* acted on.
|
||||
*
|
||||
* For anything that dismisses *after* awaiting. `openUrlExternal` takes at
|
||||
* least `OPENER_GRACE` (400 ms, doubled when `xdg-open` fails and `gio` is
|
||||
* tried) on Linux by construction, and the container can relay a second,
|
||||
* superseding URL inside that window — at which point the slot has been
|
||||
* remounted with prompt B and an unconditional `setUrlPrompt(null)` blanks
|
||||
* it. The user never sees B, and B exists nowhere but the container's
|
||||
* transcript, which is the exact failure "dismiss on success only" was
|
||||
* introduced to prevent.
|
||||
*
|
||||
* This is a sibling of {@link dismissUrlPrompt} rather than an optional
|
||||
* `expectedSeq` parameter on it, because `dismissUrlPrompt` is handed
|
||||
* straight to `onClick`/`onDismiss`: React would call it with a `MouseEvent`
|
||||
* as its first argument, that event would land in `expectedSeq`, and the ✕
|
||||
* button would silently stop dismissing anything. A parameter that is only
|
||||
* ever correct when nobody passes it by reference is not a safe signature
|
||||
* here.
|
||||
*/
|
||||
const dismissUrlPromptIfCurrent = useCallback(
|
||||
(seq: number) => {
|
||||
if (urlPromptRef.current?.seq !== seq) return;
|
||||
dismissUrlPrompt();
|
||||
},
|
||||
[dismissUrlPrompt],
|
||||
);
|
||||
|
||||
/**
|
||||
* The only writer of the prompt slot. Re-validates whatever the caller
|
||||
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||||
@@ -175,17 +230,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
console.warn("Refusing to prompt for a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
setUrlPrompt((current) => {
|
||||
if (!supersedes({ url, source }, current)) return current;
|
||||
// Read and written through the ref rather than a functional update, so
|
||||
// the mirror is current the instant this returns. Two prompts arriving in
|
||||
// one tick still see each other — that is what the ref being the eager
|
||||
// copy buys — and the seq counter no longer advances inside a state
|
||||
// updater, which React is free to run twice.
|
||||
if (!supersedes({ url, source }, urlPromptRef.current)) return;
|
||||
promptSeqRef.current += 1;
|
||||
return { url, label, source, seq: promptSeqRef.current };
|
||||
});
|
||||
const next: UrlPrompt = { url, label, source, seq: promptSeqRef.current };
|
||||
urlPromptRef.current = next;
|
||||
setUrlPrompt(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
urlPromptRef.current = urlPrompt;
|
||||
}, [urlPrompt]);
|
||||
|
||||
/**
|
||||
* The keyboard route into the toast.
|
||||
@@ -216,14 +273,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, []);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
// Tracks user intent to follow output — only set to false by explicit user
|
||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||||
const autoFollowRef = useRef(true);
|
||||
const lastUserScrollTimeRef = useRef(0);
|
||||
// True while the program in the container holds mouse reporting open (any of
|
||||
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
||||
const [mouseCaptured, setMouseCaptured] = useState(false);
|
||||
const mouseCapturedRef = useRef(false);
|
||||
|
||||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||
// and the unmount-cleanup effect further down).
|
||||
@@ -248,10 +302,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
//
|
||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
||||
// drop on its own account — asking "is this element mine?" once turned every
|
||||
// pixel under that chrome into a permanent dead zone.
|
||||
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
||||
// sibling of the host rather than a child. Nothing painted over the pane
|
||||
// refuses a drop on its own account — asking "is this element mine?" once
|
||||
// turned every pixel under that chrome into a permanent dead zone.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
@@ -312,12 +366,60 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
};
|
||||
}, [sessionId, sendInput]);
|
||||
|
||||
/**
|
||||
* Reconcile the badge with xterm's live mouse-tracking mode.
|
||||
*
|
||||
* There is no event for this, but there does not need to be a poll either:
|
||||
* the mode only ever changes because the container printed a DECSET/DECRST
|
||||
* sequence, so checking once per write covers every transition, exactly when
|
||||
* it happens. The ref gate keeps the common case (mode unchanged, thousands
|
||||
* of writes a second) down to one string comparison and no re-render.
|
||||
*/
|
||||
const syncMouseCapture = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const captured = term.modes.mouseTrackingMode !== "none";
|
||||
if (captured === mouseCapturedRef.current) return;
|
||||
mouseCapturedRef.current = captured;
|
||||
setMouseCaptured(captured);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Take the mouse back from a program that grabbed it and never let go.
|
||||
*
|
||||
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
|
||||
* tracking modes set. xterm goes on routing clicks, drags and — under
|
||||
* `?1003` — every pointer *move* to the PTY, which kills text selection and
|
||||
* floods the prompt with escape bytes. The result reads as a frozen
|
||||
* terminal, and until now the only exit was closing the tab.
|
||||
*
|
||||
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
|
||||
* xterm's own parser and never onto the wire. The program that asked for
|
||||
* tracking is usually already gone; if it is not, telling it the user pulled
|
||||
* the mouse back would only invite it to grab again on its next repaint.
|
||||
*/
|
||||
const releaseMouse = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
// The three tracking modes, then the two encodings they report in. All
|
||||
// five, because a program is free to have set any combination and a
|
||||
// leftover encoding mode outlives the tracking mode that motivated it.
|
||||
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
|
||||
}, [syncMouseCapture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
// Let the user select text even while a program holds the mouse.
|
||||
// xterm's force-selection modifier is Shift everywhere *except* macOS,
|
||||
// where it is Option and is gated behind this option, which defaults to
|
||||
// false — so without this line Mac users have no force-select at all and
|
||||
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
||||
// first. `SelectionService.shouldForceSelection`.
|
||||
macOptionClickForcesSelection: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
@@ -361,7 +463,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
console.warn("Refusing to open a link that failed validation");
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
// Same failure reporting as the toast's Open button — see the long note
|
||||
// on `handleOpenUrl`, including what this catch does *not* catch on
|
||||
// Linux. A click that appears to do nothing is the complaint either way.
|
||||
openUrlExternal(safe).catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open that link in your browser",
|
||||
detail: String(e),
|
||||
// A dead opener fails for every link in the buffer. One card.
|
||||
dedupeKey: "host-open-failed",
|
||||
}),
|
||||
);
|
||||
}, { urlRegex });
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
@@ -388,6 +501,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
useAppState.getState().sttToggle();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
|
||||
// a key because the failure this recovers from is *the pointer not
|
||||
// working* — a control you have to click can be unreachable in exactly
|
||||
// the situation that calls for it.
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
|
||||
releaseMouse();
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||
@@ -413,7 +534,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
!event.isComposing &&
|
||||
sessionTypeRef.current === "claude"
|
||||
) {
|
||||
sendInput(sessionId, "\x1b\r");
|
||||
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
|
||||
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
||||
//
|
||||
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
||||
@@ -491,43 +612,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||
// Captured during capture phase so it fires before xterm's own handler.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
lastUserScrollTimeRef.current = Date.now();
|
||||
if (e.deltaY < 0) {
|
||||
autoFollowRef.current = false;
|
||||
setIsAutoFollow(false);
|
||||
isAtBottomRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
}
|
||||
};
|
||||
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
||||
|
||||
// Track scroll position to show "Jump to Current" button.
|
||||
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
||||
let scrollStateRafId: number | null = null;
|
||||
const scrollDisposable = term.onScroll(() => {
|
||||
const buf = term.buffer.active;
|
||||
const atBottom = buf.viewportY >= buf.baseY;
|
||||
isAtBottomRef.current = atBottom;
|
||||
|
||||
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
||||
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
||||
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
||||
autoFollowRef.current = true;
|
||||
setIsAutoFollow(true);
|
||||
}
|
||||
|
||||
if (scrollStateRafId === null) {
|
||||
scrollStateRafId = requestAnimationFrame(() => {
|
||||
scrollStateRafId = null;
|
||||
setIsAtBottom(isAtBottomRef.current);
|
||||
});
|
||||
}
|
||||
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
||||
// means the session is gone, which the exit listener already reports.
|
||||
sendInput(sessionId, data).catch((e) =>
|
||||
console.error("Failed to send terminal input:", e)
|
||||
);
|
||||
});
|
||||
|
||||
// Track text selection to show copy hint in status bar
|
||||
@@ -592,15 +681,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data, () => {
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
if (!isAtBottomRef.current) {
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Scrolling on new output is xterm's own job, and it already gets it
|
||||
// right: it follows the tail while the viewport is at the bottom and
|
||||
// holds position while you are reading further up. The manual
|
||||
// `scrollToBottom()` that used to live here fought that second half.
|
||||
term.write(data, syncMouseCapture);
|
||||
detector.feed(data);
|
||||
|
||||
// Scan for SSO refresh marker in terminal output
|
||||
@@ -642,11 +727,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = null;
|
||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||
// Whether the viewport was following the tail has to be sampled
|
||||
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
|
||||
// afterwards cannot tell "was at the bottom" from "was pushed off it".
|
||||
const wasAtBottom =
|
||||
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
// Only re-anchor a viewport that was already on the tail. This
|
||||
// observer fires for any pane size change — opening the Notes dock,
|
||||
// dragging the sidebar, resizing the window — and none of those are a
|
||||
// reason to yank someone away from the scrollback they are reading.
|
||||
if (wasAtBottom) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -660,14 +752,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
osc52Disposable.dispose();
|
||||
relayDisposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
selectionDisposable.dispose();
|
||||
setTerminalHasSelection(false);
|
||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||
resizeObserver.disconnect();
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
@@ -684,7 +773,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (active) {
|
||||
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
||||
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
||||
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
||||
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
||||
|
||||
// The renderer and the activation work are independent: a terminal with
|
||||
// GPU rendering switched off still has to fit and take focus when its tab
|
||||
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
||||
// "never re-fit, never focus".
|
||||
if (active && useGpu) {
|
||||
// Attach WebGL renderer
|
||||
if (!webglRef.current) {
|
||||
try {
|
||||
@@ -699,19 +797,38 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
}
|
||||
fitRef.current?.fit();
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
if (webglRef.current) {
|
||||
} else if (webglRef.current) {
|
||||
// Release the context — for inactive terminals, and when the setting
|
||||
// turns GPU rendering off while this terminal is on screen.
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
// Same rule as the resize observer: re-anchor only what was already
|
||||
// anchored, so a tab left scrolled up comes back where it was left.
|
||||
const wasAtBottom =
|
||||
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
fitRef.current?.fit();
|
||||
if (wasAtBottom) term.scrollToBottom();
|
||||
term.focus();
|
||||
}
|
||||
}, [active]);
|
||||
}, [active, gpuRenderingSetting]);
|
||||
|
||||
// Focus on demand, for the caller that cannot rely on the effect above.
|
||||
// That one keys off `active`, so it covers switching *to* a terminal and
|
||||
// nothing else — and the notes dock sends to the terminal already on screen,
|
||||
// where `active` never changes. Consumed once and cleared, so asking twice
|
||||
// for the same terminal works.
|
||||
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
||||
const clearPendingTerminalFocus = useAppState(
|
||||
(s) => s.clearPendingTerminalFocus,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (pendingTerminalFocus !== sessionId) return;
|
||||
termRef.current?.focus();
|
||||
clearPendingTerminalFocus();
|
||||
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||
@@ -734,19 +851,65 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [imagePasteMsg]);
|
||||
|
||||
/**
|
||||
* Hand the prompted URL to the host's browser.
|
||||
*
|
||||
* Two things here are ordering, not decoration:
|
||||
*
|
||||
* - **The toast is dismissed on success only, and only if it is still the
|
||||
* same toast.** Dismissing first is what this replaced: a failed open left
|
||||
* the user with an empty screen and no way back to a URL that only exists
|
||||
* in the container's transcript. Now a failure keeps the prompt exactly
|
||||
* where it was, which also leaves "In container" one click away — the
|
||||
* fallback this failure is the argument for. Waiting to dismiss opens a
|
||||
* second window, though: the open is awaited, the container can relay a
|
||||
* superseding URL while it is in flight, and blanking the slot on success
|
||||
* would then throw away a prompt the user has never seen. Hence the seq
|
||||
* check in `dismissUrlPromptIfCurrent` rather than a bare dismissal.
|
||||
* - **The failure is a toast, not a `console.error`.** Same `pushToast` the
|
||||
* container-browser branch below uses, because from the user's side the
|
||||
* two actions fail identically: nothing happens.
|
||||
*
|
||||
* What this does *not* cover, and must not be described as covering: on Linux
|
||||
* `xdg-open` routinely exits 0 having done nothing useful, so the most common
|
||||
* Linux failure resolves this promise and reports success. Stripping the
|
||||
* leaked AppImage environment before the browser is spawned is what addresses
|
||||
* that; this is the complement that catches everything which does report.
|
||||
*/
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
// Validated again at the sink. `promptUrl` is the only writer and already
|
||||
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||
// precisely when it matters that the last thing before `openUrl` checks.
|
||||
// precisely when it matters that the last thing before the opener checks.
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
dismissUrlPrompt();
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt, dismissUrlPrompt]);
|
||||
// The prompt this click was for. Captured before the await, because the
|
||||
// slot may be holding a different one by the time the opener answers.
|
||||
const openedSeq = urlPrompt.seq;
|
||||
openUrlExternal(safe)
|
||||
.then(() => dismissUrlPromptIfCurrent(openedSeq))
|
||||
.catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open it in your browser",
|
||||
detail: String(e),
|
||||
dedupeKey: "host-open-failed",
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, dismissUrlPrompt, dismissUrlPromptIfCurrent]);
|
||||
|
||||
/**
|
||||
* Which action leads when the prompt is holding an Anthropic sign-in link.
|
||||
*
|
||||
* Resolved per project, not per URL — see `useSignInOpenTarget`. The toast
|
||||
* offers both regardless; this is only which one is filled in and reachable
|
||||
* with {@link URL_TOAST_SHORTCUT}.
|
||||
*/
|
||||
const signInDefault = useSignInOpenTarget(projectId);
|
||||
|
||||
/**
|
||||
* Open the prompted URL in the container's own browser instead of the host's.
|
||||
@@ -759,6 +922,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const handleOpenUrlInContainer = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
// Unconditional, and it needs no seq guard, because it happens *before* the
|
||||
// first await: nothing else can have touched the slot between the click and
|
||||
// this line. The success and failure reports below are toasts rather than
|
||||
// this prompt coming back, so there is nothing here that has to survive the
|
||||
// round trip — which is what makes dismissing up front correct here and
|
||||
// wrong in `handleOpenUrl`. Anything that moves this dismissal after the
|
||||
// `openPageInContainerBrowser` call has to take the seq with it.
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
@@ -793,39 +963,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
);
|
||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
autoFollowRef.current = true;
|
||||
setIsAutoFollow(true);
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
||||
// control, but only while it's the active (visible) terminal.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setTerminalAtBottom(isAtBottom);
|
||||
setScrollActiveToBottom(handleScrollToBottom);
|
||||
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
||||
|
||||
// On unmount, if this was the active terminal, clear the status-bar scroll
|
||||
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
||||
// unmount — the deactivating terminal stays mounted but hidden — so this
|
||||
// only fires when the active session is actually closed.)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeRef.current) {
|
||||
setTerminalAtBottom(true);
|
||||
setScrollActiveToBottom(() => {});
|
||||
}
|
||||
};
|
||||
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
||||
|
||||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -843,20 +980,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleToggleAutoFollow = useCallback(() => {
|
||||
const next = !autoFollowRef.current;
|
||||
autoFollowRef.current = next;
|
||||
setIsAutoFollow(next);
|
||||
if (next) {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
// Surface the capture state and its escape hatch to the status bar, but only
|
||||
// while this is the visible terminal.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setTerminalMouseCaptured(mouseCaptured);
|
||||
setReleaseActiveMouse(releaseMouse);
|
||||
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||
|
||||
// On unmount, if this was the active terminal, clear the status-bar state so
|
||||
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
||||
// the deactivating terminal stays mounted but hidden — so this only fires
|
||||
// when the active session is actually closed.)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeRef.current) {
|
||||
setTerminalMouseCaptured(false);
|
||||
setReleaseActiveMouse(() => {});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -871,6 +1014,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
signInDefault={signInDefault}
|
||||
onDismiss={dismissUrlPrompt}
|
||||
/>
|
||||
)}
|
||||
@@ -882,18 +1026,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
{/* Auto-follow toggle - top right */}
|
||||
<button
|
||||
onClick={handleToggleAutoFollow}
|
||||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||||
isAutoFollow
|
||||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||||
}`}
|
||||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||||
>
|
||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||
</button>
|
||||
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||
FitAddon measures the host element it's mounted into; padding there
|
||||
causes the grid to overhang and clip the rightmost column / bottom
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
|
||||
|
||||
describe("Anthropic sign-in links", () => {
|
||||
// The callback listener a `claude login` is waiting on is *inside* the
|
||||
// container. Sending the user to their host browser completes the sign-in
|
||||
// and then posts the result where nothing is listening, and the terminal
|
||||
// hangs to its timeout — so for these, and only these, the container-side
|
||||
// browser leads.
|
||||
// container, so a sign-in is the one case where the host browser may be the
|
||||
// wrong lead. Whether it actually is depends on the project — a live auth
|
||||
// bridge carries the callback back, and the container-side alternative is
|
||||
// not installed on a fresh project — so the owner decides and passes
|
||||
// `signInDefault`. This component only renders the decision.
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
@@ -180,7 +183,77 @@ describe("UrlToast", () => {
|
||||
.filter((t) => t === "Open" || t === "In container");
|
||||
}
|
||||
|
||||
it("puts the container browser first", () => {
|
||||
it("puts the container browser first when the caller asks for it", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["In container", "Open"]);
|
||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
||||
/callback listener is inside the container/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("leads with the host, and promises the bridge, when the bridge is live", () => {
|
||||
// The pair is unchanged; only the order and which one is filled.
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="host-bridged"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
expect(
|
||||
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
|
||||
).toHaveTextContent("Open");
|
||||
// Still recognised as a sign-in, so the explanation stays — and here the
|
||||
// explanation is true, which is the only state in which it may be given.
|
||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
||||
/the auth bridge is what carries the callback/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the callback has nothing carrying it when the host is the last resort", () => {
|
||||
// `host-fallback`: bridge off or unknown *and* no browser in the
|
||||
// container. The old two-state hint said the auth bridge would carry the
|
||||
// callback here too, which is a false promise — the user opens the link
|
||||
// in their own browser and `claude login` hangs to its timeout with
|
||||
// nothing on screen explaining why.
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="host-fallback"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
// Which button leads does not change — only what the hint claims.
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
expect(
|
||||
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
|
||||
).toHaveTextContent("Open");
|
||||
const hint = screen.getByTestId("url-toast-signin-hint");
|
||||
expect(hint).toHaveTextContent(/nothing is set up to reach it/i);
|
||||
// And it points at the two things that would fix it, since a warning
|
||||
// with no next step is only a nicer way to fail.
|
||||
expect(hint).toHaveTextContent(/Auth bridge/);
|
||||
expect(hint).toHaveTextContent(/install browser support/i);
|
||||
expect(hint).not.toHaveTextContent(/the auth bridge is what carries the callback/i);
|
||||
});
|
||||
|
||||
it("defaults to the least-bad reading when the caller passes nothing", () => {
|
||||
// A caller that says nothing has not told us a bridge is live, so the
|
||||
// hint must not invent one. The host still leads: it is the answer more
|
||||
// likely to work, and the one that reports its own failure.
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
@@ -189,9 +262,9 @@ describe("UrlToast", () => {
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["In container", "Open"]);
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
||||
/callback listener is inside the container/i,
|
||||
/nothing is set up to reach it/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -202,6 +275,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={onOpen}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -211,12 +285,14 @@ describe("UrlToast", () => {
|
||||
|
||||
it("leaves an ordinary URL alone", () => {
|
||||
// A `gh auth login` device code, a docs page, a preview build — the host
|
||||
// browser is the right answer for all of them and stays the default.
|
||||
// browser is the right answer for all of them and stays the default,
|
||||
// whatever the project's sign-in preference happens to be.
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -232,6 +308,7 @@ describe("UrlToast", () => {
|
||||
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
||||
import type { SignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,24 @@ interface Props {
|
||||
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||
* the project has no browser to open it in. */
|
||||
onOpenInContainer?: () => void;
|
||||
/**
|
||||
* Which action leads for a *sign-in* link, and why (see the note below).
|
||||
* Nothing else in the toast moves: both buttons are offered in all three
|
||||
* states, in one of two orders.
|
||||
*
|
||||
* This component does not work it out, because the answer depends on the
|
||||
* project's auth bridge and on what is installed inside its container —
|
||||
* neither of which a presentational component should be reaching for.
|
||||
* `hooks/useSignInOpenTarget.ts` owns the rule.
|
||||
*
|
||||
* Two of the three lead with the host button and differ only in the hint,
|
||||
* which is the whole point of carrying three: `"host-bridged"` may promise
|
||||
* that the auth bridge brings the callback home, `"host-fallback"` may not,
|
||||
* because in that state nothing does. `"host-fallback"` is the default for
|
||||
* that reason — a caller that says nothing has not told us a bridge is live,
|
||||
* and the hint must not invent one.
|
||||
*/
|
||||
signInDefault?: SignInOpenTarget;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -57,17 +76,26 @@ interface Props {
|
||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||
* B that arrived a second later.
|
||||
*
|
||||
* ## Anthropic sign-in links default to the container's browser
|
||||
* ## Anthropic sign-in links get their default from the caller
|
||||
*
|
||||
* For an ordinary URL the host browser is the right answer and stays the
|
||||
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
|
||||
* is waiting on is inside the container, so a host browser completes the sign-in
|
||||
* and then posts the result somewhere nothing is listening, and the terminal
|
||||
* hangs until it times out. Making the host button primary there was quietly
|
||||
* steering every user into that. The container-side browser closes the loop
|
||||
* with no host round trip and no auth bridge, so it leads — and the host button
|
||||
* stays, because a user who has the auth bridge on, or who wants their existing
|
||||
* browser session, still needs it.
|
||||
* default, unconditionally. A sign-in is the one case where it might not be:
|
||||
* the callback listener the CLI is waiting on is inside the container, so a
|
||||
* host browser can complete the sign-in and then post the result where nothing
|
||||
* is listening, leaving the terminal to hang to its timeout.
|
||||
*
|
||||
* *Can*, not *does* — which is why this is no longer decided from the URL. The
|
||||
* auth bridge mirrors that container listener onto the same host port, and the
|
||||
* container-side alternative is Playwright's dashboard pane, which a fresh
|
||||
* project has not installed. Both of those are project facts, so the owner
|
||||
* passes {@link Props.signInDefault} and this only renders it: the leading
|
||||
* button is filled and comes first, the other keeps its place beside it.
|
||||
*
|
||||
* The hint below the URL renders all *three* states, not the two orderings.
|
||||
* "Neither is set up" also leads with the host, but it is not the same claim:
|
||||
* there the callback has nothing carrying it, so the hint names what would fix
|
||||
* that instead of describing a bridge that is off. A two-way hint keyed on
|
||||
* which button leads is exactly how that false promise got shipped.
|
||||
*
|
||||
* ## Reachable without a mouse, and it does not take focus to manage it
|
||||
*
|
||||
@@ -96,6 +124,7 @@ export default function UrlToast({
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
signInDefault = "host-fallback",
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -103,18 +132,27 @@ export default function UrlToast({
|
||||
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
||||
// host button is the only action there is, so it stays primary.
|
||||
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
||||
// A sign-in link the caller has decided is better completed inside the
|
||||
// container. Everything below keys off this rather than off `signIn`, so the
|
||||
// two orderings differ only in which of the pair leads.
|
||||
const containerLeads = signIn && signInDefault === "container";
|
||||
// The third state. Both host states put the same button first, so this is
|
||||
// read by the hint alone: no bridge and no container browser means nothing is
|
||||
// carrying the callback back, and saying "the auth bridge is what carries it"
|
||||
// here is a promise the project cannot keep.
|
||||
const hostIsLastResort = signIn && signInDefault === "host-fallback";
|
||||
|
||||
// `Button` already owns the filled/outlined variants — including the rule
|
||||
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||
// foreground/link accent and fails WCAG AA behind white text.
|
||||
const hostButton = (
|
||||
<Button
|
||||
variant={signIn ? "secondary" : "primary"}
|
||||
data-url-toast-primary={signIn ? undefined : "true"}
|
||||
variant={containerLeads ? "secondary" : "primary"}
|
||||
data-url-toast-primary={containerLeads ? undefined : "true"}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
title={
|
||||
signIn
|
||||
containerLeads
|
||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||
: undefined
|
||||
}
|
||||
@@ -128,8 +166,8 @@ export default function UrlToast({
|
||||
// the container's own loopback, which is where the tool waiting for it is
|
||||
// listening — no host round trip, no auth bridge.
|
||||
<Button
|
||||
variant={signIn ? "primary" : "secondary"}
|
||||
data-url-toast-primary={signIn ? "true" : undefined}
|
||||
variant={containerLeads ? "primary" : "secondary"}
|
||||
data-url-toast-primary={containerLeads ? "true" : undefined}
|
||||
onClick={onOpenInContainer}
|
||||
className="flex-shrink-0"
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
@@ -235,14 +273,16 @@ export default function UrlToast({
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
Sign-in link — the callback listener is inside the container.
|
||||
Opening it there closes the loop; the host browser needs the auth
|
||||
bridge.
|
||||
{containerLeads
|
||||
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
|
||||
: hostIsLastResort
|
||||
? "Sign-in link — the callback listener is inside the container and nothing is set up to reach it. Turn on Auth bridge in the project’s Config tab, or install browser support to sign in inside the container."
|
||||
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signIn ? (
|
||||
{containerLeads ? (
|
||||
<>
|
||||
{containerButton}
|
||||
{hostButton}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import Button from "./Button";
|
||||
|
||||
const onClick = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
|
||||
describe("Button", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("still supports the native disabled attribute", () => {
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first.">
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||
// The reason is a description, not part of the name.
|
||||
expect(button).toHaveAccessibleName("Save");
|
||||
});
|
||||
|
||||
it("guards clicks and Enter/Space while unavailable", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still forwards keys that are not activation keys", () => {
|
||||
render(
|
||||
<Button
|
||||
unavailable
|
||||
unavailableReason="Stop the container first."
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behaves like an ordinary button when available", () => {
|
||||
render(
|
||||
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).toHaveAccessibleDescription("");
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { useUnavailable } from "./unavailable";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
||||
* `unavailableReason` to `aria-describedby` instead of using the native
|
||||
* `disabled` attribute, which would take the button out of the tab order and
|
||||
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
||||
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
||||
* worth telling the user.
|
||||
*/
|
||||
unavailable?: boolean;
|
||||
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
||||
unavailableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real buttons with visible bounds and a ≥24px hit target.
|
||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||
* `--accent` stays reserved for foreground/link use.
|
||||
*
|
||||
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
||||
* `disabled:` variant only matches the native attribute, which `unavailable`
|
||||
* deliberately does not set. Keep the two lists in step.
|
||||
*/
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
secondary:
|
||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
danger:
|
||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
@@ -35,16 +51,30 @@ export default function Button({
|
||||
size = "sm",
|
||||
className = "",
|
||||
type = "button",
|
||||
unavailable = false,
|
||||
unavailableReason = "",
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { controlProps, reasonNode } = useUnavailable({
|
||||
unavailable,
|
||||
reason: unavailableReason,
|
||||
onClick: rest.onClick,
|
||||
onKeyDown: rest.onKeyDown,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type={type}
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
{...controlProps}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{/* Outside the button: inside, the reason would join its accessible name. */}
|
||||
{reasonNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
useId,
|
||||
type KeyboardEventHandler,
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/** Keys a native `<button>` turns into a click. */
|
||||
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
||||
|
||||
export interface UnavailableControlProps {
|
||||
"aria-disabled"?: true;
|
||||
"aria-describedby"?: string;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface UnavailableControl {
|
||||
/** Spread onto the control. Carries the guarded handlers. */
|
||||
controlProps: UnavailableControlProps;
|
||||
/**
|
||||
* Render as a *sibling* of the control — inside it the reason would be
|
||||
* appended to the accessible name instead of the description.
|
||||
*/
|
||||
reasonNode: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a control unavailable without hiding it from assistive technology.
|
||||
*
|
||||
* `disabled` takes an element out of the tab order *and* out of the
|
||||
* accessibility tree, so the `title` explaining why it cannot be used is
|
||||
* announced to nobody and shown only to a sighted user with a mouse. That is
|
||||
* backwards: the people who most need the reason are the ones who never get
|
||||
* it. `aria-disabled` keeps the control focusable and announced, and
|
||||
* `aria-describedby` hands over the reason.
|
||||
*
|
||||
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
||||
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
||||
* along with the attributes, so a call site cannot take the announcement
|
||||
* without the guard. Handlers that a form can reach without going through the
|
||||
* control (Enter inside a text field submits the form) still have to guard
|
||||
* themselves.
|
||||
*/
|
||||
export function useUnavailable({
|
||||
unavailable,
|
||||
reason,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: {
|
||||
unavailable: boolean;
|
||||
reason: string;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
}): UnavailableControl {
|
||||
const reasonId = `${useId()}unavailable`;
|
||||
|
||||
if (!unavailable) {
|
||||
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
||||
}
|
||||
|
||||
return {
|
||||
controlProps: {
|
||||
"aria-disabled": true,
|
||||
"aria-describedby": reasonId,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (!ACTIVATION_KEYS.has(e.key)) {
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
// Suppress the default action before it can become a click, submit a
|
||||
// form, or scroll the page.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
reasonNode: (
|
||||
<span id={reasonId} className="sr-only">
|
||||
{reason}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useNotes } from "./useNotes";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { Note } from "../lib/types";
|
||||
|
||||
const listNotes = vi.fn();
|
||||
const saveNote = vi.fn();
|
||||
const deleteNote = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listNotes: (p: string) => listNotes(p),
|
||||
saveNote: (p: string, n: Note) => saveNote(p, n),
|
||||
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
|
||||
const toasts = () => useAppState.getState().toasts;
|
||||
|
||||
/**
|
||||
* A stand-in for the Rust store: one list per project, upsert and delete
|
||||
* applied to it, `list_notes` reading it back. Several of these tests are about
|
||||
* what the *list* looks like after a sequence of writes, which a per-call
|
||||
* `mockResolvedValueOnce` cannot express.
|
||||
*/
|
||||
function fakeBackend(initial: Record<string, Note[]> = {}) {
|
||||
const files: Record<string, Note[]> = { ...initial };
|
||||
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
|
||||
saveNote.mockImplementation(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.mockImplementation(async (p: string, id: string) => {
|
||||
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// The cache is shared app state now, so it has to be reset like any other.
|
||||
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||
listNotes.mockResolvedValue([note()]);
|
||||
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||
deleteNote.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("useNotes", () => {
|
||||
it("loads a project's notes on mount", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listNotes).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a failed save instead of swallowing it", async () => {
|
||||
// Silent save failure is data loss: the user sees their text on screen and
|
||||
// believes it is stored. Same reason `useSaveState` exists.
|
||||
saveNote.mockRejectedValueOnce(new Error("disk full"));
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.saveState.status).toBe("failed");
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces the saved note in place rather than appending", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Mock the re-read to return the edited note
|
||||
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
expect(result.current.notes[0].body).toBe("edited");
|
||||
});
|
||||
|
||||
it("drops a deleted note from the list", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNote("n1");
|
||||
});
|
||||
|
||||
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not load anything for an empty project id", async () => {
|
||||
// The dock renders with no project selected; it must not fire a command
|
||||
// for the empty string.
|
||||
renderHook(() => useNotes(""));
|
||||
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Change to a different project before the new fetch resolves
|
||||
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
// The old notes should be cleared immediately
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves no stale notes on screen when a load fails", async () => {
|
||||
listNotes.mockResolvedValueOnce([note()]);
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Switch to a project whose load fails
|
||||
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
||||
rerender({ projectId: "p2" });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ends with the list the backend returned when saving a new note", async () => {
|
||||
// Initially one note
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
||||
const newNote = note({ id: "n2", title: "New" });
|
||||
listNotes.mockResolvedValueOnce([newNote, note()]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(newNote);
|
||||
});
|
||||
|
||||
expect(result.current.notes).toHaveLength(2);
|
||||
expect(result.current.notes[0].id).toBe("n2");
|
||||
});
|
||||
|
||||
it("re-reads the list after a successful save rather than patching in place", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const callCountBefore = listNotes.mock.calls.length;
|
||||
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
// listNotes should be called again after the save
|
||||
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||
});
|
||||
|
||||
it("does not overwrite the new project's notes when a stale save resolves", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes[0].id).toBe("n1");
|
||||
|
||||
// Start a save for p1 that hangs
|
||||
let resolveSave: ((note: Note) => void) | undefined;
|
||||
saveNote.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let savePromise: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
savePromise = result.current.saveNote(note({ id: "n1" }));
|
||||
});
|
||||
|
||||
// Switch to p2 while the save is in flight
|
||||
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
|
||||
rerender({ projectId: "p2" });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Now p2's note should be displayed
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
expect(result.current.notes[0].id).toBe("n2");
|
||||
|
||||
// Resolve the stale p1 save
|
||||
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
|
||||
await act(async () => {
|
||||
resolveSave?.(note({ id: "n1", body: "edited" }));
|
||||
await savePromise;
|
||||
});
|
||||
|
||||
// p2's note should still be displayed, not p1's
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
expect(result.current.notes[0].id).toBe("n2");
|
||||
});
|
||||
|
||||
it("keeps the notes already on screen when a refresh fails", async () => {
|
||||
// The second surface mounting for a project is a refresh behind a list the
|
||||
// user is already reading. One shared cache means a failed refresh would
|
||||
// otherwise blank both panels.
|
||||
fakeBackend({ p1: [note()] });
|
||||
const tab = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||
|
||||
listNotes.mockRejectedValueOnce(new Error("read failed"));
|
||||
const dock = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
|
||||
expect(tab.result.current.notes).toHaveLength(1);
|
||||
expect(dock.result.current.notes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not report the old project's save on the new project's indicator", async () => {
|
||||
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
|
||||
// tells the user their *current* project was written when it was not.
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let resolveSave: ((n: Note) => void) | undefined;
|
||||
saveNote.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveSave = resolve)),
|
||||
);
|
||||
let savePromise: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
savePromise = result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
rerender({ projectId: "p2" });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
resolveSave?.(note({ body: "edited" }));
|
||||
await savePromise;
|
||||
});
|
||||
|
||||
expect(result.current.saveState.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("still reports a save on the indicator of the project it was made for", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(result.current.saveState.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
|
||||
// Clicking Delete while the textarea has focus fires blur first, so a save
|
||||
// and a delete go out back to back. The Rust write lock stops them
|
||||
// interleaving but does not order them: a delete that wins the lock is
|
||||
// undone by the upsert behind it, and the note comes back on next load.
|
||||
const files = fakeBackend({ p1: [note()] });
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const order: string[] = [];
|
||||
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
order.push("save");
|
||||
files[p] = [n];
|
||||
return n;
|
||||
});
|
||||
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
|
||||
order.push("delete");
|
||||
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const save = result.current.saveNote(note({ body: "typo fixed" }));
|
||||
const del = result.current.deleteNote("n1");
|
||||
await Promise.all([save, del]);
|
||||
});
|
||||
|
||||
expect(order).toEqual(["save", "delete"]);
|
||||
expect(files.p1).toHaveLength(0);
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a new note when another note is saved right after it", async () => {
|
||||
// A purely local draft used to be wiped by the next re-read: two clicks of
|
||||
// "New note", type in the second, blur, and the first row was gone.
|
||||
const files = fakeBackend({ p1: [note()] });
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let first: Note | null = null;
|
||||
await act(async () => {
|
||||
first = await result.current.createNote();
|
||||
await result.current.createNote();
|
||||
});
|
||||
expect(result.current.notes).toHaveLength(3);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(result.current.notes).toHaveLength(3);
|
||||
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
|
||||
expect(files.p1).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shares one cache between every hook watching the same project", async () => {
|
||||
// The Project Home sub-tab and the dock both mount a panel for the same
|
||||
// project. Two caches meant an edit in one was invisible to the other, and
|
||||
// the other's next blur wrote its stale copy back over it.
|
||||
fakeBackend({ p1: [note()] });
|
||||
const tab = renderHook(() => useNotes("p1"));
|
||||
const dock = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||
await waitFor(() => expect(dock.result.current.loading).toBe(false));
|
||||
|
||||
// One read for both — the in-flight flag is per project, not per hook.
|
||||
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await dock.result.current.saveNote(note({ body: "written in the dock" }));
|
||||
});
|
||||
|
||||
expect(tab.result.current.notes[0].body).toBe("written in the dock");
|
||||
expect(tab.result.current.notes).toBe(dock.result.current.notes);
|
||||
});
|
||||
|
||||
it("does not blank an already-loaded list when a second panel mounts", async () => {
|
||||
fakeBackend({ p1: [note()] });
|
||||
const tab = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||
|
||||
const dock = renderHook(() => useNotes("p1"));
|
||||
// No "Loading notes…" flash on the second surface.
|
||||
expect(dock.result.current.loading).toBe(false);
|
||||
expect(dock.result.current.notes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
|
||||
// One gesture, two requests. The tab is already loaded; the user clicks the
|
||||
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
|
||||
// dock's mount → `list_notes` are issued in the same tick. The save's
|
||||
// re-read writes the post-save list; the mount's read — issued earlier,
|
||||
// still in flight — must not then land its pre-save snapshot on top of it.
|
||||
const files = fakeBackend({ p1: [note({ body: "before" })] });
|
||||
const tab = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||
expect(tab.result.current.notes[0].body).toBe("before");
|
||||
|
||||
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
|
||||
// and hangs, standing in for a plain read that is slower than
|
||||
// `save_note`'s double-fsync write plus the re-read behind it.
|
||||
let releaseMountRead: (() => void) | undefined;
|
||||
listNotes.mockImplementationOnce(async (p: string) => {
|
||||
const preSave = [...(files[p] ?? [])];
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseMountRead = resolve;
|
||||
});
|
||||
return preSave;
|
||||
});
|
||||
const dock = renderHook(() => useNotes("p1"));
|
||||
expect(releaseMountRead).toBeDefined();
|
||||
|
||||
// The save and its re-read complete while that read is still out.
|
||||
await act(async () => {
|
||||
await tab.result.current.saveNote(note({ body: "after" }));
|
||||
});
|
||||
expect(tab.result.current.notes[0].body).toBe("after");
|
||||
|
||||
// Now the stale read lands.
|
||||
await act(async () => {
|
||||
releaseMountRead!();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(tab.result.current.notes[0].body).toBe("after");
|
||||
expect(dock.result.current.notes[0].body).toBe("after");
|
||||
});
|
||||
|
||||
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
|
||||
// The other half of the same ordering rule: a confirmed delete is newer
|
||||
// than any read issued before it finished, so the read's pre-delete list
|
||||
// must not be written back over the shortened one.
|
||||
const files = fakeBackend({ p1: [note()] });
|
||||
const tab = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||
|
||||
let releaseMountRead: (() => void) | undefined;
|
||||
listNotes.mockImplementationOnce(async (p: string) => {
|
||||
const preDelete = [...(files[p] ?? [])];
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseMountRead = resolve;
|
||||
});
|
||||
return preDelete;
|
||||
});
|
||||
const dock = renderHook(() => useNotes("p1"));
|
||||
expect(releaseMountRead).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
await tab.result.current.deleteNote("n1");
|
||||
});
|
||||
expect(tab.result.current.notes).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
releaseMountRead!();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(tab.result.current.notes).toHaveLength(0);
|
||||
expect(dock.result.current.notes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type { Note } from "../lib/types";
|
||||
import type { SaveState } from "./useSaveState";
|
||||
import { useAppState } from "../store/appState";
|
||||
|
||||
/** A blank note, ordered to the top so the user can start typing immediately. */
|
||||
function draft(): Note {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
// The backend keeps whatever id it is handed for a note it has not seen,
|
||||
// so this one is the note's real id from the first save onward.
|
||||
id: crypto.randomUUID(),
|
||||
title: "",
|
||||
body: "",
|
||||
pinned: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable empty list, so a project with nothing cached does not re-render on identity. */
|
||||
const NO_NOTES: Note[] = [];
|
||||
|
||||
/**
|
||||
* Per-project mutation chain.
|
||||
*
|
||||
* A project's writes are serialised so that two of them cannot be in flight at
|
||||
* once. The Rust `write_lock` stops an upsert and a delete *interleaving*; it
|
||||
* does not order them, and the order is the part that matters here. Clicking
|
||||
* Delete while the textarea has focus fires `blur` first, so `save_note` and
|
||||
* `delete_note` are issued back to back — and if the delete wins the lock, the
|
||||
* upsert behind it re-inserts the note and it comes back on the next load.
|
||||
* "Fix a typo, decide the note is useless, delete it" is an ordinary sequence.
|
||||
*
|
||||
* Module scope, not hook scope, for the reason `useTerminal`'s input queue is:
|
||||
* several components call `useNotes` for the same project (the Project Home
|
||||
* tab and the dock), and a per-hook chain would give each its own ordering and
|
||||
* leave them racing each other — which is the bug, not the fix.
|
||||
*/
|
||||
const mutationChains = new Map<string, Promise<unknown>>();
|
||||
|
||||
function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T> {
|
||||
const previous = mutationChains.get(projectId) ?? Promise.resolve();
|
||||
// `run` on both arms: a failed mutation must not stall every later one.
|
||||
const result = previous.then(run, run);
|
||||
const tail = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
mutationChains.set(projectId, tail);
|
||||
void tail.then(() => {
|
||||
// Drop the entry once idle, so closed projects do not accumulate.
|
||||
if (mutationChains.get(projectId) === tail) mutationChains.delete(projectId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project write ordering for the shared notes cache.
|
||||
*
|
||||
* `mutationChains` orders a project's *writes* against each other. It says
|
||||
* nothing about reads, and the mount load is a read that runs outside it — so
|
||||
* one gesture can put two requests in flight at once and let the slower one
|
||||
* win. Clicking the dock toggle with the textarea focused fires `blur` →
|
||||
* `saveNote` and the dock's mount → `list_notes` in the same tick: the save
|
||||
* finishes, its re-read writes the post-save list, and then the mount's read —
|
||||
* issued earlier, still out — lands its pre-save snapshot on top. Both panels
|
||||
* show stale text until something else refreshes. It needs the plain read to
|
||||
* be slower than `save_note`'s double-fsync write plus a second read, so it is
|
||||
* narrow, but it was reproduced.
|
||||
*
|
||||
* The fix is a sequence number rather than a chain, because the two requests
|
||||
* are not competing for a resource — the loser's result is simply *older*, and
|
||||
* the cheapest correct thing to do with it is throw it away. Every write
|
||||
* claims a sequence when the request behind it is issued, and `commitNotes`
|
||||
* drops one whose sequence predates what is already cached. That also closes a
|
||||
* hole identity comparison cannot: on a `p1 → p2 → p1` switch a read from the
|
||||
* *first* p1 era is indistinguishable from a current one by project id, and
|
||||
* would land its stale list on the second era's.
|
||||
*
|
||||
* Note what this deliberately does **not** replace. `isCurrent()` asks whether
|
||||
* this *panel* is still showing the project a save was made for, which governs
|
||||
* a per-panel `SaveIndicator` and not the shared cache at all; a per-project
|
||||
* counter cannot answer it. Ordering and panel identity are two questions, and
|
||||
* they keep two guards.
|
||||
*
|
||||
* Entries are two integers per project and are never pruned: they must outlive
|
||||
* every request that could still land, and the map is monotone, so a stale
|
||||
* sequence can never be reissued.
|
||||
*/
|
||||
const notesSequences = new Map<string, { issued: number; committed: number }>();
|
||||
|
||||
function sequenceFor(projectId: string): { issued: number; committed: number } {
|
||||
let seq = notesSequences.get(projectId);
|
||||
if (!seq) notesSequences.set(projectId, (seq = { issued: 0, committed: 0 }));
|
||||
return seq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the sequence for a write about to be issued.
|
||||
*
|
||||
* Called immediately before the request whose result it will commit, so that
|
||||
* ordering is by *issue* time. Resolution order is exactly what cannot be
|
||||
* trusted here.
|
||||
*/
|
||||
function issueNotesWrite(projectId: string): number {
|
||||
const seq = sequenceFor(projectId);
|
||||
seq.issued += 1;
|
||||
return seq.issued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a list into the cache under the sequence it was issued at, unless
|
||||
* something newer has already been committed.
|
||||
*
|
||||
* A local patch — the filter behind a confirmed delete, say — is authoritative
|
||||
* at the moment it applies rather than derived from an earlier read, so it
|
||||
* claims its sequence here: `issued` is never below `committed`, so a freshly
|
||||
* claimed one always wins, and anything still in flight behind it is correctly
|
||||
* treated as stale.
|
||||
*/
|
||||
function commitNotes(projectId: string, seq: number, notes: Note[]): boolean {
|
||||
const sequence = sequenceFor(projectId);
|
||||
if (seq <= sequence.committed) return false;
|
||||
sequence.committed = seq;
|
||||
useAppState.getState().setProjectNotes(projectId, notes);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the canonical list into the shared cache.
|
||||
*
|
||||
* A successful save stamps a new `updated_at` and the backend sorts on it, so
|
||||
* the record's position has changed and positional patching would disagree
|
||||
* with what a reload would show. The backend owns the order; the webview never
|
||||
* sorts. A failed re-read leaves the cache alone rather than clearing it.
|
||||
*
|
||||
* `true` means "the cache is current", which is why a superseded commit still
|
||||
* returns it: whatever beat this read was issued later and therefore read the
|
||||
* same write or a later one.
|
||||
*/
|
||||
async function refresh(projectId: string): Promise<boolean> {
|
||||
const seq = issueNotesWrite(projectId);
|
||||
try {
|
||||
const reloaded = await commands.listNotes(projectId);
|
||||
commitNotes(projectId, seq, reloaded);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A project's notes, cached from the backend.
|
||||
*
|
||||
* The backend is the source of truth and the zustand slice is the cache —
|
||||
* every mutation goes through a command and the returned list replaces the
|
||||
* cached one, so the list can never drift from the file. The cache lives in
|
||||
* the store rather than in this hook because two surfaces show the same
|
||||
* project's notes at once; see `notesByProject`.
|
||||
*
|
||||
* `saveState` is deliberately *not* shared: it is this panel's report of this
|
||||
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
|
||||
* silently is a user staring at text they believe is stored.
|
||||
*/
|
||||
export function useNotes(projectId: string) {
|
||||
const cached = useAppState((s) => s.notesByProject[projectId]);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentProjectId = useRef(projectId);
|
||||
currentProjectId.current = projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
// Read through `getState` rather than through subscribed values: the
|
||||
// effect must fire once per project, not again every time the flag it sets
|
||||
// changes. Two panels mounting for the same project therefore make one
|
||||
// read, and the second renders from the cache with no loading flash.
|
||||
const store = useAppState.getState();
|
||||
if (store.notesLoading[projectId]) return;
|
||||
store.setNotesLoading(projectId, true);
|
||||
const seq = issueNotesWrite(projectId);
|
||||
commands
|
||||
.listNotes(projectId)
|
||||
.then((loaded) => {
|
||||
commitNotes(projectId, seq, loaded);
|
||||
})
|
||||
.catch((e) => {
|
||||
// A project that has never been read caches the empty list, so a panel
|
||||
// does not sit on "Loading notes…" forever. One that *has* been read
|
||||
// keeps what it has: this load is a refresh behind a list already on
|
||||
// screen — the second surface mounting, say — and a failed refresh
|
||||
// must not blank both of them. Same rule as `refresh()`. Neither
|
||||
// branch has a stale-project hazard, because the write is keyed by the
|
||||
// project it belongs to.
|
||||
//
|
||||
// The commit goes under this read's own sequence, not a fresh one: a
|
||||
// *later* read still in flight has the newer answer and must not be
|
||||
// dropped in favour of this failure's empty list.
|
||||
if (useAppState.getState().notesByProject[projectId] === undefined) {
|
||||
commitNotes(projectId, seq, []);
|
||||
}
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not load notes for this project",
|
||||
detail: String(e),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
useAppState.getState().setNotesLoading(projectId, false);
|
||||
});
|
||||
}, [projectId, pushToast]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// The indicator belongs to whatever project this panel is showing *now*.
|
||||
// Without this, switching project mid-save leaves the new project's
|
||||
// SaveIndicator stuck on the old project's "Saving…" — the same wrong-project
|
||||
// report as flashing its "Saved ✓", just in the other direction.
|
||||
useEffect(() => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
setSaveState({ status: "idle", error: null });
|
||||
}, [projectId]);
|
||||
|
||||
/**
|
||||
* Whether this hook is still looking at the project a queued mutation was
|
||||
* issued for. Only the *reporting* is gated on it — the cache write is not,
|
||||
* because it is keyed by project and belongs to that project either way.
|
||||
* Without this, the new project's SaveIndicator flashes "Saved ✓" for the
|
||||
* old project's write.
|
||||
*/
|
||||
const isCurrent = useCallback(
|
||||
() => currentProjectId.current === projectId,
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const succeeded = useCallback(() => {
|
||||
setSaveState({ status: "saved", error: null });
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(
|
||||
() => setSaveState({ status: "idle", error: null }),
|
||||
2500,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const saveNote = useCallback(
|
||||
(note: Note) =>
|
||||
enqueueMutation(projectId, async () => {
|
||||
if (isCurrent()) {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
setSaveState({ status: "saving", error: null });
|
||||
}
|
||||
try {
|
||||
await commands.saveNote(projectId, note);
|
||||
await refresh(projectId);
|
||||
if (isCurrent()) succeeded();
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = String(e);
|
||||
if (isCurrent()) setSaveState({ status: "failed", error: message });
|
||||
// The toast is not project-scoped — it names the failure and stays
|
||||
// readable after a switch — so it fires either way.
|
||||
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
[projectId, pushToast, succeeded, isCurrent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a note by persisting it, rather than holding it locally until the
|
||||
* first blur.
|
||||
*
|
||||
* The draft used to live only in the list, which meant any *other* note
|
||||
* being saved replaced the list with the backend's and the unsaved draft
|
||||
* silently vanished — click "New note" twice, type in the second, blur, and
|
||||
* the first row is gone. Sharing one cache between two surfaces makes that
|
||||
* worse rather than better: a local-only row would exist in whichever panel
|
||||
* created it and nowhere else. Letting the backend own the row from the
|
||||
* start removes the whole class: there is no such thing as a note in the
|
||||
* list that the file does not have.
|
||||
*/
|
||||
const createNote = useCallback(
|
||||
() =>
|
||||
enqueueMutation(projectId, async () => {
|
||||
const note = draft();
|
||||
try {
|
||||
const saved = await commands.saveNote(projectId, note);
|
||||
if (!(await refresh(projectId))) {
|
||||
// The note exists; only the re-read failed. Show it rather than
|
||||
// leaving the user with a button that did nothing visible.
|
||||
const store = useAppState.getState();
|
||||
commitNotes(projectId, issueNotesWrite(projectId), [
|
||||
saved,
|
||||
...(store.notesByProject[projectId] ?? []),
|
||||
]);
|
||||
}
|
||||
return saved;
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not create note",
|
||||
detail: String(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
const deleteNote = useCallback(
|
||||
(noteId: string) =>
|
||||
enqueueMutation(projectId, async () => {
|
||||
try {
|
||||
await commands.deleteNote(projectId, noteId);
|
||||
const store = useAppState.getState();
|
||||
commitNotes(
|
||||
projectId,
|
||||
issueNotesWrite(projectId),
|
||||
(store.notesByProject[projectId] ?? []).filter((n) => n.id !== noteId),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
return {
|
||||
notes: cached ?? NO_NOTES,
|
||||
// Only "loading" before the project has ever been read — never on a
|
||||
// refresh behind a list that is already on screen, and never on the second
|
||||
// panel to mount for a project the first one already fetched. A failed
|
||||
// load caches the empty list, so this cannot latch on.
|
||||
loading: Boolean(projectId) && cached === undefined,
|
||||
saveState,
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
};
|
||||
}
|
||||
@@ -36,5 +36,9 @@ export function useSettings() {
|
||||
appSettings,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
/** For a command that already returns the new `AppSettings` itself
|
||||
* (settings import) — updates the store without a redundant
|
||||
* `updateSettings` round trip through the backend. */
|
||||
setAppSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
checkBrowserViewSupport,
|
||||
getAuthBridgeStatus,
|
||||
} from "../lib/tauri-commands";
|
||||
import { canOpenPageInContainerBrowser } from "../lib/browserViewSupport";
|
||||
import type {
|
||||
AuthBridgeChangedEvent,
|
||||
AuthBridgeStatus,
|
||||
PlaywrightDetection,
|
||||
} from "../lib/types";
|
||||
|
||||
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
|
||||
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
|
||||
|
||||
/**
|
||||
* Which of the URL toast's two buttons should lead for a sign-in link — and,
|
||||
* for the host, *why*.
|
||||
*
|
||||
* Three states rather than two because "host" covers two worlds that are not
|
||||
* the same promise to the user:
|
||||
*
|
||||
* - `host-bridged` — the auth bridge is live, so a sign-in completed in the
|
||||
* user's own browser has its callback carried back to the listener inside
|
||||
* the container. The host is genuinely the better answer here.
|
||||
* - `container` — no bridge, but the container has a browser to open, which
|
||||
* closes the loop locally with nothing crossing to the host.
|
||||
* - `host-fallback` — neither. The host is the *least bad* of two answers
|
||||
* that can both fail, and the toast has to say so: a hint claiming the
|
||||
* bridge will carry the callback is a false promise in this state, and the
|
||||
* user's `claude login` hangs to its timeout with nothing explaining why.
|
||||
*
|
||||
* Only `container` changes which button leads; the split between the two host
|
||||
* states exists so the toast's hint can tell the truth. Keep it that way — the
|
||||
* consumer that folds them back together is the bug this replaced.
|
||||
*/
|
||||
export type SignInOpenTarget = "host-bridged" | "container" | "host-fallback";
|
||||
|
||||
/**
|
||||
* Whether the auth bridge can be relied on to catch a callback for this
|
||||
* project.
|
||||
*
|
||||
* Deliberately **not** gated on `active_ports` being non-empty. There is only
|
||||
* something to bridge once the CLI has bound its callback listener, and the
|
||||
* order in which that happens against the URL landing in the transcript is not
|
||||
* ours to control — requiring a port here would make the answer depend on a
|
||||
* race and flip the default button between two otherwise identical sign-ins.
|
||||
* `enabled` is the durable fact: the poller is watching, and it will mirror the
|
||||
* port the moment it appears.
|
||||
*
|
||||
* A conflict is the exception, because it is the one state where the bridge is
|
||||
* on and nevertheless *cannot* catch the callback — the host port it needed was
|
||||
* already taken. That is precisely when the container-side browser is the
|
||||
* better default, so it must not read as live.
|
||||
*/
|
||||
export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
|
||||
if (!status || !status.enabled) return false;
|
||||
return status.conflicts.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule, as a pure function of the two things it depends on.
|
||||
*
|
||||
* Both host answers land on the same button, for different reasons — and they
|
||||
* are deliberately *not* the same value:
|
||||
*
|
||||
* - With the bridge live (`host-bridged`), the host browser is strictly
|
||||
* better — it is the user's own signed-in profile, and the callback still
|
||||
* reaches the container.
|
||||
* - With neither available (`host-fallback`), the host is the *more likely to
|
||||
* work* of two imperfect answers, and it is the one that reports its own
|
||||
* failure (see `handleOpenUrl` in `TerminalView`). The container-side target
|
||||
* is Playwright's dashboard pane, and Playwright's browsers are not baked
|
||||
* into the image, so on a fresh project pointing there fails on every
|
||||
* platform after a several-second wait. Nothing carries the callback back in
|
||||
* this state, so the toast says so rather than promising the bridge.
|
||||
*
|
||||
* Whichever way it goes, both buttons stay in the toast. This chooses which one
|
||||
* leads, never which ones exist.
|
||||
*/
|
||||
export function chooseSignInTarget(
|
||||
bridge: AuthBridgeStatus | null,
|
||||
detection: PlaywrightDetection | null,
|
||||
): SignInOpenTarget {
|
||||
if (authBridgeIsLive(bridge)) return "host-bridged";
|
||||
if (canOpenPageInContainerBrowser(detection)) return "container";
|
||||
return "host-fallback";
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a Playwright probe is reused for.
|
||||
*
|
||||
* `check_browser_view_support` is a `docker exec` running a Node probe, and
|
||||
* every terminal tab of a project would otherwise run its own on mount. Five
|
||||
* minutes is long enough that opening a handful of tabs costs one exec, and
|
||||
* short enough that pressing "Set up Playwright" in the Browser tab is
|
||||
* reflected in the default before the user has finished reading the result.
|
||||
*/
|
||||
const DETECTION_TTL_MS = 5 * 60_000;
|
||||
|
||||
const detectionCache = new Map<
|
||||
string,
|
||||
{ at: number; probe: Promise<PlaywrightDetection | null> }
|
||||
>();
|
||||
|
||||
/** The shared, rate-limited probe. Never rejects — "didn't answer" is `null`. */
|
||||
function probeBrowserSupport(projectId: string): Promise<PlaywrightDetection | null> {
|
||||
const hit = detectionCache.get(projectId);
|
||||
if (hit && Date.now() - hit.at < DETECTION_TTL_MS) return hit.probe;
|
||||
const probe = checkBrowserViewSupport(projectId).catch(() => {
|
||||
// A failure is usually a stopped container, which is a state the user
|
||||
// leaves — so it is not worth remembering for five minutes.
|
||||
detectionCache.delete(projectId);
|
||||
return null;
|
||||
});
|
||||
detectionCache.set(projectId, { at: Date.now(), probe });
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Test seam: drops the memoized probes so a case starts from nothing. */
|
||||
export function resetBrowserSupportCache(): void {
|
||||
detectionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default action for Anthropic sign-in links in this project.
|
||||
*
|
||||
* Resolved at mount rather than when a URL arrives, on purpose: the toast has
|
||||
* two buttons side by side, and a default that settles a second after the
|
||||
* toast appears moves them under a mouse that is already travelling.
|
||||
*
|
||||
* The expensive half is only paid when it can change the answer. The bridge
|
||||
* status is host-side and cheap; the Playwright probe is a container exec, and
|
||||
* a live bridge decides the question before it is ever asked — which, with the
|
||||
* bridge now on by default, is the ordinary case.
|
||||
*/
|
||||
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
|
||||
// `host-fallback` is the honest starting point, not `host-bridged`: before
|
||||
// the status call answers, nothing is known to be carrying the callback, and
|
||||
// the hint that claims one is the failure this three-state answer exists to
|
||||
// prevent. Over-warning for the moment before the answer arrives costs a line
|
||||
// of hedged text; under-warning costs a login that hangs to its timeout.
|
||||
const [target, setTarget] = useState<SignInOpenTarget>("host-fallback");
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setTarget("host-fallback");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let bridge: AuthBridgeStatus | null = null;
|
||||
let detection: PlaywrightDetection | null = null;
|
||||
|
||||
const settle = () => {
|
||||
if (!cancelled) setTarget(chooseSignInTarget(bridge, detection));
|
||||
};
|
||||
|
||||
const consider = (next: AuthBridgeStatus) => {
|
||||
bridge = next;
|
||||
settle();
|
||||
// Only now is the container's side of it worth an exec.
|
||||
if (authBridgeIsLive(bridge)) return;
|
||||
probeBrowserSupport(projectId).then((d) => {
|
||||
if (cancelled) return;
|
||||
detection = d;
|
||||
settle();
|
||||
});
|
||||
};
|
||||
|
||||
getAuthBridgeStatus(projectId)
|
||||
.then((s) => {
|
||||
if (!cancelled) consider(s);
|
||||
})
|
||||
// Nothing to say to the user here: an unanswered status call is fed
|
||||
// through as a bridge that is off, which lands on `container` or
|
||||
// `host-fallback` — and `host-fallback`'s hint is the one that tells the
|
||||
// user the callback has nothing carrying it.
|
||||
.catch(() => {
|
||||
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
|
||||
});
|
||||
|
||||
// The switch can be flipped *while a login is hanging* — that is the whole
|
||||
// reason `set_auth_bridge_enabled` exists outside the Config tab's save —
|
||||
// so the default has to follow it rather than reflect whatever was true
|
||||
// when this terminal was opened.
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||
if (event.payload.project_id !== projectId) return;
|
||||
consider(event.payload.status);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The queue lives at module scope in useTerminal, so the command layer is
|
||||
// mocked and the hook's `sendInput` is exercised through `renderHook`.
|
||||
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
|
||||
openTerminalSession: vi.fn(),
|
||||
closeTerminalSession: vi.fn(),
|
||||
terminalResize: vi.fn(),
|
||||
pasteImageToTerminal: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
|
||||
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
|
||||
|
||||
describe("useTerminal input ordering", () => {
|
||||
beforeEach(() => {
|
||||
terminalInput.mockReset();
|
||||
});
|
||||
|
||||
it("preserves order even when the underlying invokes resolve out of order", async () => {
|
||||
// Make the *first* call the slowest, which is exactly the race that put a
|
||||
// backspace behind the characters typed after it.
|
||||
const resolvers: Array<() => void> = [];
|
||||
terminalInput.mockImplementation(
|
||||
() => new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
const first = result.current.sendInput("s1", "\x7f"); // backspace
|
||||
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
|
||||
|
||||
// Only one write may be in flight at a time.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(1);
|
||||
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
|
||||
|
||||
resolvers.shift()!();
|
||||
await first;
|
||||
|
||||
// The three queued keystrokes coalesce into one ordered write.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(2);
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
|
||||
|
||||
resolvers.shift()!();
|
||||
await Promise.all(rest);
|
||||
|
||||
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
|
||||
expect(sent).toBe("\x7fabc");
|
||||
});
|
||||
|
||||
it("settles each caller's promise and does not drop later writes on failure", async () => {
|
||||
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
|
||||
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
|
||||
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
|
||||
});
|
||||
|
||||
it("keeps separate sessions independent", async () => {
|
||||
terminalInput.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await Promise.all([
|
||||
result.current.sendInput("a", "1"),
|
||||
result.current.sendInput("b", "2"),
|
||||
]);
|
||||
|
||||
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
|
||||
expect(bySession).toContainEqual(["a", "1"]);
|
||||
expect(bySession).toContainEqual(["b", "2"]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
/**
|
||||
* Per-session ordered write queue.
|
||||
*
|
||||
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
||||
* that command is `async` on the Rust side Tauri spawns each one as an
|
||||
* independent task. Those tasks then race for the session mutex in
|
||||
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
||||
* were typed in — the visible symptom was a backspace landing *after* the
|
||||
* characters typed behind it. The serial writer task downstream cannot help,
|
||||
* because the order is already lost by the time anything reaches the channel.
|
||||
*
|
||||
* The queue restores ordering the same way the web terminal gets it for free:
|
||||
* one write in flight at a time, the next only after the previous resolves.
|
||||
* Anything typed while a write is in flight coalesces into the next chunk,
|
||||
* which also collapses a burst of typing into a couple of IPC round trips
|
||||
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
||||
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
||||
* caller's promise still settles only when its own bytes have gone, so
|
||||
* `await sendInput(...)` keeps the meaning it had.
|
||||
*
|
||||
* Module scope, not hook scope, because `useTerminal()` is called from several
|
||||
* components (App for speech-to-text, TerminalView for typing and image paste,
|
||||
* useProjectActions for tile commands). A per-hook queue would give each caller
|
||||
* its own ordering and leave them racing against each other.
|
||||
*/
|
||||
type PendingWrite = {
|
||||
bytes: number[];
|
||||
resolve: () => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
||||
|
||||
async function drainInputQueue(sessionId: string): Promise<void> {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q || q.draining) return;
|
||||
|
||||
q.draining = true;
|
||||
try {
|
||||
while (q.pending.length > 0) {
|
||||
// Take everything queued so far as one batch, preserving order.
|
||||
const batch = q.pending.splice(0, q.pending.length);
|
||||
const bytes = batch.flatMap((w) => w.bytes);
|
||||
try {
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
batch.forEach((w) => w.resolve());
|
||||
} catch (err) {
|
||||
// Reject only the writes in this batch. Anything queued while it was
|
||||
// in flight is still pending and gets its own attempt on the next lap.
|
||||
batch.forEach((w) => w.reject(err));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
q.draining = false;
|
||||
// Drop the entry once idle so closed sessions do not accumulate.
|
||||
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let q = inputQueues.get(sessionId);
|
||||
if (!q) {
|
||||
q = { pending: [], draining: false };
|
||||
inputQueues.set(sessionId, q);
|
||||
}
|
||||
q.pending.push({ bytes, resolve, reject });
|
||||
void drainInputQueue(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop any queued input for a session that is going away. */
|
||||
function discardInputQueue(sessionId: string): void {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q) return;
|
||||
const dropped = q.pending.splice(0, q.pending.length);
|
||||
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
||||
if (!q.draining) inputQueues.delete(sessionId);
|
||||
}
|
||||
|
||||
export function useTerminal() {
|
||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||
useAppState(
|
||||
@@ -33,6 +113,7 @@ export function useTerminal() {
|
||||
const session = currentSessions.find((s) => s.id === sessionId);
|
||||
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
||||
|
||||
discardInputQueue(sessionId);
|
||||
await commands.closeTerminalSession(sessionId);
|
||||
removeSession(sessionId);
|
||||
|
||||
@@ -54,7 +135,7 @@ export function useTerminal() {
|
||||
const sendInput = useCallback(
|
||||
async (sessionId: string, data: string) => {
|
||||
const bytes = Array.from(new TextEncoder().encode(data));
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
await enqueueInput(sessionId, bytes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* What a container has to have before anything can be opened *inside* it.
|
||||
*
|
||||
* The Browser tab asks this to decide what to offer; the terminal's URL toast
|
||||
* asks it to decide which of its two buttons should lead. Both need the same
|
||||
* answer, so the predicates live here rather than beside either caller — the
|
||||
* failure this avoids is the toast steering a user at a container-side browser
|
||||
* that the Browser tab is, on the very same screen, offering to install.
|
||||
*
|
||||
* The important thing to know about `PlaywrightDetection` is that browsers are
|
||||
* deliberately **not** baked into the image: the libraries they link against
|
||||
* are, the binaries are a user-pressed install. So "Playwright is present" and
|
||||
* "a page can actually be opened" are two different questions, and a fresh
|
||||
* project answers yes to neither.
|
||||
*/
|
||||
|
||||
import type { PlaywrightDetection } from "./types";
|
||||
|
||||
/**
|
||||
* Mirrors Rust `PlaywrightDetection::is_usable` — the packages the live
|
||||
* dashboard needs. Says nothing about whether a browser exists to show in it.
|
||||
*/
|
||||
export function isBrowserViewUsable(d: PlaywrightDetection | null): boolean {
|
||||
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `openPageInContainerBrowser` has a browser to launch.
|
||||
*
|
||||
* Stricter than {@link isBrowserViewUsable} on purpose: the packages can be
|
||||
* installed with `~/.cache/ms-playwright` still empty, which is exactly the
|
||||
* state a `playwright install` step exists to leave behind, and launching into
|
||||
* it fails several seconds after the click.
|
||||
*
|
||||
* Unknown reads as "no". A probe that could not run (stopped container, an
|
||||
* image predating these fields) leaves the executable fields absent, and the
|
||||
* caller's fallback — the host browser — is the one that at least reports its
|
||||
* own failure. Over-refusing costs a user one extra click on a button that is
|
||||
* still right there; over-accepting costs them a sign-in that goes nowhere.
|
||||
*/
|
||||
export function canOpenPageInContainerBrowser(d: PlaywrightDetection | null): boolean {
|
||||
if (!isBrowserViewUsable(d) || !d) return false;
|
||||
// The viewer's own Chromium, confirmed on disk by the probe.
|
||||
if (d.chromium_executable_exists) return true;
|
||||
// Google Chrome is an apt package, so it is never in `browsers` and has no
|
||||
// revision to skew against.
|
||||
if (d.chrome_channel !== null) return true;
|
||||
// `== null`, not `=== null`: a probe from a container predating the
|
||||
// executable fields omits them entirely, and `undefined` there means "didn't
|
||||
// answer", not "missing". In that case a non-empty bundle list is the only
|
||||
// evidence available, and it is better than nothing.
|
||||
return d.chromium_executable == null && d.browsers.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput";
|
||||
|
||||
describe("toClaudePayload", () => {
|
||||
it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => {
|
||||
expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r");
|
||||
});
|
||||
|
||||
it("replaces every newline so the note arrives as one prompt", () => {
|
||||
// Typed raw, each \n submits — the note would arrive as three truncated
|
||||
// messages instead of one.
|
||||
expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree");
|
||||
});
|
||||
|
||||
it("normalises CRLF, which is what a paste from Windows carries", () => {
|
||||
expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo");
|
||||
});
|
||||
|
||||
it("normalises a lone CR, which would otherwise submit", () => {
|
||||
// A bare \r is a carriage return: it submits in a Claude prompt and runs
|
||||
// the line in a shell — the terminator this function promises not to
|
||||
// append. A textarea cannot make one, but a notes file that was
|
||||
// hand-edited or written by something else can, and `load_in` hands it
|
||||
// straight back.
|
||||
expect(toClaudePayload("one\rtwo")).toBe("one\x1b\rtwo");
|
||||
expect(toClaudePayload("one\rtwo\r\nthree\nfour")).toBe(
|
||||
"one\x1b\rtwo\x1b\rthree\x1b\rfour",
|
||||
);
|
||||
expect(toClaudePayload("text\r").endsWith("\r")).toBe(true);
|
||||
// …but only as the tail of the soft-newline sequence, never bare.
|
||||
expect(toClaudePayload("text\r")).toBe("text\x1b\r");
|
||||
});
|
||||
|
||||
it("leaves single-line text untouched", () => {
|
||||
expect(toClaudePayload("just one line")).toBe("just one line");
|
||||
});
|
||||
|
||||
it("never appends a terminator", () => {
|
||||
// The note lands in the prompt unsubmitted; the user presses Enter. An
|
||||
// unsent prompt is recoverable, a sent one is not.
|
||||
expect(toClaudePayload("text").endsWith("\r")).toBe(false);
|
||||
expect(toClaudePayload("text\n")).toBe("text\x1b\r");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The bytes that insert a newline in Claude Code's prompt without submitting
|
||||
* it: ESC then CR.
|
||||
*
|
||||
* These are the in-band bytes, not a guess — they are exactly what Claude
|
||||
* Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and
|
||||
* Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since
|
||||
* that feature landed. **This must not be "simplified" to `\n`:** Claude Code
|
||||
* accepts `\n` too, but a shell would *run* the line, so the two session types
|
||||
* would quietly diverge.
|
||||
*
|
||||
* That last sentence is also why anything sending this must first check the
|
||||
* session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and
|
||||
* answers with a bell.
|
||||
*/
|
||||
export const CLAUDE_SOFT_NEWLINE = "\x1b\r";
|
||||
|
||||
/**
|
||||
* Turn multi-line text into something that arrives in a Claude prompt as one
|
||||
* message.
|
||||
*
|
||||
* Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive
|
||||
* as N truncated prompts. Deliberately appends no terminator: the text lands
|
||||
* in the prompt and the user presses Enter, which is what speech-to-text does
|
||||
* for the same reason — an unsent prompt is recoverable and a sent one is not.
|
||||
*
|
||||
* A **lone** `\r` is matched too, not only the one in a CRLF. It is a carriage
|
||||
* return: it submits in a Claude prompt and runs the line in a shell, which is
|
||||
* exactly the terminator this function promises never to append. A `<textarea>`
|
||||
* cannot produce one, but a note body is read back from a JSON file that can be
|
||||
* hand-edited or written by something else, so the guarantee has to hold for
|
||||
* whatever `load_in` returns rather than for whatever the editor can type.
|
||||
*/
|
||||
export function toClaudePayload(text: string): string {
|
||||
return text.replace(/\r\n|\r|\n/g, CLAUDE_SOFT_NEWLINE);
|
||||
}
|
||||
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
|
||||
describe("chrome over a pane, with no dialog open", () => {
|
||||
/** Everything that is painted over a pane and is not a blocker. */
|
||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
||||
// host — so "does the pane contain what is painted here?" made the
|
||||
// terminal's top-right corner a dead zone no user action could clear.
|
||||
["the Following/Paused toggle", () => document.createElement("button")],
|
||||
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
|
||||
// and a *sibling* of the xterm host — so "does the pane contain what is
|
||||
// painted here?" made the terminal's top-right corner a dead zone no
|
||||
// user action could clear. (The retired Following toggle held the same
|
||||
// corner and produced the original bug.)
|
||||
["the mouse-release badge", () => document.createElement("button")],
|
||||
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||
// pane, and its error cards stay until dismissed.
|
||||
["a toast card", () => document.createElement("div")],
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
*
|
||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
|
||||
* of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||
* Permanent dead zones no user action could clear.
|
||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sessionDisplayName } from "./sessionName";
|
||||
import type { Project, TerminalSession } from "./types";
|
||||
|
||||
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const project = (renamed: Record<string, string> = {}) =>
|
||||
({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project;
|
||||
|
||||
describe("sessionDisplayName", () => {
|
||||
it("prefers a user-set custom name, prefixed with the project", () => {
|
||||
expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe(
|
||||
"api: release work",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the session name when there is no custom one", () => {
|
||||
expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review");
|
||||
});
|
||||
|
||||
it("falls back to the project name when there is no session name", () => {
|
||||
expect(sessionDisplayName(session(), project())).toBe("api");
|
||||
});
|
||||
|
||||
it("marks bash sessions", () => {
|
||||
expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)");
|
||||
});
|
||||
|
||||
it("works with no project, which is how a closing tab renders", () => {
|
||||
expect(sessionDisplayName(session())).toBe("api");
|
||||
});
|
||||
|
||||
it("does not mark bash when a custom name is set, matching the existing rule", () => {
|
||||
expect(
|
||||
sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })),
|
||||
).toBe("api: logs");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Project, TerminalSession } from "./types";
|
||||
|
||||
/**
|
||||
* What a terminal session is called on screen.
|
||||
*
|
||||
* The rule used to be written twice inside `MainTabs.tsx` — once in `tabLabel`
|
||||
* for the drag ghost, once inline in `renderTab` — both local and neither
|
||||
* exported, so the two could disagree the moment either was edited. It is here
|
||||
* because a third caller (the note send-target picker) would have made that
|
||||
* three.
|
||||
*
|
||||
* A user-set name wins and is prefixed with the project, because a custom name
|
||||
* is usually about the work rather than the project and needs the context. The
|
||||
* `(bash)` marker only appears on the fallback: a session someone bothered to
|
||||
* name does not need to be told apart from its neighbours.
|
||||
*/
|
||||
export function sessionDisplayName(
|
||||
session: TerminalSession,
|
||||
project?: Project,
|
||||
): string {
|
||||
const custom = project?.renamed_session_names?.[session.id];
|
||||
if (custom) return `${session.projectName}: ${custom}`;
|
||||
return (
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeImport, describeImportWarnings } from "./settingsImportPreview";
|
||||
import type { SettingsImportPreview } from "./types";
|
||||
|
||||
function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImportPreview {
|
||||
return {
|
||||
exported_at: "2026-08-27T00:00:00Z",
|
||||
app_version: "0.4.14",
|
||||
custom_env_var_count: 0,
|
||||
gateway_model_count: 0,
|
||||
has_claude_code_settings: false,
|
||||
has_claude_oauth_token: false,
|
||||
has_gateway_api_key: false,
|
||||
has_gateway_master_key: false,
|
||||
has_web_terminal_access_token: false,
|
||||
enables_web_terminal: false,
|
||||
ollama_base_url: null,
|
||||
llamacpp_base_url: null,
|
||||
openai_compatible_base_url: null,
|
||||
gateway_api_base: null,
|
||||
image_source: "registry",
|
||||
custom_image_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeImport", () => {
|
||||
it("always names the settings replacement, even with nothing else set", () => {
|
||||
expect(describeImport(preview())).toEqual([
|
||||
"Your global settings (all of them — this replaces what's here now)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("singularizes a count of exactly one", () => {
|
||||
const items = describeImport(preview({ custom_env_var_count: 1, gateway_model_count: 1 }));
|
||||
expect(items).toContain("1 global custom env var");
|
||||
expect(items).toContain("1 gateway model");
|
||||
});
|
||||
|
||||
it("pluralizes counts greater than one", () => {
|
||||
const items = describeImport(preview({ custom_env_var_count: 3, gateway_model_count: 2 }));
|
||||
expect(items).toContain("3 global custom env vars");
|
||||
expect(items).toContain("2 gateway models");
|
||||
});
|
||||
|
||||
it("names every present secret and setting without naming absent ones", () => {
|
||||
const items = describeImport(
|
||||
preview({
|
||||
has_claude_code_settings: true,
|
||||
has_claude_oauth_token: true,
|
||||
has_gateway_api_key: true,
|
||||
has_gateway_master_key: true,
|
||||
}),
|
||||
);
|
||||
expect(items).toContain("Global Claude Code settings");
|
||||
expect(items).toContain("Your shared Claude login");
|
||||
expect(items).toContain("The gateway provider API key");
|
||||
expect(items).toContain("The gateway master key");
|
||||
// None of the count-based items, since both counts are 0.
|
||||
expect(items.some((i) => i.includes("env var"))).toBe(false);
|
||||
expect(items.some((i) => i.includes("gateway model"))).toBe(false);
|
||||
});
|
||||
|
||||
it("names the web terminal access token like any other present secret", () => {
|
||||
const items = describeImport(preview({ has_web_terminal_access_token: true }));
|
||||
expect(items).toContain("The web terminal access token");
|
||||
});
|
||||
|
||||
it("names custom base URLs verbatim, since they're endpoints rather than secrets", () => {
|
||||
const items = describeImport(
|
||||
preview({
|
||||
ollama_base_url: "http://10.0.0.5:11434",
|
||||
gateway_api_base: "https://gateway.example/v1",
|
||||
}),
|
||||
);
|
||||
expect(items).toContain("Ollama server: http://10.0.0.5:11434");
|
||||
expect(items).toContain("Gateway upstream: https://gateway.example/v1");
|
||||
expect(items.some((i) => i.includes("llama.cpp"))).toBe(false);
|
||||
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
|
||||
});
|
||||
|
||||
it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
|
||||
expect(
|
||||
describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
|
||||
).toContain("Docker image: ghcr.io/me/triple-c");
|
||||
expect(describeImport(preview({ image_source: "custom", custom_image_name: null }))).toContain(
|
||||
"Docker image: (no image name set)",
|
||||
);
|
||||
expect(describeImport(preview({ image_source: "registry" })).some((i) => i.includes("Docker image"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeImportWarnings", () => {
|
||||
it("is empty when nothing about the import needs extra attention", () => {
|
||||
expect(describeImportWarnings(preview())).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when the import enables the web terminal, regardless of the token", () => {
|
||||
// `enabled` and the token are independent — the warning is about the
|
||||
// service turning on, whether or not a token came with it.
|
||||
expect(describeImportWarnings(preview({ enables_web_terminal: true }))).toEqual([
|
||||
"Enables the remote web terminal, which listens on your network.",
|
||||
]);
|
||||
expect(
|
||||
describeImportWarnings(
|
||||
preview({ enables_web_terminal: true, has_web_terminal_access_token: true }),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("warns about a dormant web terminal token even while the terminal stays off", () => {
|
||||
expect(describeImportWarnings(preview({ has_web_terminal_access_token: true }))).toEqual([
|
||||
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not only when it changes", () => {
|
||||
expect(
|
||||
describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
|
||||
).toEqual(["Runs every project container from a custom Docker image: evil:latest."]);
|
||||
expect(describeImportWarnings(preview({ image_source: "registry" }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SettingsImportPreview } from "./types";
|
||||
|
||||
/** Named things a `SettingsImportPreview` says an import will change, for
|
||||
* `ImportSettingsModal`'s confirmation list. Does not include anything
|
||||
* `describeImportWarnings` covers — those get their own, more visible
|
||||
* treatment rather than blending into this list. */
|
||||
export function describeImport(preview: SettingsImportPreview): string[] {
|
||||
const items: string[] = ["Your global settings (all of them — this replaces what's here now)"];
|
||||
if (preview.custom_env_var_count > 0) {
|
||||
items.push(
|
||||
`${preview.custom_env_var_count} global custom env var${preview.custom_env_var_count === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
if (preview.has_claude_code_settings) items.push("Global Claude Code settings");
|
||||
if (preview.gateway_model_count > 0) {
|
||||
items.push(`${preview.gateway_model_count} gateway model${preview.gateway_model_count === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (preview.has_claude_oauth_token) items.push("Your shared Claude login");
|
||||
if (preview.has_gateway_api_key) items.push("The gateway provider API key");
|
||||
if (preview.has_gateway_master_key) items.push("The gateway master key");
|
||||
if (preview.has_web_terminal_access_token) items.push("The web terminal access token");
|
||||
if (preview.ollama_base_url) items.push(`Ollama server: ${preview.ollama_base_url}`);
|
||||
if (preview.llamacpp_base_url) items.push(`llama.cpp server: ${preview.llamacpp_base_url}`);
|
||||
if (preview.openai_compatible_base_url) {
|
||||
items.push(`OpenAI-compatible server: ${preview.openai_compatible_base_url}`);
|
||||
}
|
||||
if (preview.gateway_api_base) items.push(`Gateway upstream: ${preview.gateway_api_base}`);
|
||||
if (preview.image_source === "custom") {
|
||||
items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Things about an import that deserve more attention than a bullet in a
|
||||
* long list — deliberately its own function rather than a flag inside
|
||||
* `describeImport`: a setting that turns on a network-listening service is
|
||||
* exactly the kind of change a "your settings were replaced" summary is bad
|
||||
* at surfacing, on purpose or (if the file came from someone else) not.
|
||||
*
|
||||
* A token that arrives with the terminal left *off* gets its own warning
|
||||
* too, distinct from the "enables it now" one: `start_web_terminal` only
|
||||
* mints a fresh token when none is already set, so a planted token here
|
||||
* would silently become live the next time someone flips the terminal on
|
||||
* through the UI, with no import-time signal that it wasn't freshly
|
||||
* generated.
|
||||
*
|
||||
* A custom Docker image gets a warning every time, not just on change: it's
|
||||
* the image every project container is created from, so it's worth calling
|
||||
* out regardless of what was configured before the import.
|
||||
*/
|
||||
export function describeImportWarnings(preview: SettingsImportPreview): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (preview.enables_web_terminal) {
|
||||
warnings.push("Enables the remote web terminal, which listens on your network.");
|
||||
} else if (preview.has_web_terminal_access_token) {
|
||||
warnings.push(
|
||||
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
|
||||
);
|
||||
}
|
||||
if (preview.image_source === "custom") {
|
||||
warnings.push(
|
||||
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
// Notes — per-project, host-side, readable with the container stopped.
|
||||
export const listNotes = (projectId: string) =>
|
||||
invoke<Note[]>("list_notes", { projectId });
|
||||
/** Insert or replace one note. `created_at` and `id` are owned by the backend. */
|
||||
export const saveNote = (projectId: string, note: Note) =>
|
||||
invoke<Note>("save_note", { projectId, note });
|
||||
export const deleteNote = (projectId: string, noteId: string) =>
|
||||
invoke<void>("delete_note", { projectId, noteId });
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||
export const updateSettings = (settings: AppSettings) =>
|
||||
@@ -42,6 +51,15 @@ export const inspectCaCertPath = (path: string) =>
|
||||
export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// Settings export/import — `false`/`null` mean the save/open dialog was
|
||||
// dismissed, not an error.
|
||||
export const exportSettings = (password: string) =>
|
||||
invoke<boolean>("export_settings", { password });
|
||||
export const previewSettingsImport = (password: string) =>
|
||||
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
|
||||
export const applySettingsImport = (password: string) =>
|
||||
invoke<SettingsImportOutcome>("apply_settings_import", { password });
|
||||
|
||||
// AWS
|
||||
export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
@@ -332,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
|
||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||
//
|
||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
||||
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
|
||||
// rather than polling) → migrateProjectToBase → the project sits
|
||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||
// rollbackMigration.
|
||||
//
|
||||
@@ -343,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
|
||||
//
|
||||
// Progress arrives on the existing `container-progress` event.
|
||||
|
||||
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
||||
/**
|
||||
* Runs two container/image filesystem probes; not for polling.
|
||||
*
|
||||
* **Not read-only, despite only reporting.** When the container is *stopped*
|
||||
* the backend has to commit its writable layer to a throwaway image before it
|
||||
* can read anything — `docker exec` needs a running container — so this writes
|
||||
* (and then removes) an image. The result is cached per stop, so repeat calls
|
||||
* while the container stays stopped are cheap, but the first one after each stop
|
||||
* pays for a commit of the whole layer: seconds on a small project, tens of
|
||||
* seconds on a large one. Do not add a caller that fires more often than "the
|
||||
* container settled into a new state" without re-reading
|
||||
* `get_container_staleness`'s doc comment first.
|
||||
*/
|
||||
export const getContainerStaleness = (projectId: string) =>
|
||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||
|
||||
@@ -368,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
|
||||
* app crash shows up here as phase "interrupted". */
|
||||
export const getMigrationState = (projectId: string) =>
|
||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
||||
|
||||
/** Open a URL in the user's own browser.
|
||||
*
|
||||
* Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On
|
||||
* Linux the app ships as an AppImage whose environment leaks into everything
|
||||
* it spawns, which kills a *cold-launched* browser before it paints while
|
||||
* `xdg-open` still exits 0 — so the plugin path reported success and did
|
||||
* nothing (triple-c#34). The Rust side hands the child a repaired environment
|
||||
* and re-validates the URL, which matters because these URLs originate in an
|
||||
* untrusted container. macOS and Windows still reach the plugin, just from
|
||||
* Rust, so there is no platform branch here.
|
||||
*
|
||||
* Rejects with a string already phrased for a toast. */
|
||||
export const openUrlExternal = (url: string) =>
|
||||
invoke<void>("open_url_external", { url });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
|
||||
|
||||
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
|
||||
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
|
||||
|
||||
describe("isLinuxWebview", () => {
|
||||
it("recognises desktop Linux", () => {
|
||||
expect(isLinuxWebview(LINUX)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count Android as desktop Linux", () => {
|
||||
expect(isLinuxWebview(ANDROID)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the other desktop platforms", () => {
|
||||
expect(isLinuxWebview(MAC)).toBe(false);
|
||||
expect(isLinuxWebview(WINDOWS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTerminalGpuRendering", () => {
|
||||
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
|
||||
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
|
||||
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
|
||||
});
|
||||
|
||||
it("auto is on elsewhere", () => {
|
||||
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
|
||||
});
|
||||
|
||||
it("an explicit setting wins on every platform", () => {
|
||||
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Decides whether the terminal loads `@xterm/addon-webgl`.
|
||||
*
|
||||
* Split out of `TerminalView` so it can be unit-tested without standing up a
|
||||
* terminal, and so the platform rule lives in exactly one place.
|
||||
*/
|
||||
|
||||
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
|
||||
export function isLinuxWebview(userAgent: string): boolean {
|
||||
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective WebGL setting.
|
||||
*
|
||||
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
|
||||
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux —
|
||||
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
|
||||
* which leaves WebGL present but software-rasterised, so loading the addon is
|
||||
* slower than the canvas renderer it would otherwise have fallen back to.
|
||||
*/
|
||||
export function resolveTerminalGpuRendering(
|
||||
setting: boolean | null | undefined,
|
||||
userAgent: string,
|
||||
): boolean {
|
||||
if (typeof setting === "boolean") return setting;
|
||||
return !isLinuxWebview(userAgent);
|
||||
}
|
||||
@@ -290,6 +290,52 @@ export interface AppSettings {
|
||||
stt: SttSettings;
|
||||
gateway: GatewaySettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
|
||||
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
|
||||
* backed by software rasterisation and the addon ends up slower than the
|
||||
* canvas renderer it would otherwise fall back to. See
|
||||
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||
terminal_gpu_rendering: boolean | null;
|
||||
}
|
||||
|
||||
/** What `preview_settings_import` returns before anything is applied —
|
||||
* counts and presence flags only, never a secret value itself. Built from
|
||||
* this, not from the raw import file, which the frontend never sees. */
|
||||
export interface SettingsImportPreview {
|
||||
exported_at: string;
|
||||
app_version: string;
|
||||
custom_env_var_count: number;
|
||||
gateway_model_count: number;
|
||||
has_claude_code_settings: boolean;
|
||||
has_claude_oauth_token: boolean;
|
||||
has_gateway_api_key: boolean;
|
||||
has_gateway_master_key: boolean;
|
||||
has_web_terminal_access_token: boolean;
|
||||
/** Whether the import turns the web terminal on — surfaced separately
|
||||
* from the token above since either can be true without the other, and
|
||||
* "this enables a service that listens on your network" must not hide
|
||||
* inside a generic "settings replaced" summary. */
|
||||
enables_web_terminal: boolean;
|
||||
/** Non-blank custom base URLs the import would set — endpoints, not
|
||||
* secrets, so shown verbatim to disclose a redirect of model traffic. */
|
||||
ollama_base_url: string | null;
|
||||
llamacpp_base_url: string | null;
|
||||
openai_compatible_base_url: string | null;
|
||||
gateway_api_base: string | null;
|
||||
/** Whether the import sets a custom Docker image, and its name if so —
|
||||
* this is the image every project container is created from, so worth
|
||||
* more attention than an ordinary setting. */
|
||||
image_source: ImageSource;
|
||||
custom_image_name: string | null;
|
||||
}
|
||||
|
||||
/** What `apply_settings_import` returns: the settings that were actually
|
||||
* saved, plus a note for each keychain secret the import carried but could
|
||||
* not be restored (a partial keychain failure must not read as unqualified
|
||||
* success just because the settings half went through). */
|
||||
export interface SettingsImportOutcome {
|
||||
settings: AppSettings;
|
||||
secret_restore_warnings: string[];
|
||||
}
|
||||
|
||||
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
|
||||
@@ -519,6 +565,16 @@ export interface SchedulerNotification {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One project note. Mirrors `models::Note` — field names are the Rust ones. */
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Which loopback family the container-side listener was found on.
|
||||
|
||||
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
|
||||
* A direct port of `usable_sign_in_link` in
|
||||
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
||||
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
|
||||
* only thing standing between any of this and `openUrl`, and duplicating its
|
||||
* only thing standing between any of this and `openUrlExternal`, and
|
||||
* duplicating its
|
||||
* rules here would be a second place for them to go stale.
|
||||
*
|
||||
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
|
||||
@@ -293,7 +294,7 @@ export class UrlDetector {
|
||||
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
|
||||
// swallowed into the middle of a match becomes a URL that renders as one
|
||||
// thing in the toast and resolves as another. Everything emitted here is
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MAX_RELAY_URL_LENGTH,
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
isAnthropicSignInUrl,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
urlOrigin,
|
||||
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
|
||||
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAnthropicSignInUrl", () => {
|
||||
// Classification only. Where a sign-in link should be opened is decided by
|
||||
// `hooks/useSignInOpenTarget.ts`, from facts about the project — this answers
|
||||
// the narrower question of whether it is a sign-in link at all, and it does
|
||||
// so through the same allowlist the sign-in flow itself uses.
|
||||
it("recognises the links `claude setup-token` and `claude login` print", () => {
|
||||
expect(
|
||||
isAnthropicSignInUrl(
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAnthropicSignInUrl("https://platform.claude.com/oauth/code/callback?x=1"),
|
||||
).toBe(true);
|
||||
expect(isAnthropicSignInUrl("https://console.anthropic.com/login?x=1")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is not fooled by a host that merely contains an allowed domain", () => {
|
||||
// The thing the allowlist exists for: `claude.ai.evil.tld` ends with
|
||||
// neither `claude.ai` nor `.claude.ai`.
|
||||
expect(isAnthropicSignInUrl("https://claude.ai.evil.tld/oauth/authorize")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAnthropicSignInUrl("https://notclaude.ai/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the full validator, not just the host test", () => {
|
||||
// It runs `sanitizeRelayUrl`, so everything that cannot be opened at all
|
||||
// is not a sign-in link either — no separate, weaker copy of the rules.
|
||||
expect(isAnthropicSignInUrl("javascript:claude.ai/login")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://claude.ai@evil.tld/login")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://claude\nai/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim every allowlisted URL is a sign-in", () => {
|
||||
expect(isAnthropicSignInUrl("https://claude.ai/chat/abc")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://www.anthropic.com/news")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves an ordinary link alone, whatever it says in its path", () => {
|
||||
// A `gh auth login` device code is the common one, and sending it to a
|
||||
// container-side browser would be actively wrong.
|
||||
expect(isAnthropicSignInUrl("https://github.com/login/device?code=A")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-6
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* URL relay — host side of `container/triple-c-open` — and the single URL
|
||||
* validator every `openUrl` call site in the app is required to go through.
|
||||
* validator every `openUrlExternal` call site in the app is required to go
|
||||
* through.
|
||||
*
|
||||
* A CLI inside the container has no browser. When it wants to open a URL
|
||||
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
|
||||
@@ -182,11 +183,22 @@ export function extendsUrl(next: string, current: string): boolean {
|
||||
/**
|
||||
* Whether this is a URL that signs the user in to Anthropic.
|
||||
*
|
||||
* Used to decide *presentation*, not permission — the toast makes the
|
||||
* container-side browser the default action for these, because the OAuth
|
||||
* callback listener is inside the container and the host has nothing to catch
|
||||
* it with. It is deliberately the same host allowlist the sign-in flow itself
|
||||
* uses, so the two cannot disagree about what a sign-in link is.
|
||||
* Classification only. It answers "is this a sign-in link", never "where should
|
||||
* it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
|
||||
* because it depends on things this module has no business knowing: whether the
|
||||
* project's auth bridge is live, and whether a browser is actually installed in
|
||||
* the container. This function stays here because the *rule* it encodes is a
|
||||
* URL rule, and it is deliberately the same host allowlist the sign-in flow
|
||||
* itself uses, so the two cannot disagree about what a sign-in link is.
|
||||
*
|
||||
* It used to carry the default with it — container-side always, on the grounds
|
||||
* that "the OAuth callback listener is inside the container and the host has
|
||||
* nothing to catch it with". Both halves of that are now wrong. The host does
|
||||
* have something to catch it with (the auth bridge mirrors the container's
|
||||
* loopback listener onto the same host port), and the container-side target is
|
||||
* not a general browser but Playwright's dashboard pane, whose browsers are
|
||||
* deliberately not baked into the image — so on a fresh project the default
|
||||
* pointed at something that was not installed, on every platform.
|
||||
*/
|
||||
export function isAnthropicSignInUrl(url: string): boolean {
|
||||
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user