Compare commits
25
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 | ||
|
|
9fadfbc37a |
@@ -299,8 +299,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -319,21 +345,22 @@ jobs:
|
|||||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||||
|
# artifact that runs everywhere, and building them is pure cost.
|
||||||
|
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||||
|
npx tauri build --bundles appimage
|
||||||
|
|
||||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
# 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
|
# 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
|
# window comes up blank. It has to come from the host; see the script
|
||||||
# header for the evidence and the trade.
|
# header for the evidence and the trade.
|
||||||
- name: Unbundle the host-coupled Wayland client
|
- name: Finalize the AppImage
|
||||||
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage
|
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
|
||||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
@@ -425,8 +452,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -172,8 +172,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -185,23 +211,38 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||||
|
# artifact that runs everywhere, and building them is pure cost.
|
||||||
|
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||||
|
npx tauri build --bundles appimage
|
||||||
|
|
||||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
# 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
|
# 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
|
# window comes up blank. It has to come from the host; see the script
|
||||||
# header for the evidence and the trade.
|
# header for the evidence and the trade.
|
||||||
- name: Unbundle the host-coupled Wayland client
|
- name: Finalize the AppImage
|
||||||
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage
|
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
# The versioned AppImage only. The update channel's copy lives in
|
||||||
|
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||||
|
# it up and publish an 80 MB duplicate under a second name.
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
|
||||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
|
# A green job that published nothing is the worst outcome available:
|
||||||
|
# the release exists, carries no AppImage, and nobody is told. The
|
||||||
|
# `|| true` above is there so a missing bundle does not mask the real
|
||||||
|
# error, which makes this check the thing that catches it.
|
||||||
|
shopt -s nullglob
|
||||||
|
collected=(artifacts/*)
|
||||||
|
if [ ${#collected[@]} -eq 0 ]; then
|
||||||
|
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
@@ -277,6 +318,19 @@ jobs:
|
|||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The fixed tag every installed AppImage checks for updates. Separate
|
||||||
|
# from the versioned release above because the updater's URL must never
|
||||||
|
# move, and `releases/latest` does.
|
||||||
|
- name: Publish the Linux update channel
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
bash scripts/publish-update-channel.sh \
|
||||||
|
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version]
|
||||||
@@ -332,8 +386,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -55,5 +76,21 @@ jobs:
|
|||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,ignore-error=true
|
||||||
|
|||||||
+21
-5
@@ -71,13 +71,29 @@ npm ci
|
|||||||
npx tauri build
|
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/`:
|
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||||
|
|
||||||
| Format | Path |
|
| Format | Path | Released |
|
||||||
|------------|-------------------------------|
|
|------------|-------------------------------|----------|
|
||||||
| AppImage | `appimage/*.AppImage` |
|
| AppImage | `appimage/*.AppImage` | yes |
|
||||||
| Debian pkg | `deb/*.deb` |
|
| Debian pkg | `deb/*.deb` | no |
|
||||||
| RPM pkg | `rpm/*.rpm` |
|
| 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
|
## 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,
|
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.
|
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
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
@@ -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
|
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.
|
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
- **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
|
- **`: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
|
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`
|
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||||
@@ -679,8 +756,26 @@ deliberately out of scope — this is not a project backup.
|
|||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
|
|
||||||
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
|
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||||
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
|
`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
|
`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
|
`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
|
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||||
|
|||||||
+32
-8
@@ -41,14 +41,16 @@ Download the build for your platform from [GitHub Releases](https://github.com/s
|
|||||||
|----------|------|---------|
|
|----------|------|---------|
|
||||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
| **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. |
|
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||||
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
|
| **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||||
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
|
|
||||||
| **Arch / CachyOS / other Linux** | `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`.
|
> **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).
|
> **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.
|
> **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
|
### Adding an AppImage to the app launcher
|
||||||
@@ -241,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 │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -266,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
|
- **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.
|
open you get a welcome screen with Docker/image/project readiness checks.
|
||||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
- **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
|
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||||
speech-to-text is enabled.
|
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1222,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.
|
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
|
### Files
|
||||||
|
|
||||||
|
|||||||
@@ -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/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/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, 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/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ choice it never asked about.
|
|||||||
|
|
||||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
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.
|
file drag-drop, STT), the web terminal, and workspace backup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+6
-3
@@ -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`):
|
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`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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)
|
### bollard (Docker API)
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -5306,6 +5306,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ local-ip-address = "0.6"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
zeroize = "1"
|
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]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
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
|
/// 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
|
/// pane always has something specific to say. This is host-side only — no
|
||||||
/// container recreation is involved either way.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn set_browser_view_enabled(
|
pub async fn set_browser_view_enabled(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -23,9 +29,32 @@ pub async fn set_browser_view_enabled(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<BrowserViewStatus, String> {
|
) -> Result<BrowserViewStatus, String> {
|
||||||
if !enabled {
|
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.
|
// 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?;
|
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
|
.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]
|
#[tauri::command]
|
||||||
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
pub async fn get_browser_view_status(
|
||||||
Ok(manager().status(&project_id).await)
|
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.
|
/// Probe the container for Playwright without starting anything.
|
||||||
@@ -110,7 +160,9 @@ pub async fn open_browser_view_popout(
|
|||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 {
|
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||||
return Err(
|
return Err(
|
||||||
"The browser view isn't running. Start it before opening it in its own window."
|
"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 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.
|
// 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.
|
// 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 {
|
if status.state != BrowserViewState::Running {
|
||||||
crate::commands::project_commands::emit_progress(
|
crate::commands::project_commands::emit_progress(
|
||||||
&app_handle,
|
&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
|
// 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.
|
// window of its own or it lands somewhere the user isn't looking.
|
||||||
if show_window {
|
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() {
|
if let Some(url) = status.url.as_deref() {
|
||||||
let name = state
|
let name = state
|
||||||
.projects_store
|
.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))
|
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.
|
/// The project's container, or a sentence saying why there isn't one.
|
||||||
///
|
///
|
||||||
/// Every command here needs a *running* container, and every one of them used
|
/// Every command here needs a *running* container, and every one of them used
|
||||||
@@ -344,3 +414,43 @@ async fn running_container(
|
|||||||
}
|
}
|
||||||
Ok(container_id)
|
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
|
//! ## 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
|
//! 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
|
//! is the only thing that tears them down, so every way a session can end funnels
|
||||||
//! through one code path:
|
//! through one code path:
|
||||||
//!
|
//!
|
||||||
//! | Trigger | 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 |
|
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||||
//! | Project deleted | supervisor's `store.get()` check |
|
//! | Project deleted | supervisor's `store.get()` check |
|
||||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
//! | 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
|
//! 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
|
//! 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
|
//! 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 commands;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BrowserViewStatus {
|
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 enabled: bool,
|
||||||
pub state: BrowserViewState,
|
pub state: BrowserViewState,
|
||||||
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
|
/// 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>>>;
|
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)]
|
#[derive(Default)]
|
||||||
pub struct BrowserViewManager {
|
pub struct BrowserViewManager {
|
||||||
sessions: SessionMap,
|
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,
|
next_epoch: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// 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) {
|
match self.sessions.lock().await.get(project_id) {
|
||||||
Some(session) => BrowserViewStatus {
|
Some(session) => BrowserViewStatus {
|
||||||
enabled,
|
enabled,
|
||||||
@@ -261,6 +271,14 @@ impl BrowserViewManager {
|
|||||||
///
|
///
|
||||||
/// Idempotent: a call while a live session exists returns that session's
|
/// Idempotent: a call while a live session exists returns that session's
|
||||||
/// status untouched, so re-opening the tab does not restart the dashboard.
|
/// 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(
|
pub async fn start(
|
||||||
&self,
|
&self,
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -268,7 +286,7 @@ impl BrowserViewManager {
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
store: Arc<ProjectsStore>,
|
store: Arc<ProjectsStore>,
|
||||||
) -> Result<BrowserViewStatus, String> {
|
) -> 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,
|
// Bind the answer before acting on it: `status()` takes the same lock,
|
||||||
// and this mutex is not reentrant.
|
// and this mutex is not reentrant.
|
||||||
@@ -279,7 +297,7 @@ impl BrowserViewManager {
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.is_some_and(|s| !s.supervisor.is_finished());
|
.is_some_and(|s| !s.supervisor.is_finished());
|
||||||
if already_live {
|
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?;
|
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);
|
emit(&app, &project_id, &status);
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop one project's view and wait until its host port has been released.
|
/// 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) {
|
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
|
// Remove under the lock, then release it before awaiting: the
|
||||||
// supervisor takes the same lock to deregister itself on exit.
|
// supervisor takes the same lock to deregister itself on exit.
|
||||||
let session = self.sessions.lock().await.remove(project_id);
|
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.
|
// longer exists. The session owns it, and this is where the session ends.
|
||||||
let _ = popout::close(&app, &project_id);
|
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));
|
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +945,25 @@ mod tests {
|
|||||||
assert!(s.url.is_none());
|
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]
|
#[test]
|
||||||
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
||||||
let mut d = PlaywrightDetection::default();
|
let mut d = PlaywrightDetection::default();
|
||||||
|
|||||||
@@ -92,8 +92,111 @@ fn pick_recorded_lineage(
|
|||||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||||
/// be called on demand, not polled.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn get_container_staleness(
|
pub async fn get_container_staleness(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -145,16 +248,62 @@ pub async fn get_container_staleness(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Probes ───────────────────────────────────────────────────────────
|
// ── Probes ───────────────────────────────────────────────────────────
|
||||||
let running = match &container_id {
|
let container_running = match &container_id {
|
||||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
|
||||||
None => false,
|
None => None,
|
||||||
};
|
};
|
||||||
let from_manifest = if running {
|
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
|
||||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
let from_manifest = match (
|
||||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
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
|
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 {
|
let (from_manifest, base_manifest) = match from_manifest {
|
||||||
@@ -1964,6 +2113,59 @@ mod tests {
|
|||||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
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]
|
#[test]
|
||||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||||
assert_eq!(human_bytes(512), "512 B");
|
assert_eq!(human_bytes(512), "512 B");
|
||||||
|
|||||||
@@ -1036,7 +1036,6 @@ fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_project(
|
pub async fn update_project(
|
||||||
project: serde_json::Value,
|
project: serde_json::Value,
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
// Taken as raw JSON, then deserialised, for one reason: a secret field that
|
// Taken as raw JSON, then deserialised, for one reason: a secret field that
|
||||||
@@ -1098,37 +1097,57 @@ pub async fn update_project(
|
|||||||
// [`crate::models::validate_env_vars_update`].
|
// [`crate::models::validate_env_vars_update`].
|
||||||
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||||
|
|
||||||
project.container_id = stored.container_id;
|
restore_store_owned_fields(&mut project, &stored);
|
||||||
project.status = stored.status;
|
|
||||||
project.created_at = stored.created_at;
|
|
||||||
project.updated_at = chrono::Utc::now().to_rfc3339();
|
project.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
store_secrets_for_project(&project, &explicitly_cleared)?;
|
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
|
// Nothing reconciles the *running* auth bridge here any more, and there is
|
||||||
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
// nothing left for such a step to do. This command can no longer change
|
||||||
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
// `auth_bridge_enabled` at all (see [`restore_store_owned_fields`]), so a
|
||||||
// when nothing is running, so this is safe on every project save.
|
// reconcile could only ever re-assert what was already true. The paths that
|
||||||
if updated.auth_bridge_enabled {
|
// do change it each own their own side effect: `set_auth_bridge_enabled`
|
||||||
if let Some(ref container_id) = updated.container_id {
|
// starts or stops the bridge itself, [`start_project_container`] arms it
|
||||||
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
// when the container comes up, and `reconcile_project_statuses` re-arms it
|
||||||
state
|
// for every already-running container at launch. The version of this that
|
||||||
.auth_bridge
|
// re-asserted on every save is what turned a stale flag in a payload into a
|
||||||
.start(
|
// restarted bridge.
|
||||||
updated.id.clone(),
|
state.projects_store.update(project)
|
||||||
container_id.clone(),
|
}
|
||||||
app_handle,
|
|
||||||
state.projects_store.clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.auth_bridge.stop(&updated.id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
#[tauri::command]
|
||||||
@@ -2186,4 +2205,89 @@ mod tests {
|
|||||||
// Changing it to a different root is a change, and refused.
|
// Changing it to a different root is a change, and refused.
|
||||||
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
|||||||
|
|
||||||
/// Build the command to run in the container terminal.
|
/// Build the command to run in the container terminal.
|
||||||
///
|
///
|
||||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
/// 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> {
|
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
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.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();
|
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 {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
return vec![
|
||||||
cmd.extend(permission_args);
|
"bash".to_string(),
|
||||||
if let Some(name) = session_name {
|
"-c".to_string(),
|
||||||
if !name.is_empty() {
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
cmd.push("-n".to_string());
|
];
|
||||||
cmd.push(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cmd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = aws_commands::resolve_profile_for_project(
|
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||||
project,
|
|
||||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
// then exec's into claude.
|
// 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!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -75,9 +114,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// refused rather than silently substituted (it used to fall back to
|
||||||
/// `"dropped-file"`).
|
/// `"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]
|
#[test]
|
||||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||||
use crate::commands::file_commands::host_upload_name;
|
use crate::commands::file_commands::host_upload_name;
|
||||||
|
|||||||
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
|
|||||||
.collect()
|
.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
|
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
||||||
/// name in [`SECRET_ENV_KEYS`].
|
/// name in [`SECRET_ENV_KEYS`].
|
||||||
fn env_holds_a_secret(env: &[String]) -> bool {
|
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_image_by_name(&get_snapshot_image_name(project)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
|
||||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
|
||||||
/// only has the image name (the project record is already gone by then).
|
/// (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> {
|
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Removed snapshot image {}", image_name);
|
log::info!("Removed image {}", image_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
Err(bollard::errors::Error::DockerResponseServerError {
|
||||||
@@ -4464,6 +4577,29 @@ mod tests {
|
|||||||
assert!(env_holds_a_secret(&env));
|
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]
|
#[test]
|
||||||
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
||||||
let clean = SnapshotScrubReport {
|
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
|
/// How old a `triple-c.probe=migration` container must be before
|
||||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
/// [`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))
|
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.
|
/// 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
|
/// 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("pre-migration-handmade", false, ancient, &now));
|
||||||
assert!(!pin_is_reapable("latest", 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 models;
|
||||||
mod project_lock;
|
mod project_lock;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
pub mod url_open;
|
||||||
pub mod web_terminal;
|
pub mod web_terminal;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -263,12 +264,20 @@ pub fn run() {
|
|||||||
// logged warning rather than a failed start.
|
// logged warning rather than a failed start.
|
||||||
//
|
//
|
||||||
// Ordering matters. Probes are removed first because a probe holds
|
// 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
|
// second so the images they were holding are dangling by the time
|
||||||
// the sweep lists them; the sweep runs last and collects both.
|
// the sweep lists them; the sweep runs last and collects both.
|
||||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
crate::docker::reap_probe_containers().await;
|
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;
|
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||||
if reaped > 0 {
|
if reaped > 0 {
|
||||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||||
@@ -544,6 +553,9 @@ pub fn run() {
|
|||||||
commands::update_commands::check_image_update,
|
commands::update_commands::check_image_update,
|
||||||
// Help
|
// Help
|
||||||
commands::help_commands::get_help_content,
|
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
|
// Install helper
|
||||||
commands::install_helper_commands::detect_install_options,
|
commands::install_helper_commands::detect_install_options,
|
||||||
commands::install_helper_commands::run_docker_install,
|
commands::install_helper_commands::run_docker_install,
|
||||||
@@ -926,7 +938,6 @@ mod tests {
|
|||||||
"core:webview:allow-internal-toggle-devtools",
|
"core:webview:allow-internal-toggle-devtools",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"opener:allow-open-url",
|
|
||||||
];
|
];
|
||||||
expected.sort();
|
expected.sort();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -63,6 +63,12 @@
|
|||||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
/// as a separate, unrelated cause.
|
/// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||||
|
|
||||||
@@ -138,6 +144,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
apply_webkit_wayland_workaround();
|
apply_webkit_wayland_workaround();
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
|
|||||||
true
|
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.
|
/// How much autonomy Claude Code is granted inside the container.
|
||||||
///
|
///
|
||||||
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||||
@@ -336,17 +356,30 @@ pub struct Project {
|
|||||||
pub sandbox_mode_enabled: bool,
|
pub sandbox_mode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
pub mission_control_enabled: bool,
|
||||||
/// Opt in to the auth bridge: while the container runs, its loopback
|
/// The auth bridge: while the container runs, its loopback listeners are
|
||||||
/// listeners are mirrored onto the host's loopback so browser OAuth
|
/// mirrored onto the host's loopback so browser OAuth callbacks
|
||||||
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||||
/// Purely host-side — it deliberately has no container-recreation label,
|
/// Purely host-side — it deliberately has no container-recreation label,
|
||||||
/// because toggling it changes nothing about the container itself.
|
/// 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,
|
pub auth_bridge_enabled: bool,
|
||||||
/// Opt in to the browser-view pane, which watches and takes over the
|
/// Opt in to the browser-view pane, which watches and takes over the
|
||||||
/// browser Claude drives with Playwright inside the container. Purely
|
/// browser Claude drives with Playwright inside the container. Purely
|
||||||
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||||
/// container-recreation label.
|
/// 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)]
|
#[serde(default)]
|
||||||
pub browser_view_enabled: bool,
|
pub browser_view_enabled: bool,
|
||||||
/// Grant the container what a VPN client needs to build a tunnel:
|
/// Grant the container what a VPN client needs to build a tunnel:
|
||||||
@@ -639,7 +672,7 @@ impl Project {
|
|||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: false,
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
auth_bridge_enabled: false,
|
auth_bridge_enabled: default_auth_bridge_enabled(),
|
||||||
browser_view_enabled: false,
|
browser_view_enabled: false,
|
||||||
vpn_support_enabled: false,
|
vpn_support_enabled: false,
|
||||||
use_shared_auth_token: default_use_shared_auth_token(),
|
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();
|
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(round_tripped, partial);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||||
let mut projects = self.lock();
|
let mut projects = self.lock();
|
||||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
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();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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.
|
/// 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> {
|
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
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();
|
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
|
// The args are interpolated into a shell script string below, so
|
||||||
// single-quote each one.
|
// single-quote each one.
|
||||||
let permission_flags: String = permission_args
|
let permission_flags: String = permission_args
|
||||||
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
.collect();
|
.collect();
|
||||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
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!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
echo "Validating AWS session for profile '{profile}'..."
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
@@ -260,9 +267,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
|
import { openUrlExternal } from "../lib/tauri-commands";
|
||||||
import { useDocker } from "../hooks/useDocker";
|
import { useDocker } from "../hooks/useDocker";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import Button from "./ui/Button";
|
import Button from "./ui/Button";
|
||||||
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
const handleOpenDocs = async () => {
|
const handleOpenDocs = async () => {
|
||||||
if (!options) return;
|
if (!options) return;
|
||||||
try {
|
try {
|
||||||
await openUrl(options.docs_url);
|
await openUrlExternal(options.docs_url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open docs URL:", e);
|
console.error("Failed to open docs URL:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
export default function StatusBar({ stt }: Props) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
terminalHasSelection: s.terminalHasSelection,
|
terminalHasSelection: s.terminalHasSelection,
|
||||||
activeSessionId: s.activeSessionId,
|
activeSessionId: s.activeSessionId,
|
||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
|
||||||
notesDockOpen: s.notesDockOpen,
|
notesDockOpen: s.notesDockOpen,
|
||||||
toggleNotesDock: s.toggleNotesDock,
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
|
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||||
|
releaseActiveMouse: s.releaseActiveMouse,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
</span>
|
</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">
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
{activeSessionId && !terminalAtBottom && (
|
{activeSessionId && terminalMouseCaptured && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollActiveToBottom()}
|
data-mouse-release="true"
|
||||||
|
onClick={() => releaseActiveMouse()}
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
setBrowserViewMatchWindow,
|
setBrowserViewMatchWindow,
|
||||||
setBrowserViewPopoutAlwaysOnTop,
|
setBrowserViewPopoutAlwaysOnTop,
|
||||||
} from "../../../lib/tauri-commands";
|
} from "../../../lib/tauri-commands";
|
||||||
|
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
import OpenPageDialog from "./OpenPageDialog";
|
import OpenPageDialog from "./OpenPageDialog";
|
||||||
import AccordionSection from "../../ui/AccordionSection";
|
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
|
// Prefer the probe: it is the fresher of the two, and it is the one that
|
||||||
// reflects an install that just finished.
|
// reflects an install that just finished.
|
||||||
const probed = detection ?? status.detection;
|
const probed = detection ?? status.detection;
|
||||||
const ready = isUsable(probed);
|
const ready = isBrowserViewUsable(probed);
|
||||||
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
||||||
// apt package, so it never shows up in `browsers`, and a container that has
|
// apt package, so it never shows up in `browsers`, and a container that has
|
||||||
// it is not missing a browser.
|
// 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`.
|
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||||
*
|
*
|
||||||
@@ -627,7 +623,7 @@ function Setup({
|
|||||||
onInstall: (which: Exclude<SetupJob, null>) => void;
|
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||||
}) {
|
}) {
|
||||||
const busy = job !== null;
|
const busy = job !== null;
|
||||||
const havePackages = isUsable(detection);
|
const havePackages = isBrowserViewUsable(detection);
|
||||||
const missing = missingParts(detection);
|
const missing = missingParts(detection);
|
||||||
const browsers = detection?.browsers ?? [];
|
const browsers = detection?.browsers ?? [];
|
||||||
const chrome = detection?.chrome_channel ?? null;
|
const chrome = detection?.chrome_channel ?? null;
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
|
|||||||
hasClaudeToken: vi.fn(),
|
hasClaudeToken: vi.fn(),
|
||||||
clearClaudeToken: vi.fn(),
|
clearClaudeToken: vi.fn(),
|
||||||
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
||||||
|
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
const openUrl = vi.fn();
|
const openUrlExternal = vi.fn();
|
||||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
|
||||||
openUrl: (...args: unknown[]) => openUrl(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** Captured event handlers, keyed by event name, so tests can emit. */
|
/** Captured event handlers, keyed by event name, so tests can emit. */
|
||||||
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
||||||
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
|
|||||||
|
|
||||||
const link = await screen.findByRole("link", { name: url });
|
const link = await screen.findByRole("link", { name: url });
|
||||||
fireEvent.click(link);
|
fireEvent.click(link);
|
||||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores output belonging to a different project", async () => {
|
it("ignores output belonging to a different project", async () => {
|
||||||
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
|
|||||||
|
|
||||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||||
fireEvent.click(link);
|
fireEvent.click(link);
|
||||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
|
||||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
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");
|
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||||
|
|
||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
expect(openUrl).not.toHaveBeenCalled();
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a hyperlink belonging to a different project", async () => {
|
it("ignores a hyperlink belonging to a different project", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
|
||||||
import { cancelClaudeToken } from "../../lib/tauri-commands";
|
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await openUrl(target);
|
await openUrlExternal(target);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLinkError(
|
setLinkError(
|
||||||
authErrorMessage(
|
authErrorMessage(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { UpdateInfo } from "../../lib/types";
|
import type { UpdateInfo } from "../../lib/types";
|
||||||
|
import { openUrlExternal } from "../../lib/tauri-commands";
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import { formatBytes } from "../../lib/formatBytes";
|
import { formatBytes } from "../../lib/formatBytes";
|
||||||
@@ -19,7 +19,7 @@ export default function UpdateDialog({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const handleDownload = async (url: string) => {
|
const handleDownload = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await openUrl(url);
|
await openUrlExternal(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open URL:", 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 { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
import TerminalView, { supersedes } from "./TerminalView";
|
import TerminalView, { supersedes } from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
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.
|
* 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),
|
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. */
|
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
|
||||||
const ptyOutput = vi.hoisted(() => ({
|
const ptyOutput = vi.hoisted(() => ({
|
||||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||||
@@ -44,6 +65,9 @@ vi.mock("../../lib/tauri-commands", () => ({
|
|||||||
awsSsoRefresh: vi.fn(async () => {}),
|
awsSsoRefresh: vi.fn(async () => {}),
|
||||||
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
||||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
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", () => ({
|
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", () => ({
|
vi.mock("@tauri-apps/api/webview", () => ({
|
||||||
getCurrentWebview: () => ({
|
getCurrentWebview: () => ({
|
||||||
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
||||||
@@ -127,6 +147,14 @@ beforeEach(() => {
|
|||||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||||
dragDrop.handler = null;
|
dragDrop.handler = null;
|
||||||
ptyOutput.listeners.clear();
|
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 = "";
|
document.body.innerHTML = "";
|
||||||
useAppState.setState({ sessions: [] });
|
useAppState.setState({ sessions: [] });
|
||||||
});
|
});
|
||||||
@@ -370,15 +398,34 @@ describe("TerminalView — where a dropped file lands", () => {
|
|||||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
|
||||||
// The regression this file could not see. The toggle is `absolute top-2
|
// The regression this file could not see. Chrome like the URL toast is a
|
||||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
// *sibling* of the xterm host painted over the pane, so
|
||||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
// `elementFromPoint` returns it rather than the host — and a gate asking
|
||||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||||
// with no message and no log line. jsdom never ran that branch.
|
// with no message and no log line. jsdom never ran that branch.
|
||||||
const view = await mountWithLayout();
|
//
|
||||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
// The original fixture was the always-rendered "▼ Following" toggle. That
|
||||||
stubElementFromPoint(toggle);
|
// 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);
|
await drop(780, 10);
|
||||||
|
|
||||||
@@ -539,6 +586,354 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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", () => {
|
describe("TerminalView — focus on request", () => {
|
||||||
/** Mount, then deliberately give focus away, so what the assertions below
|
/** Mount, then deliberately give focus away, so what the assertions below
|
||||||
* observe is the *request* taking effect and never the focus `active`
|
* observe is the *request* taking effect and never the focus `active`
|
||||||
@@ -594,3 +989,88 @@ describe("TerminalView — focus on request", () => {
|
|||||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
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,7 +3,6 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
@@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
|||||||
import {
|
import {
|
||||||
awsSsoRefresh,
|
awsSsoRefresh,
|
||||||
openPageInContainerBrowser,
|
openPageInContainerBrowser,
|
||||||
|
openUrlExternal,
|
||||||
uploadHostFileToTerminal,
|
uploadHostFileToTerminal,
|
||||||
} from "../../lib/tauri-commands";
|
} from "../../lib/tauri-commands";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
sanitizeRelayUrl,
|
sanitizeRelayUrl,
|
||||||
} from "../../lib/urlRelay";
|
} from "../../lib/urlRelay";
|
||||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||||
|
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||||
import UrlToast, {
|
import UrlToast, {
|
||||||
URL_TOAST_PRIMARY_SELECTOR,
|
URL_TOAST_PRIMARY_SELECTOR,
|
||||||
URL_TOAST_SELECTOR,
|
URL_TOAST_SELECTOR,
|
||||||
@@ -48,6 +49,22 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export type PromptSource = "relay" | UrlSource;
|
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. */
|
/** Higher wins. Provenance, not recency. */
|
||||||
const SOURCE_RANK: Record<PromptSource, number> = {
|
const SOURCE_RANK: Record<PromptSource, number> = {
|
||||||
heuristic: 0,
|
heuristic: 0,
|
||||||
@@ -99,8 +116,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
||||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -131,17 +148,22 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// replacing a first would otherwise mutate the toast in place, swapping the
|
// 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
|
// 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.
|
// remounts the component, so a new URL is unmistakably a new prompt.
|
||||||
const [urlPrompt, setUrlPrompt] = useState<{
|
const [urlPrompt, setUrlPrompt] = useState<UrlPrompt | null>(null);
|
||||||
url: string;
|
|
||||||
label: string;
|
|
||||||
source: PromptSource;
|
|
||||||
seq: number;
|
|
||||||
} | null>(null);
|
|
||||||
const promptSeqRef = useRef(0);
|
const promptSeqRef = useRef(0);
|
||||||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
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.
|
* A mirror of the prompt slot, written *eagerly* by the two functions that
|
||||||
const urlPromptRef = useRef<{ url: string } | null>(null);
|
* 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
|
* Empty the prompt slot, and put focus somewhere real if it was inside the
|
||||||
@@ -156,10 +178,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
*/
|
*/
|
||||||
const dismissUrlPrompt = useCallback(() => {
|
const dismissUrlPrompt = useCallback(() => {
|
||||||
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
|
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
|
||||||
|
urlPromptRef.current = null;
|
||||||
setUrlPrompt(null);
|
setUrlPrompt(null);
|
||||||
if (wasInside) termRef.current?.focus();
|
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
|
* The only writer of the prompt slot. Re-validates whatever the caller
|
||||||
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||||||
@@ -178,17 +230,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
console.warn("Refusing to prompt for a URL that failed validation");
|
console.warn("Refusing to prompt for a URL that failed validation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUrlPrompt((current) => {
|
// Read and written through the ref rather than a functional update, so
|
||||||
if (!supersedes({ url, source }, current)) return current;
|
// 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;
|
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.
|
* The keyboard route into the toast.
|
||||||
@@ -219,14 +273,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
}, []);
|
}, []);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
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 [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
// True while the program in the container holds mouse reporting open (any of
|
||||||
// Tracks user intent to follow output — only set to false by explicit user
|
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
||||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
const [mouseCaptured, setMouseCaptured] = useState(false);
|
||||||
const autoFollowRef = useRef(true);
|
const mouseCapturedRef = useRef(false);
|
||||||
const lastUserScrollTimeRef = useRef(0);
|
|
||||||
|
|
||||||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||||
// and the unmount-cleanup effect further down).
|
// and the unmount-cleanup effect further down).
|
||||||
@@ -251,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 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
|
// 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
|
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
||||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
// sibling of the host rather than a child. Nothing painted over the pane
|
||||||
// drop on its own account — asking "is this element mine?" once turned every
|
// refuses a drop on its own account — asking "is this element mine?" once
|
||||||
// pixel under that chrome into a permanent dead zone.
|
// turned every pixel under that chrome into a permanent dead zone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -315,12 +366,60 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
};
|
};
|
||||||
}, [sessionId, sendInput]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
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",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0d1117",
|
background: "#0d1117",
|
||||||
@@ -364,7 +463,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
console.warn("Refusing to open a link that failed validation");
|
console.warn("Refusing to open a link that failed validation");
|
||||||
return;
|
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 });
|
}, { urlRegex });
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
@@ -391,6 +501,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
useAppState.getState().sttToggle();
|
useAppState.getState().sttToggle();
|
||||||
return false;
|
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
|
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||||
@@ -501,42 +619,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track text selection to show copy hint in status bar
|
// Track text selection to show copy hint in status bar
|
||||||
const selectionDisposable = term.onSelectionChange(() => {
|
const selectionDisposable = term.onSelectionChange(() => {
|
||||||
setTerminalHasSelection(term.hasSelection());
|
setTerminalHasSelection(term.hasSelection());
|
||||||
@@ -599,15 +681,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data, () => {
|
// Scrolling on new output is xterm's own job, and it already gets it
|
||||||
if (autoFollowRef.current) {
|
// right: it follows the tail while the viewport is at the bottom and
|
||||||
term.scrollToBottom();
|
// holds position while you are reading further up. The manual
|
||||||
if (!isAtBottomRef.current) {
|
// `scrollToBottom()` that used to live here fought that second half.
|
||||||
isAtBottomRef.current = true;
|
term.write(data, syncMouseCapture);
|
||||||
setIsAtBottom(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
detector.feed(data);
|
detector.feed(data);
|
||||||
|
|
||||||
// Scan for SSO refresh marker in terminal output
|
// Scan for SSO refresh marker in terminal output
|
||||||
@@ -649,11 +727,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeRafId = requestAnimationFrame(() => {
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
resizeRafId = null;
|
resizeRafId = null;
|
||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
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();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
if (autoFollowRef.current) {
|
// Only re-anchor a viewport that was already on the tail. This
|
||||||
term.scrollToBottom();
|
// 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);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -667,14 +752,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
osc52Disposable.dispose();
|
osc52Disposable.dispose();
|
||||||
relayDisposable.dispose();
|
relayDisposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
setTerminalHasSelection(false);
|
setTerminalHasSelection(false);
|
||||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
@@ -723,10 +805,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (active) {
|
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();
|
fitRef.current?.fit();
|
||||||
if (autoFollowRef.current) {
|
if (wasAtBottom) term.scrollToBottom();
|
||||||
term.scrollToBottom();
|
|
||||||
}
|
|
||||||
term.focus();
|
term.focus();
|
||||||
}
|
}
|
||||||
}, [active, gpuRenderingSetting]);
|
}, [active, gpuRenderingSetting]);
|
||||||
@@ -767,19 +851,65 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [imagePasteMsg]);
|
}, [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(() => {
|
const handleOpenUrl = useCallback(() => {
|
||||||
if (!urlPrompt) return;
|
if (!urlPrompt) return;
|
||||||
// Validated again at the sink. `promptUrl` is the only writer and already
|
// 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
|
// 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);
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
dismissUrlPrompt();
|
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a URL that failed validation");
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
|
dismissUrlPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
// The prompt this click was for. Captured before the await, because the
|
||||||
}, [urlPrompt, dismissUrlPrompt]);
|
// 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.
|
* Open the prompted URL in the container's own browser instead of the host's.
|
||||||
@@ -792,6 +922,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const handleOpenUrlInContainer = useCallback(() => {
|
const handleOpenUrlInContainer = useCallback(() => {
|
||||||
if (!urlPrompt) return;
|
if (!urlPrompt) return;
|
||||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
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();
|
dismissUrlPrompt();
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a URL that failed validation");
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
@@ -826,39 +963,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
}, [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 writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -876,20 +980,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleAutoFollow = useCallback(() => {
|
// Surface the capture state and its escape hatch to the status bar, but only
|
||||||
const next = !autoFollowRef.current;
|
// while this is the visible terminal.
|
||||||
autoFollowRef.current = next;
|
useEffect(() => {
|
||||||
setIsAutoFollow(next);
|
if (!active) return;
|
||||||
if (next) {
|
setTerminalMouseCaptured(mouseCaptured);
|
||||||
const term = termRef.current;
|
setReleaseActiveMouse(releaseMouse);
|
||||||
if (term) {
|
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||||
fitRef.current?.fit();
|
|
||||||
term.scrollToBottom();
|
// On unmount, if this was the active terminal, clear the status-bar state so
|
||||||
isAtBottomRef.current = true;
|
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
||||||
setIsAtBottom(true);
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -904,6 +1014,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
label={urlPrompt.label}
|
label={urlPrompt.label}
|
||||||
onOpen={handleOpenUrl}
|
onOpen={handleOpenUrl}
|
||||||
onOpenInContainer={handleOpenUrlInContainer}
|
onOpenInContainer={handleOpenUrlInContainer}
|
||||||
|
signInDefault={signInDefault}
|
||||||
onDismiss={dismissUrlPrompt}
|
onDismiss={dismissUrlPrompt}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -915,18 +1026,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</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
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||||
FitAddon measures the host element it's mounted into; padding there
|
FitAddon measures the host element it's mounted into; padding there
|
||||||
causes the grid to overhang and clip the rightmost column / bottom
|
causes the grid to overhang and clip the rightmost column / bottom
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
|
|||||||
url={SIGN_IN}
|
url={SIGN_IN}
|
||||||
onOpen={noop}
|
onOpen={noop}
|
||||||
onOpenInContainer={noop}
|
onOpenInContainer={noop}
|
||||||
|
signInDefault="container"
|
||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
|
|||||||
url={SIGN_IN}
|
url={SIGN_IN}
|
||||||
onOpen={noop}
|
onOpen={noop}
|
||||||
onOpenInContainer={noop}
|
onOpenInContainer={noop}
|
||||||
|
signInDefault="container"
|
||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
|
|||||||
|
|
||||||
describe("Anthropic sign-in links", () => {
|
describe("Anthropic sign-in links", () => {
|
||||||
// The callback listener a `claude login` is waiting on is *inside* the
|
// The callback listener a `claude login` is waiting on is *inside* the
|
||||||
// container. Sending the user to their host browser completes the sign-in
|
// container, so a sign-in is the one case where the host browser may be the
|
||||||
// and then posts the result where nothing is listening, and the terminal
|
// wrong lead. Whether it actually is depends on the project — a live auth
|
||||||
// hangs to its timeout — so for these, and only these, the container-side
|
// bridge carries the callback back, and the container-side alternative is
|
||||||
// browser leads.
|
// not installed on a fresh project — so the owner decides and passes
|
||||||
|
// `signInDefault`. This component only renders the decision.
|
||||||
const SIGN_IN =
|
const SIGN_IN =
|
||||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
"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");
|
.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(
|
render(
|
||||||
<UrlToast
|
<UrlToast
|
||||||
url={SIGN_IN}
|
url={SIGN_IN}
|
||||||
@@ -189,9 +262,9 @@ describe("UrlToast", () => {
|
|||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(actions()).toEqual(["In container", "Open"]);
|
expect(actions()).toEqual(["Open", "In container"]);
|
||||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
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}
|
url={SIGN_IN}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onOpenInContainer={noop}
|
onOpenInContainer={noop}
|
||||||
|
signInDefault="container"
|
||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -211,12 +285,14 @@ describe("UrlToast", () => {
|
|||||||
|
|
||||||
it("leaves an ordinary URL alone", () => {
|
it("leaves an ordinary URL alone", () => {
|
||||||
// A `gh auth login` device code, a docs page, a preview build — the host
|
// 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(
|
render(
|
||||||
<UrlToast
|
<UrlToast
|
||||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||||
onOpen={noop}
|
onOpen={noop}
|
||||||
onOpenInContainer={noop}
|
onOpenInContainer={noop}
|
||||||
|
signInDefault="container"
|
||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -232,6 +308,7 @@ describe("UrlToast", () => {
|
|||||||
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
||||||
onOpen={noop}
|
onOpen={noop}
|
||||||
onOpenInContainer={noop}
|
onOpenInContainer={noop}
|
||||||
|
signInDefault="container"
|
||||||
onDismiss={noop}
|
onDismiss={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { KeyboardEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
||||||
|
import type { SignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||||
import Button from "../ui/Button";
|
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
|
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||||
* the project has no browser to open it in. */
|
* the project has no browser to open it in. */
|
||||||
onOpenInContainer?: () => void;
|
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;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +76,26 @@ interface Props {
|
|||||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||||
* B that arrived a second later.
|
* 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
|
* 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
|
* default, unconditionally. A sign-in is the one case where it might not be:
|
||||||
* is waiting on is inside the container, so a host browser completes the sign-in
|
* the callback listener the CLI is waiting on is inside the container, so a
|
||||||
* and then posts the result somewhere nothing is listening, and the terminal
|
* host browser can complete the sign-in and then post the result where nothing
|
||||||
* hangs until it times out. Making the host button primary there was quietly
|
* is listening, leaving the terminal to hang to its timeout.
|
||||||
* 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
|
* *Can*, not *does* — which is why this is no longer decided from the URL. The
|
||||||
* stays, because a user who has the auth bridge on, or who wants their existing
|
* auth bridge mirrors that container listener onto the same host port, and the
|
||||||
* browser session, still needs it.
|
* 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
|
* ## 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",
|
label = "Long URL detected",
|
||||||
onOpen,
|
onOpen,
|
||||||
onOpenInContainer,
|
onOpenInContainer,
|
||||||
|
signInDefault = "host-fallback",
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const origin = urlOrigin(url);
|
const origin = urlOrigin(url);
|
||||||
@@ -103,18 +132,27 @@ export default function UrlToast({
|
|||||||
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
||||||
// host button is the only action there is, so it stays primary.
|
// host button is the only action there is, so it stays primary.
|
||||||
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
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
|
// `Button` already owns the filled/outlined variants — including the rule
|
||||||
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||||
// foreground/link accent and fails WCAG AA behind white text.
|
// foreground/link accent and fails WCAG AA behind white text.
|
||||||
const hostButton = (
|
const hostButton = (
|
||||||
<Button
|
<Button
|
||||||
variant={signIn ? "secondary" : "primary"}
|
variant={containerLeads ? "secondary" : "primary"}
|
||||||
data-url-toast-primary={signIn ? undefined : "true"}
|
data-url-toast-primary={containerLeads ? undefined : "true"}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
title={
|
title={
|
||||||
signIn
|
containerLeads
|
||||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -128,8 +166,8 @@ export default function UrlToast({
|
|||||||
// the container's own loopback, which is where the tool waiting for it is
|
// the container's own loopback, which is where the tool waiting for it is
|
||||||
// listening — no host round trip, no auth bridge.
|
// listening — no host round trip, no auth bridge.
|
||||||
<Button
|
<Button
|
||||||
variant={signIn ? "primary" : "secondary"}
|
variant={containerLeads ? "primary" : "secondary"}
|
||||||
data-url-toast-primary={signIn ? "true" : undefined}
|
data-url-toast-primary={containerLeads ? "true" : undefined}
|
||||||
onClick={onOpenInContainer}
|
onClick={onOpenInContainer}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
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,
|
lineHeight: 1.35,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign-in link — the callback listener is inside the container.
|
{containerLeads
|
||||||
Opening it there closes the loop; the host browser needs the auth
|
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{signIn ? (
|
{containerLeads ? (
|
||||||
<>
|
<>
|
||||||
{containerButton}
|
{containerButton}
|
||||||
{hostButton}
|
{hostButton}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
|
|||||||
describe("chrome over a pane, with no dialog open", () => {
|
describe("chrome over a pane, with no dialog open", () => {
|
||||||
/** Everything that is painted over a pane and is not a blocker. */
|
/** Everything that is painted over a pane and is not a blocker. */
|
||||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
|
||||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
// and a *sibling* of the xterm host — so "does the pane contain what is
|
||||||
// host — so "does the pane contain what is painted here?" made the
|
// painted here?" made the terminal's top-right corner a dead zone no
|
||||||
// terminal's top-right corner a dead zone no user action could clear.
|
// user action could clear. (The retired Following toggle held the same
|
||||||
["the Following/Paused toggle", () => document.createElement("button")],
|
// 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
|
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||||
// pane, and its error cards stay until dismissed.
|
// pane, and its error cards stay until dismissed.
|
||||||
["a toast card", () => document.createElement("div")],
|
["a toast card", () => document.createElement("div")],
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
*
|
*
|
||||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
|
||||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
* of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||||
* Permanent dead zones no user action could clear.
|
* Permanent dead zones no user action could clear.
|
||||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||||
|
|||||||
@@ -350,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
|
|||||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||||
//
|
//
|
||||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
|
||||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
// rather than polling) → migrateProjectToBase → the project sits
|
||||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||||
// rollbackMigration.
|
// rollbackMigration.
|
||||||
//
|
//
|
||||||
@@ -361,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
|
|||||||
//
|
//
|
||||||
// Progress arrives on the existing `container-progress` event.
|
// 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) =>
|
export const getContainerStaleness = (projectId: string) =>
|
||||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||||
|
|
||||||
@@ -386,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
|
|||||||
* app crash shows up here as phase "interrupted". */
|
* app crash shows up here as phase "interrupted". */
|
||||||
export const getMigrationState = (projectId: string) =>
|
export const getMigrationState = (projectId: string) =>
|
||||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
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 });
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
|
|||||||
* A direct port of `usable_sign_in_link` in
|
* A direct port of `usable_sign_in_link` in
|
||||||
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
||||||
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
|
* 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.
|
* 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
|
* 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
|
// 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
|
// 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
|
// 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
|
// stopping the match early only means the legitimate prefix survives
|
||||||
// instead of the whole candidate being thrown away.
|
// instead of the whole candidate being thrown away.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MAX_RELAY_URL_LENGTH,
|
MAX_RELAY_URL_LENGTH,
|
||||||
RelayRateLimiter,
|
RelayRateLimiter,
|
||||||
URL_RELAY_OSC,
|
URL_RELAY_OSC,
|
||||||
|
isAnthropicSignInUrl,
|
||||||
parseUrlRelayOsc,
|
parseUrlRelayOsc,
|
||||||
sanitizeRelayUrl,
|
sanitizeRelayUrl,
|
||||||
urlOrigin,
|
urlOrigin,
|
||||||
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
|
|||||||
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
|
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
|
* 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
|
* 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
|
* (`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.
|
* Whether this is a URL that signs the user in to Anthropic.
|
||||||
*
|
*
|
||||||
* Used to decide *presentation*, not permission — the toast makes the
|
* Classification only. It answers "is this a sign-in link", never "where should
|
||||||
* container-side browser the default action for these, because the OAuth
|
* it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
|
||||||
* callback listener is inside the container and the host has nothing to catch
|
* because it depends on things this module has no business knowing: whether the
|
||||||
* it with. It is deliberately the same host allowlist the sign-in flow itself
|
* project's auth bridge is live, and whether a browser is actually installed in
|
||||||
* uses, so the two cannot disagree about what a sign-in link is.
|
* 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 {
|
export function isAnthropicSignInUrl(url: string): boolean {
|
||||||
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
|
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
|
||||||
|
|||||||
+14
-10
@@ -205,16 +205,20 @@ interface AppState {
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
|
// Whether a program in the active terminal is holding mouse reporting open,
|
||||||
|
// and how to take it back. Surfaced so the release control can live in the
|
||||||
|
// status bar: painted over the terminal it would sit on top of whatever TUI
|
||||||
|
// is asking for the mouse, and swallow clicks aimed at that program's own
|
||||||
|
// top-right corner for as long as it ran. Only the active TerminalView
|
||||||
|
// writes these.
|
||||||
|
terminalMouseCaptured: boolean;
|
||||||
|
setTerminalMouseCaptured: (captured: boolean) => void;
|
||||||
|
releaseActiveMouse: () => void;
|
||||||
|
setReleaseActiveMouse: (fn: () => void) => void;
|
||||||
// STT toggle for the active session, registered by App so the terminal's
|
// STT toggle for the active session, registered by App so the terminal's
|
||||||
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
||||||
sttToggle: () => void;
|
sttToggle: () => void;
|
||||||
setSttToggle: (fn: () => void) => void;
|
setSttToggle: (fn: () => void) => void;
|
||||||
// Active terminal scroll state, surfaced so the status bar can host the
|
|
||||||
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
||||||
terminalAtBottom: boolean;
|
|
||||||
setTerminalAtBottom: (v: boolean) => void;
|
|
||||||
scrollActiveToBottom: () => void;
|
|
||||||
setScrollActiveToBottom: (fn: () => void) => void;
|
|
||||||
sidebarView: "projects" | "settings";
|
sidebarView: "projects" | "settings";
|
||||||
setSidebarView: (view: "projects" | "settings") => void;
|
setSidebarView: (view: "projects" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
|
terminalMouseCaptured: false,
|
||||||
|
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
|
||||||
|
releaseActiveMouse: () => {},
|
||||||
|
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
|
||||||
sttToggle: () => {},
|
sttToggle: () => {},
|
||||||
setSttToggle: (fn) => set({ sttToggle: fn }),
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
||||||
terminalAtBottom: true,
|
|
||||||
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
||||||
scrollActiveToBottom: () => {},
|
|
||||||
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
sidebarCollapsed: loadSidebarCollapsed(),
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
|||||||
@@ -639,8 +639,14 @@ fi
|
|||||||
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
||||||
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
||||||
# slow or offline network never blocks container readiness.
|
# slow or offline network never blocks container readiness.
|
||||||
|
# The lock is shared with the per-session update that every Claude terminal
|
||||||
|
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
|
||||||
|
# "Container ready" is printed *after* this finishes, so a user who starts a
|
||||||
|
# project and immediately opens a tab would otherwise have two updaters
|
||||||
|
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
|
||||||
|
# damage right before it ran the result.
|
||||||
echo "entrypoint: checking for Claude Code updates..."
|
echo "entrypoint: checking for Claude Code updates..."
|
||||||
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
|
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
|
||||||
&& echo "entrypoint: Claude Code is up to date" \
|
&& echo "entrypoint: Claude Code is up to date" \
|
||||||
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
AppStream metadata for the AppImage.
|
||||||
|
|
||||||
|
Without this an AppImage manager (Gear Lever, AppImageLauncher and the like)
|
||||||
|
can adopt the file but has nothing to show for it: no summary, no category,
|
||||||
|
no release history. appimagetool warns about its absence on every build.
|
||||||
|
|
||||||
|
The id matches `identifier` in tauri.conf.json and the .desktop basename, so
|
||||||
|
the desktop entry, the AppStream component and the AppImage all name the
|
||||||
|
same application. `@VERSION@` is substituted at build time.
|
||||||
|
-->
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>com.triple-c.desktop</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Triple-C</name>
|
||||||
|
<summary>Run Claude Code sessions in isolated Docker containers</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Triple-C sandboxes Claude Code inside per-project Docker containers, so an
|
||||||
|
agent can install packages, edit files and run commands without touching
|
||||||
|
the host. Each project gets its own container, its own credentials and its
|
||||||
|
own terminal sessions.
|
||||||
|
</p>
|
||||||
|
<p>Features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Per-project containers with persistent home and config volumes</li>
|
||||||
|
<li>Multiple terminal sessions per project, in one reorderable tab strip</li>
|
||||||
|
<li>Notes that can be sent straight into a running agent's prompt</li>
|
||||||
|
<li>Anthropic, AWS Bedrock, Ollama, llama.cpp and OpenAI-compatible backends</li>
|
||||||
|
<li>Remote access over a browser terminal, and speech-to-text input</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">Triple-C.desktop</launchable>
|
||||||
|
<categories>
|
||||||
|
<category>Development</category>
|
||||||
|
<category>Utility</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/shadowdao/triple-c</url>
|
||||||
|
<url type="bugtracker">https://github.com/shadowdao/triple-c/issues</url>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>triple-c</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="@VERSION@" date="@DATE@"/>
|
||||||
|
</releases>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1"/>
|
||||||
|
</component>
|
||||||
Executable
+317
@@ -0,0 +1,317 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Post-process a built AppImage: make it start on modern Mesa, and make it
|
||||||
|
# adoptable and updatable by an AppImage manager.
|
||||||
|
#
|
||||||
|
# Tauri hands off to linuxdeploy, which offers no hook between building the
|
||||||
|
# AppDir and packing it, so both jobs are done by unpacking the finished image
|
||||||
|
# and repacking it. That is also why the update information is embedded here
|
||||||
|
# rather than passed to the bundler.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. The bundled Wayland client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
|
||||||
|
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
|
||||||
|
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
|
||||||
|
# against *our* copy instead of the system one it was built against, and when
|
||||||
|
# ours is older than Mesa needs, EGL initialisation fails outright:
|
||||||
|
#
|
||||||
|
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||||
|
#
|
||||||
|
# WebKitGTK prints that from its own C code and kills the webview, so the
|
||||||
|
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
|
||||||
|
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
|
||||||
|
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
|
||||||
|
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
|
||||||
|
# `wl_fixes_interface` among them. Removing this one file from the AppDir
|
||||||
|
# fixes it; removing libwayland-egl or libepoxy does not.
|
||||||
|
#
|
||||||
|
# **Building on a newer runner would not fix this.** libwayland-client is a
|
||||||
|
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
|
||||||
|
# match the compositor and Mesa actually running, not the ones the build
|
||||||
|
# machine had. Any pinned version is wrong on a system newer than the builder,
|
||||||
|
# so the only correct version is the host's. That is what AppImage excludelists
|
||||||
|
# are for; this library simply is not on linuxdeploy's.
|
||||||
|
#
|
||||||
|
# Bundling a *newer* wayland instead would not fix this either, only defer it.
|
||||||
|
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
|
||||||
|
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
|
||||||
|
# libwayland-client.so.0. If those symbols will not resolve, the driver never
|
||||||
|
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
|
||||||
|
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
|
||||||
|
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
|
||||||
|
# we pick is one wayland release away from being too old again.
|
||||||
|
#
|
||||||
|
# So the copy is not deleted, it is demoted. It moves to a directory that is
|
||||||
|
# not on the loader path, and a hook puts that directory on the path only when
|
||||||
|
# the host has no libwayland-client of its own. Hosts with one — which is
|
||||||
|
# every host with a graphical desktop, since Mesa itself depends on it — get
|
||||||
|
# theirs, matching their Mesa. A host without one still gets a working app.
|
||||||
|
#
|
||||||
|
# The ordering works because `AppRun.wrapped` appends the inherited
|
||||||
|
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
|
||||||
|
# lands last: a fallback, never an override.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Metadata an AppImage manager needs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Two things, neither of which the bundler produces:
|
||||||
|
#
|
||||||
|
# * AppStream metadata, so a manager can show what the app is rather than a
|
||||||
|
# bare filename. appimagetool warns about its absence on every build.
|
||||||
|
# * Update information embedded in the image — the string that tells a
|
||||||
|
# manager where to look for a newer build. Without it the app can be
|
||||||
|
# adopted but never updated, which is the whole point.
|
||||||
|
#
|
||||||
|
# The update URL is a **fixed** tag on the GitHub mirror, which is where
|
||||||
|
# updates are pulled from, rather than `releases/latest`. `latest` follows
|
||||||
|
# whatever release is newest, and the Gitea-to-GitHub backfill creates one
|
||||||
|
# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which
|
||||||
|
# carry no AppImage. A fixed tag cannot be pointed at a release that has none,
|
||||||
|
# and is equally immune to a release marked prerelease.
|
||||||
|
#
|
||||||
|
# The output is named for the fixed tag too. zsync records the filename it was
|
||||||
|
# generated for and a client resolves it relative to the .zsync URL, so a
|
||||||
|
# versioned name would send every client looking for the version it already
|
||||||
|
# has. The versioned copy is written afterwards for the normal release.
|
||||||
|
#
|
||||||
|
# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what
|
||||||
|
# a desktop menu and most managers use to file the application.
|
||||||
|
#
|
||||||
|
# Usage: finalize-appimage.sh <directory holding the .AppImage>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LIB="libwayland-client.so.0"
|
||||||
|
FALLBACK_DIR="usr/lib/wayland-fallback"
|
||||||
|
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
|
||||||
|
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||||
|
|
||||||
|
APP_ID="com.triple-c.desktop"
|
||||||
|
# The channel pair lives in its own directory. Left beside the versioned image
|
||||||
|
# they are picked up by the release job's `*.AppImage` glob, and every release
|
||||||
|
# then carries an eighty-megabyte byte-identical duplicate under a second name
|
||||||
|
# — which is exactly as confusing on a downloads page as it sounds.
|
||||||
|
CHANNEL_DIR="update-channel"
|
||||||
|
STABLE_NAME="Triple-C_x86_64.AppImage"
|
||||||
|
UPDATE_TAG="linux-latest"
|
||||||
|
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
|
||||||
|
CATEGORIES="Development;Utility;"
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
|
||||||
|
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
|
||||||
|
# metadata is missing under any other name — while the script cheerfully
|
||||||
|
# reported it present. The AppStream id inside the file is unchanged and is
|
||||||
|
# what actually identifies the component; only the filename follows the tool.
|
||||||
|
appdata_installed_as="Triple-C.appdata.xml"
|
||||||
|
|
||||||
|
dir="${1:?usage: finalize-appimage.sh <bundle/appimage directory>}"
|
||||||
|
cd "$dir"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
images=(*.AppImage)
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#images[@]} -eq 0 ]; then
|
||||||
|
echo "No .AppImage in $dir — nothing to do." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Refused here rather than after the repack: with two present the old position
|
||||||
|
# let the script download appimagetool, repack, overwrite the versioned
|
||||||
|
# artifact and write the channel pair, *then* fail — and it silently picked
|
||||||
|
# images[0], which is glob order, i.e. the older version.
|
||||||
|
if [ ${#images[@]} -ne 1 ]; then
|
||||||
|
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
appimage="${images[0]}"
|
||||||
|
here="$PWD"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
check="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work" "$check"' EXIT
|
||||||
|
|
||||||
|
echo "Inspecting $appimage"
|
||||||
|
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
|
||||||
|
root="$work/squashfs-root"
|
||||||
|
|
||||||
|
# The demotion and the metadata are independent jobs, and an absent library
|
||||||
|
# must not skip the second. An early exit here also left `update-channel/`
|
||||||
|
# uncreated, which killed the publish step on a missing directory and took the
|
||||||
|
# tag and mirror jobs down with it — a half-published release.
|
||||||
|
demoted=false
|
||||||
|
if [ -e "$root/usr/lib/$LIB" ]; then
|
||||||
|
|
||||||
|
mkdir -p "$root/$FALLBACK_DIR"
|
||||||
|
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
|
||||||
|
|
||||||
|
cat > "$root/$HOOK" <<'HOOK_EOF'
|
||||||
|
#! /usr/bin/env bash
|
||||||
|
# Fall back to the bundled libwayland-client only when the host has none.
|
||||||
|
#
|
||||||
|
# The host's copy is the correct one whenever it exists: its Mesa was built
|
||||||
|
# against it, and `libEGL.so.1` needs symbols from it before it will load.
|
||||||
|
# Ours is here so a host without any libwayland-client still starts.
|
||||||
|
#
|
||||||
|
# This runs before AppRun.wrapped, which appends the inherited
|
||||||
|
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
|
||||||
|
_tc_host_has_wayland_client() {
|
||||||
|
if command -v ldconfig >/dev/null 2>&1 &&
|
||||||
|
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local d
|
||||||
|
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
|
||||||
|
/lib /lib64 /lib/x86_64-linux-gnu; do
|
||||||
|
[ -e "$d/libwayland-client.so.0" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! _tc_host_has_wayland_client; then
|
||||||
|
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
|
||||||
|
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||||
|
fi
|
||||||
|
unset -f _tc_host_has_wayland_client
|
||||||
|
HOOK_EOF
|
||||||
|
chmod +x "$root/$HOOK"
|
||||||
|
|
||||||
|
# AppRun sources each hook by name rather than globbing the directory, so a
|
||||||
|
# new hook file is inert until AppRun is told about it.
|
||||||
|
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
|
||||||
|
python3 - "$root/AppRun" <<'PATCH_EOF'
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
src = open(path).read()
|
||||||
|
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
|
||||||
|
if exec_line not in src:
|
||||||
|
raise SystemExit("AppRun does not have the exec line this patch expects")
|
||||||
|
src = src.replace(
|
||||||
|
exec_line,
|
||||||
|
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
|
||||||
|
)
|
||||||
|
open(path, "w").write(src)
|
||||||
|
PATCH_EOF
|
||||||
|
fi
|
||||||
|
demoted=true
|
||||||
|
echo "Demoted $LIB to $FALLBACK_DIR."
|
||||||
|
else
|
||||||
|
echo "$LIB is not bundled — nothing to demote."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- metadata -------------------------------------------------------------
|
||||||
|
|
||||||
|
# Version comes from the artifact rather than a second source that could drift.
|
||||||
|
version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
|
||||||
|
[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ -f "$appdata_src" ]; then
|
||||||
|
mkdir -p "$root/usr/share/metainfo"
|
||||||
|
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
|
||||||
|
"$appdata_src" > "$root/usr/share/metainfo/$appdata_installed_as"
|
||||||
|
echo "Added AppStream metadata for $version."
|
||||||
|
else
|
||||||
|
echo "No AppStream source at $appdata_src — skipping." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
|
||||||
|
#
|
||||||
|
# The AppDir root entry is a **symlink** into usr/share/applications, so a
|
||||||
|
# plain `sed -i` replaces the link with a regular file and leaves the real entry
|
||||||
|
# untouched — two divergent copies, of which the empty one is the one that
|
||||||
|
# actually ships and the filled one is the only one a root-only guard can see.
|
||||||
|
# `--follow-symlinks` writes through. Both locations are globbed because the
|
||||||
|
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
|
||||||
|
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
|
||||||
|
[ -e "$desktop" ] || continue
|
||||||
|
if grep -q "^Categories=$" "$desktop"; then
|
||||||
|
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
|
||||||
|
echo "Filled in Categories for ${desktop#"$root"/}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Repacking."
|
||||||
|
|
||||||
|
tool="$work/appimagetool"
|
||||||
|
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
||||||
|
chmod +x "$tool"
|
||||||
|
|
||||||
|
# --appimage-extract-and-run: CI runners generally have no FUSE.
|
||||||
|
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
|
||||||
|
rm -rf "$CHANNEL_DIR"
|
||||||
|
mkdir -p "$CHANNEL_DIR"
|
||||||
|
ARCH=x86_64 "$tool" --appimage-extract-and-run \
|
||||||
|
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
|
||||||
|
chmod +x "$CHANNEL_DIR/$STABLE_NAME"
|
||||||
|
|
||||||
|
# The versioned name is what the per-version release publishes; the stable one
|
||||||
|
# and its .zsync go to the rolling tag. Same bytes, two names, two places.
|
||||||
|
# zsyncmake writes the .zsync into the working directory, not beside the image
|
||||||
|
# it describes, so it has to be collected rather than assumed in place.
|
||||||
|
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
|
||||||
|
|
||||||
|
cp "$CHANNEL_DIR/$STABLE_NAME" "$appimage"
|
||||||
|
chmod +x "$appimage"
|
||||||
|
|
||||||
|
# The guards are the test. Each one is a way the repack could look like it
|
||||||
|
# worked while shipping the original bug.
|
||||||
|
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
|
||||||
|
out="$check/squashfs-root"
|
||||||
|
|
||||||
|
fail() { echo "FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ "$demoted" = true ]; then
|
||||||
|
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
|
||||||
|
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
|
||||||
|
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
||||||
|
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
||||||
|
fi
|
||||||
|
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
||||||
|
|
||||||
|
# An empty Categories or missing metadata ships an image a manager cannot file
|
||||||
|
# or describe, and both fail silently at runtime rather than at build time.
|
||||||
|
# Asserted positively, over every entry: the earlier form checked only that no
|
||||||
|
# *root* file held an empty value, which passed while the real entry under
|
||||||
|
# usr/share/applications shipped empty, and also passed on a missing key.
|
||||||
|
desktops=0
|
||||||
|
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
|
||||||
|
[ -e "$desktop" ] || continue
|
||||||
|
desktops=$((desktops + 1))
|
||||||
|
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|
||||||
|
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
|
||||||
|
done
|
||||||
|
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
|
||||||
|
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
|
||||||
|
|| fail "AppStream metadata did not make it into the image."; }
|
||||||
|
|
||||||
|
# The update string is the difference between adoptable and updatable. It
|
||||||
|
# lives in the image's own `.upd_info` ELF section, not in the .zsync — the
|
||||||
|
# .zsync only records a *relative* filename, which a client resolves against
|
||||||
|
# the URL it fetched the .zsync from. That is exactly why the output is named
|
||||||
|
# for the fixed tag: a versioned name here resolves to the build the client
|
||||||
|
# already has.
|
||||||
|
[ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
|
||||||
|
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
|
||||||
|
|
||||||
|
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|
||||||
|
|| fail "the image does not carry exactly the expected update information."
|
||||||
|
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
|
||||||
|
|| fail "the .zsync names something other than $STABLE_NAME."
|
||||||
|
|
||||||
|
# The versioned release must carry one AppImage, not two. This is the guard
|
||||||
|
# for the duplicate that shipped in 0.4.20 and 0.4.21.
|
||||||
|
shopt -s nullglob
|
||||||
|
beside=(*.AppImage)
|
||||||
|
shopt -u nullglob
|
||||||
|
[ "${#beside[@]}" -eq 1 ] \
|
||||||
|
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
|
||||||
|
|
||||||
|
if [ "$demoted" = true ]; then
|
||||||
|
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
|
||||||
|
else
|
||||||
|
echo "OK: $appimage had no bundled $LIB to demote, and carries"
|
||||||
|
fi
|
||||||
|
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."
|
||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Publish the AppImage and its .zsync to the fixed `linux-latest` tag on the
|
||||||
|
# GitHub mirror — the URL every installed copy checks for updates.
|
||||||
|
#
|
||||||
|
# This exists because the update URL has to be one that never moves.
|
||||||
|
# `releases/latest` does move: it follows whatever release is newest, and the
|
||||||
|
# Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including
|
||||||
|
# the `-win` and `-mac` tags that carry no AppImage. Pointing a million
|
||||||
|
# installed copies at a URL that can resolve to a release with no AppImage in
|
||||||
|
# it is a failure that shows up on users' machines and nowhere else.
|
||||||
|
#
|
||||||
|
# So this tag holds exactly two files, replaced in place on every release.
|
||||||
|
# The versioned per-release artifacts are published separately and are what a
|
||||||
|
# human downloads; this is what the updater reads.
|
||||||
|
#
|
||||||
|
# It writes to GitHub rather than Gitea because that mirror is where updates
|
||||||
|
# are pulled from. Needs GH_PAT with contents write on the mirror.
|
||||||
|
#
|
||||||
|
# **The tag has to exist in Gitea, not just on GitHub, and that is the whole
|
||||||
|
# reason this script touches Gitea at all.** Gitea push-mirrors this repo to
|
||||||
|
# GitHub, and a mirror push deletes remote refs that have no local counterpart.
|
||||||
|
# A tag created only by GitHub's release API therefore survives until the next
|
||||||
|
# mirror run and then vanishes — which is exactly what happened to 0.4.20 and
|
||||||
|
# 0.4.21: the release was created and both URLs verified 200 at 00:38, and the
|
||||||
|
# 13:04 mirror deleted the tag, leaving every installed copy checking a 404.
|
||||||
|
# Versioned tags never had this problem because `create-tag` creates them in
|
||||||
|
# Gitea first. So does this one, now, and before the GitHub release rather than
|
||||||
|
# after, so there is no window where the two disagree.
|
||||||
|
#
|
||||||
|
# Note what this means for verification: publishing correctly is not evidence
|
||||||
|
# the channel still works hours later. The Gitea tag is what makes it durable,
|
||||||
|
# so its absence is treated as a failure rather than a warning.
|
||||||
|
#
|
||||||
|
# Usage: GH_PAT=... GITEA_TOKEN=... GITEA_SHA=... publish-update-channel.sh <dir>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="shadowdao/triple-c"
|
||||||
|
TAG="linux-latest"
|
||||||
|
API="https://api.github.com/repos/$REPO"
|
||||||
|
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
|
||||||
|
|
||||||
|
GITEA_API="${GITEA_API:-https://repo.anhonesthost.net/api/v1}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-CyberCoveLLC/Triple-C}"
|
||||||
|
|
||||||
|
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
|
||||||
|
: "${GITEA_TOKEN:?GITEA_TOKEN is required to anchor the $TAG tag against the mirror}"
|
||||||
|
: "${GITEA_SHA:?GITEA_SHA is required to point the $TAG tag at this build}"
|
||||||
|
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
|
||||||
|
cd "$dir"
|
||||||
|
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
[ -e "$asset" ] || { echo "Missing $asset in $dir" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
|
||||||
|
tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||||
|
# Status, not a boolean. `curl -sf` fails identically for "404, the tag is
|
||||||
|
# genuinely absent" and "503, Gitea is briefly unreachable", and treating the
|
||||||
|
# second as the first means POSTing over a tag that already exists, taking a
|
||||||
|
# 409, and aborting the last step of build-linux — which `create-tag` and
|
||||||
|
# `sync-to-github` both depend on. A transient blip would cost the release, not
|
||||||
|
# just the channel update. Same `case`-on-code idiom as `Upload to Gitea
|
||||||
|
# release` two steps above in the workflow. A refused connection reports 000
|
||||||
|
# and lands in the catch-all.
|
||||||
|
tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; }
|
||||||
|
|
||||||
|
# Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
|
||||||
|
#
|
||||||
|
# An earlier version deleted and recreated it so the tag would name the current
|
||||||
|
# build. That was worse than useless: nothing about the channel depends on
|
||||||
|
# which commit the tag points at — the update string resolves the tag by *name*
|
||||||
|
# and the assets hang off the release object — while a DELETE followed by a
|
||||||
|
# failed POST destroys a working anchor and leaves a window in which a mirror
|
||||||
|
# run prunes GitHub's copy. A transient Gitea error would have converted a
|
||||||
|
# healthy channel into a dead one, which is strictly worse than this step not
|
||||||
|
# existing. Gitea's POST /tags has no force semantics, so the DELETE was only
|
||||||
|
# ever there to get around a 409; asking first removes the need.
|
||||||
|
echo "==> Anchoring the $TAG tag in Gitea"
|
||||||
|
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
case "$anchor_probe" in
|
||||||
|
200)
|
||||||
|
echo " already anchored — left alone"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo " creating it at ${GITEA_SHA:0:9}"
|
||||||
|
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
|
||||||
|
>/dev/null
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2
|
||||||
|
echo " Refusing to guess — creating it blindly would 409 over an" >&2
|
||||||
|
echo " existing tag and abort the release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Not best-effort. Without this tag the mirror removes GitHub's and the
|
||||||
|
# channel dies silently somewhere between now and four hours from now. Reported
|
||||||
|
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
|
||||||
|
anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
[ "$anchor_code" = "200" ] || {
|
||||||
|
echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2
|
||||||
|
echo " without it the mirror would delete GitHub's copy." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Look through the authenticated list rather than /releases/tags/, which never
|
||||||
|
# returns drafts. That matters here specifically: GitHub demotes a published
|
||||||
|
# release to a draft when its tag is deleted, which is the state every mirror
|
||||||
|
# run left behind, so the by-tag lookup reports "absent" while orphaned drafts
|
||||||
|
# sit there holding 86 MB each. Reuse the newest and delete the rest, or they
|
||||||
|
# accumulate one per release forever.
|
||||||
|
echo "==> Looking for the $TAG release (drafts included)"
|
||||||
|
all_releases="$(gh "$API/releases?per_page=100")"
|
||||||
|
mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
tag = sys.argv[1]
|
||||||
|
rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag]
|
||||||
|
rs.sort(key=lambda r: r.get("created_at",""), reverse=True)
|
||||||
|
for r in rs:
|
||||||
|
print(r["id"])
|
||||||
|
' "$TAG")
|
||||||
|
|
||||||
|
release_id="${existing[0]:-}"
|
||||||
|
|
||||||
|
for stale in "${existing[@]:1}"; do
|
||||||
|
echo " deleting orphaned duplicate release $stale"
|
||||||
|
gh -X DELETE "$API/releases/$stale" >/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$release_id" ]; then
|
||||||
|
# A draft has no tag and serves no download URL, so it has to be republished.
|
||||||
|
echo " reusing release $release_id"
|
||||||
|
# `make_latest` is not optional here even though this release already exists.
|
||||||
|
# Publishing a draft is a publish transition, where the API's documented
|
||||||
|
# default is `true` — so omitting it would quietly promote this channel to
|
||||||
|
# the repository's "Latest release" and bury the versioned release a person
|
||||||
|
# actually wants from the releases page.
|
||||||
|
#
|
||||||
|
# `tag_name` is re-sent deliberately, and must be: the API removes the tag
|
||||||
|
# when a PATCH omits it. Given this whole change exists because a tag
|
||||||
|
# disappeared, that is an expensive line to tidy away.
|
||||||
|
gh -X PATCH "$API/releases/$release_id" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null
|
||||||
|
release="$(gh "$API/releases/$release_id")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
echo "==> Creating it"
|
||||||
|
# Not a prerelease, but deliberately not the "latest" release either: this
|
||||||
|
# tag is a channel, and it must never displace the versioned release a
|
||||||
|
# person lands on from the releases page.
|
||||||
|
body_json="$(python3 -c '
|
||||||
|
import json
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": "'"$TAG"'",
|
||||||
|
"name": "Linux update channel",
|
||||||
|
"body": "Rolling AppImage build that Triple-C\u2019s in-app updater reads. "
|
||||||
|
"The two files here are replaced on every release; for a specific "
|
||||||
|
"version, use the versioned releases instead.",
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
"make_latest": "false",
|
||||||
|
}))')"
|
||||||
|
|
||||||
|
# `already_exists` is a benign, recoverable answer, not a reason to abort the
|
||||||
|
# last step of build-linux and lose the release with it. It means a release
|
||||||
|
# for this tag exists but the listing above did not show it — a draft that has
|
||||||
|
# sunk past the first page, since a draft's created_at is frozen while newer
|
||||||
|
# releases push it down. Re-ask by tag and carry on.
|
||||||
|
create_body="$(mktemp)"
|
||||||
|
create_code="$(curl -s -o "$create_body" -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" \
|
||||||
|
-X POST "$API/releases" -d "$body_json")"
|
||||||
|
|
||||||
|
case "$create_code" in
|
||||||
|
201)
|
||||||
|
release="$(cat "$create_body")"
|
||||||
|
;;
|
||||||
|
422)
|
||||||
|
if grep -q "already_exists" "$create_body"; then
|
||||||
|
echo " a release for $TAG already exists but was not listed — reusing it"
|
||||||
|
release="$(gh "$API/releases/tags/$TAG")"
|
||||||
|
else
|
||||||
|
echo "FAILED: GitHub rejected the release (422):" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: creating the $TAG release returned $create_code:" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$create_body"
|
||||||
|
|
||||||
|
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One asset at a time, delete immediately followed by upload. Deleting both up
|
||||||
|
# front leaves the channel holding a fresh AppImage and no .zsync if the second
|
||||||
|
# upload fails, and a client that cannot fetch the .zsync simply stops updating
|
||||||
|
# — no error anyone here would see.
|
||||||
|
asset_ids="$(printf '%s' "$release" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
keep = set(sys.argv[1:])
|
||||||
|
out = {}
|
||||||
|
for a in json.load(sys.stdin).get("assets", []):
|
||||||
|
if a["name"] in keep:
|
||||||
|
out[a["name"]] = a["id"]
|
||||||
|
print(json.dumps(out))
|
||||||
|
' "${ASSETS[@]}")"
|
||||||
|
|
||||||
|
# --retry/--max-time/--http1.1 for the reason the Gitea upload steps in this
|
||||||
|
# repo carry them: real mid-stream failures on large assets (curl 92 and 28).
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
stale_id="$(printf '%s' "$asset_ids" | python3 -c 'import sys,json;print(json.load(sys.stdin).get(sys.argv[1],""))' "$asset")"
|
||||||
|
if [ -n "$stale_id" ]; then
|
||||||
|
echo "==> Replacing $asset (dropping superseded asset $stale_id)"
|
||||||
|
gh -X DELETE "$API/releases/assets/$stale_id" >/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
|
||||||
|
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$asset" \
|
||||||
|
"https://uploads.github.com/repos/$REPO/releases/$release_id/assets?name=$asset" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# The updater is only as good as this URL, and a silent failure here means
|
||||||
|
# every installed copy quietly stops updating. Confirm both are actually
|
||||||
|
# fetchable at the address the AppImage was built to check.
|
||||||
|
# Size as well as status: a 200 only proves something is served at the
|
||||||
|
# address, not that it is this build. GitHub accepting a truncated upload
|
||||||
|
# would pass a status-only check and then fail every client's checksum.
|
||||||
|
echo "==> Verifying the published URLs"
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
url="https://github.com/$REPO/releases/download/$TAG/$asset"
|
||||||
|
local_size="$(stat -c %s "$asset")"
|
||||||
|
|
||||||
|
headers="$(curl -sIL "$url" | tr -d '\r')"
|
||||||
|
code="$(printf '%s\n' "$headers" | awk '/^HTTP\//{c=$2} END{print c}')"
|
||||||
|
served="$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:"{n=$2} END{print n}')"
|
||||||
|
|
||||||
|
[ "$code" = "200" ] || { echo "FAILED: $url returned ${code:-no status}" >&2; exit 1; }
|
||||||
|
[ "$served" = "$local_size" ] \
|
||||||
|
|| { echo "FAILED: $url serves ${served:-unknown} bytes, built $local_size." >&2; exit 1; }
|
||||||
|
echo " $code $served bytes $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Drop the bundled libwayland-client.so.0 out of a built AppImage.
|
|
||||||
#
|
|
||||||
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
|
|
||||||
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
|
|
||||||
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
|
|
||||||
# against *our* copy instead of the system one it was built against, and when
|
|
||||||
# ours is older than Mesa needs, EGL initialisation fails outright:
|
|
||||||
#
|
|
||||||
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
|
||||||
#
|
|
||||||
# WebKitGTK prints that from its own C code and kills the webview, so the
|
|
||||||
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
|
|
||||||
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
|
|
||||||
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
|
|
||||||
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
|
|
||||||
# `wl_fixes_interface` among them. Removing this one file from the AppDir
|
|
||||||
# fixes it; removing libwayland-egl or libepoxy does not.
|
|
||||||
#
|
|
||||||
# **Building on a newer runner would not fix this.** libwayland-client is a
|
|
||||||
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
|
|
||||||
# match the compositor and Mesa actually running, not the ones the build
|
|
||||||
# machine had. Any pinned version is wrong on a system newer than the builder,
|
|
||||||
# so the only correct version is the host's. That is what AppImage excludelists
|
|
||||||
# are for; this library simply is not on linuxdeploy's.
|
|
||||||
#
|
|
||||||
# Bundling a *newer* wayland instead would not fix this either, only defer it.
|
|
||||||
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
|
|
||||||
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
|
|
||||||
# libwayland-client.so.0. If those symbols will not resolve, the driver never
|
|
||||||
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
|
|
||||||
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
|
|
||||||
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
|
|
||||||
# we pick is one wayland release away from being too old again.
|
|
||||||
#
|
|
||||||
# So the copy is not deleted, it is demoted. It moves to a directory that is
|
|
||||||
# not on the loader path, and a hook puts that directory on the path only when
|
|
||||||
# the host has no libwayland-client of its own. Hosts with one — which is
|
|
||||||
# every host with a graphical desktop, since Mesa itself depends on it — get
|
|
||||||
# theirs, matching their Mesa. A host without one still gets a working app.
|
|
||||||
#
|
|
||||||
# The ordering works because `AppRun.wrapped` appends the inherited
|
|
||||||
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
|
|
||||||
# lands last: a fallback, never an override.
|
|
||||||
#
|
|
||||||
# Usage: unbundle-wayland-client.sh <directory holding the .AppImage>
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
LIB="libwayland-client.so.0"
|
|
||||||
FALLBACK_DIR="usr/lib/wayland-fallback"
|
|
||||||
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
|
|
||||||
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
|
||||||
|
|
||||||
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
|
|
||||||
cd "$dir"
|
|
||||||
|
|
||||||
shopt -s nullglob
|
|
||||||
images=(*.AppImage)
|
|
||||||
shopt -u nullglob
|
|
||||||
if [ ${#images[@]} -eq 0 ]; then
|
|
||||||
echo "No .AppImage in $dir — nothing to do." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
appimage="${images[0]}"
|
|
||||||
here="$PWD"
|
|
||||||
|
|
||||||
work="$(mktemp -d)"
|
|
||||||
check="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$work" "$check"' EXIT
|
|
||||||
|
|
||||||
echo "Inspecting $appimage"
|
|
||||||
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
|
|
||||||
root="$work/squashfs-root"
|
|
||||||
|
|
||||||
if [ ! -e "$root/usr/lib/$LIB" ]; then
|
|
||||||
# Not a failure: linuxdeploy may have stopped bundling it, which is the
|
|
||||||
# outcome this script exists to produce.
|
|
||||||
echo "$LIB is not bundled — leaving $appimage alone."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$root/$FALLBACK_DIR"
|
|
||||||
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
|
|
||||||
|
|
||||||
cat > "$root/$HOOK" <<'HOOK_EOF'
|
|
||||||
#! /usr/bin/env bash
|
|
||||||
# Fall back to the bundled libwayland-client only when the host has none.
|
|
||||||
#
|
|
||||||
# The host's copy is the correct one whenever it exists: its Mesa was built
|
|
||||||
# against it, and `libEGL.so.1` needs symbols from it before it will load.
|
|
||||||
# Ours is here so a host without any libwayland-client still starts.
|
|
||||||
#
|
|
||||||
# This runs before AppRun.wrapped, which appends the inherited
|
|
||||||
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
|
|
||||||
_tc_host_has_wayland_client() {
|
|
||||||
if command -v ldconfig >/dev/null 2>&1 &&
|
|
||||||
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local d
|
|
||||||
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
|
|
||||||
/lib /lib64 /lib/x86_64-linux-gnu; do
|
|
||||||
[ -e "$d/libwayland-client.so.0" ] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! _tc_host_has_wayland_client; then
|
|
||||||
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
|
|
||||||
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
|
||||||
fi
|
|
||||||
unset -f _tc_host_has_wayland_client
|
|
||||||
HOOK_EOF
|
|
||||||
chmod +x "$root/$HOOK"
|
|
||||||
|
|
||||||
# AppRun sources each hook by name rather than globbing the directory, so a
|
|
||||||
# new hook file is inert until AppRun is told about it.
|
|
||||||
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
|
|
||||||
python3 - "$root/AppRun" <<'PATCH_EOF'
|
|
||||||
import sys
|
|
||||||
path = sys.argv[1]
|
|
||||||
src = open(path).read()
|
|
||||||
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
|
|
||||||
if exec_line not in src:
|
|
||||||
raise SystemExit("AppRun does not have the exec line this patch expects")
|
|
||||||
src = src.replace(
|
|
||||||
exec_line,
|
|
||||||
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
|
|
||||||
)
|
|
||||||
open(path, "w").write(src)
|
|
||||||
PATCH_EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
|
|
||||||
|
|
||||||
tool="$work/appimagetool"
|
|
||||||
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
|
||||||
chmod +x "$tool"
|
|
||||||
|
|
||||||
# --appimage-extract-and-run: CI runners generally have no FUSE.
|
|
||||||
ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null
|
|
||||||
chmod +x "$appimage"
|
|
||||||
|
|
||||||
# The guards are the test. Each one is a way the repack could look like it
|
|
||||||
# worked while shipping the original bug.
|
|
||||||
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
|
|
||||||
out="$check/squashfs-root"
|
|
||||||
|
|
||||||
fail() { echo "FAILED: $1" >&2; exit 1; }
|
|
||||||
|
|
||||||
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
|
|
||||||
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
|
|
||||||
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
|
||||||
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
|
||||||
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
|
||||||
|
|
||||||
echo "OK: $appimage now prefers the host $LIB, with a bundled fallback."
|
|
||||||
Reference in New Issue
Block a user