Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db648230ee | ||
|
|
5a452e7a2a | ||
|
|
5a09254538 | ||
|
|
9297020688 | ||
|
|
bf8094dbc4 | ||
|
|
90b7e4ccb2 | ||
|
|
afe9d5cdb2 | ||
|
|
b59c6148ff | ||
|
|
95a78fe9a3 | ||
|
|
307ea07409 | ||
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec | ||
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 | ||
|
|
670450ccfd | ||
|
|
a0b9f1e19b | ||
|
|
9fadfbc37a | ||
|
|
a3bdf6f4da |
@@ -299,8 +299,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -319,14 +345,22 @@ jobs:
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
@@ -418,8 +452,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
|
||||
@@ -172,8 +172,34 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
# `npm ci` — from the lockfile, never resolving afresh.
|
||||
#
|
||||
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||
# install`, which deleted the lockfile "to ensure correct
|
||||
# platform-specific bindings" (2d4fce9). That made every build
|
||||
# re-resolve the whole tree against the registry, so a dependency
|
||||
# publishing a new version could break CI with no change to this
|
||||
# repo — and one did. Deleting the lockfile then hit a null
|
||||
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||
#
|
||||
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||
#
|
||||
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||
# Reproduced exactly by removing the lockfile locally on the same
|
||||
# Node 22.23.2 the runner installs.
|
||||
#
|
||||
# The binding worry is obsolete: the committed lockfile records 25
|
||||
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||
# with a clean tsc, a successful build and 752 passing tests from the
|
||||
# resulting tree.
|
||||
#
|
||||
# Do not "fix" a future dependency error by deleting the lockfile
|
||||
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
@@ -185,16 +211,38 @@ jobs:
|
||||
working-directory: ./app
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
# AppImage only: the .deb and .rpm were dropped in favour of the one
|
||||
# artifact that runs everywhere, and building them is pure cost.
|
||||
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
|
||||
npx tauri build --bundles appimage
|
||||
|
||||
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||
# window comes up blank. It has to come from the host; see the script
|
||||
# header for the evidence and the trade.
|
||||
- name: Finalize the AppImage
|
||||
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
# The versioned AppImage only. The update channel's copy lives in
|
||||
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||
# it up and publish an 80 MB duplicate under a second name.
|
||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# A green job that published nothing is the worst outcome available:
|
||||
# the release exists, carries no AppImage, and nobody is told. The
|
||||
# `|| true` above is there so a missing bundle does not mask the real
|
||||
# error, which makes this check the thing that catches it.
|
||||
shopt -s nullglob
|
||||
collected=(artifacts/*)
|
||||
if [ ${#collected[@]} -eq 0 ]; then
|
||||
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
@@ -270,6 +318,19 @@ jobs:
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
# The fixed tag every installed AppImage checks for updates. Separate
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
bash scripts/publish-update-channel.sh \
|
||||
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
@@ -325,8 +386,10 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# `npm ci` here too, so all three platforms install identically and
|
||||
# none of them can re-resolve the tree mid-release. Windows already
|
||||
# did. See the Linux job for what a fresh resolution cost us.
|
||||
npm ci
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
|
||||
@@ -28,6 +28,27 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# Put BuildKit in the host's network namespace so it can reach
|
||||
# act_runner's cache service.
|
||||
#
|
||||
# The `docker-container` driver — which the multi-arch build below
|
||||
# requires, since the plain `docker` driver cannot do
|
||||
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
|
||||
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
|
||||
# an address the *job* container can reach, and nothing teaches the
|
||||
# BuildKit container about it: the job could reach
|
||||
# 192.168.1.126:40649 while the container actually making the request
|
||||
# could not, and the build died with `no route to host`.
|
||||
#
|
||||
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
|
||||
# missing route (a wrong address times out instead) — which is what a
|
||||
# default firewalld zone does to traffic arriving from the docker
|
||||
# bridge. Sharing the host's namespace sidesteps the question
|
||||
# entirely: the cache address becomes local to BuildKit.
|
||||
#
|
||||
# No effect on runners where this already worked.
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -55,5 +76,21 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
# `ignore-error` is what stops a cache failure failing a build that
|
||||
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||
# service on the runner host's LAN address, and the `docker-container`
|
||||
# builder `setup-buildx-action` creates could not route to it —
|
||||
# every layer of both arches built, then the job died on
|
||||
# `GetCacheEntryDownloadURL: no route to host` while exporting.
|
||||
#
|
||||
# On a pull_request `push:` above is false, so this job pushes
|
||||
# nothing and the cache is its only output: failing it discarded a
|
||||
# complete, successful validation of the Dockerfile for both
|
||||
# architectures. A cache is an optimisation and must degrade to
|
||||
# "slow", never to "red".
|
||||
#
|
||||
# The import is already non-fatal — the build ran all 37 layers after
|
||||
# warning that it could not read the cache — so only the exporter
|
||||
# needs the flag.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
|
||||
+21
-5
@@ -71,13 +71,29 @@ npm ci
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
npx tauri build --bundles appimage
|
||||
```
|
||||
|
||||
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
|
||||
publish for an audience the AppImage already serves, and neither could
|
||||
self-update. A bare `npx tauri build` still emits them, because
|
||||
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
|
||||
untouched; they are not released and not tested.
|
||||
|
||||
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||
|
||||
| Format | Path |
|
||||
|------------|-------------------------------|
|
||||
| AppImage | `appimage/*.AppImage` |
|
||||
| Debian pkg | `deb/*.deb` |
|
||||
| RPM pkg | `rpm/*.rpm` |
|
||||
| Format | Path | Released |
|
||||
|------------|-------------------------------|----------|
|
||||
| AppImage | `appimage/*.AppImage` | yes |
|
||||
| Debian pkg | `deb/*.deb` | no |
|
||||
| RPM pkg | `rpm/*.rpm` | no |
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
|
||||
section of `CLAUDE.md` for why both of its steps are load-bearing.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
|
||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||
so a project already at `true` gets no recreation at all on upgrade.
|
||||
|
||||
### Keeping Claude Code current
|
||||
|
||||
`claude update` runs in **two** places, and both are needed:
|
||||
|
||||
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||
command every Claude session launches with, because containers use a stop/start model and a
|
||||
long-lived one would otherwise never re-check.
|
||||
|
||||
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||
deliberately untouched.
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||
@@ -436,6 +456,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
||||
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
|
||||
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
|
||||
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
|
||||
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
|
||||
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
|
||||
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
|
||||
for the same reason a running container already outranked it. Assuming a snapshot existed is what
|
||||
made a stopped, never-recreated project report "no container or snapshot image yet" with its
|
||||
container sitting right there, and left Update disabled on the projects furthest behind.
|
||||
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
|
||||
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
|
||||
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
|
||||
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
|
||||
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
|
||||
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
|
||||
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
|
||||
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
|
||||
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
|
||||
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
|
||||
layer acceptable. Two rules it earned the hard way:
|
||||
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
|
||||
daemon-wide, so a second copy of the app has live probe images matching the glob.
|
||||
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
|
||||
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
|
||||
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
|
||||
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
|
||||
two concurrent probes fought over one tag — whichever finished first force-removed the image the
|
||||
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
|
||||
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
|
||||
uniqueness is what makes overlapping probes safe.
|
||||
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
|
||||
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
|
||||
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
|
||||
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
|
||||
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
|
||||
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
|
||||
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
|
||||
failed to invalidate would plan a migration against a filesystem the project no longer has.
|
||||
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
|
||||
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
|
||||
which leaves the migrate action in the project menu enabled — that action is not gated on the
|
||||
banner — so the pre-flight would report nothing to copy while the backend was told to copy
|
||||
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
|
||||
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
|
||||
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
|
||||
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
|
||||
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
|
||||
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
|
||||
- **`get_container_staleness` never commits while the project is claimed.** It takes no
|
||||
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
|
||||
reads `project_lock::held` instead and probes the snapshot rather than the container. The
|
||||
collision is not symmetric: the probe losing is a retryable `probe_error`, but
|
||||
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
|
||||
commit would fail the user's Start with an opaque error.
|
||||
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
|
||||
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
|
||||
stamps *now* — which is what real probe images do, and what any fixture for them must do.
|
||||
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||
@@ -679,8 +756,26 @@ deliberately out of scope — this is not a project backup.
|
||||
|
||||
## Packaging
|
||||
|
||||
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
|
||||
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
|
||||
Windows are untouched.
|
||||
|
||||
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
|
||||
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
|
||||
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
|
||||
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
|
||||
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
|
||||
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
|
||||
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
|
||||
metadata and update information**, without which an AppImage manager can adopt the app but never
|
||||
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
|
||||
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
|
||||
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
|
||||
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
|
||||
|
||||
**There is deliberately no Arch package.** A
|
||||
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||
|
||||
+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. |
|
||||
| **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` |
|
||||
| **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. |
|
||||
| **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one.
|
||||
|
||||
> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions.
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
@@ -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
|
||||
open you get a welcome screen with Docker/image/project readiness checks.
|
||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
||||
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
|
||||
speech-to-text is enabled.
|
||||
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
|
||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ choice it never asked about.
|
||||
|
||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||
file drag-drop, STT), the web terminal, and workspace backup.
|
||||
|
||||
---
|
||||
|
||||
+6
-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`):
|
||||
|
||||
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
|
||||
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
|
||||
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
|
||||
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
|
||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
|
||||
|
||||
### bollard (Docker API)
|
||||
|
||||
|
||||
Generated
+1
@@ -5306,6 +5306,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -39,6 +39,10 @@ local-ip-address = "0.6"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
zeroize = "1"
|
||||
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
|
||||
# container. Already in the tree transitively (reqwest), and the point of
|
||||
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,6 +15,12 @@ use crate::AppState;
|
||||
/// non-`Running` status carrying an explanation rather than an error, so the
|
||||
/// pane always has something specific to say. This is host-side only — no
|
||||
/// container recreation is involved either way.
|
||||
///
|
||||
/// Either way the choice is persisted, so it survives an app restart. This is
|
||||
/// the only caller allowed to write `false`: every other path to
|
||||
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
|
||||
/// is a teardown rather than the user changing their mind. Enabling persists
|
||||
/// inside `start`, which is the single funnel for it.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_enabled(
|
||||
project_id: String,
|
||||
@@ -23,9 +29,15 @@ pub async fn set_browser_view_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
if !enabled {
|
||||
// Persist first, then tear down: the supervisor's own teardown emit
|
||||
// reads this flag back out of the store, and reading it mid-stop would
|
||||
// announce a view that is going away as still enabled.
|
||||
state
|
||||
.projects_store
|
||||
.set_browser_view_enabled(&project_id, false)?;
|
||||
// Awaits the supervisor, so the host port is released before we return.
|
||||
manager().stop(&project_id).await;
|
||||
return Ok(manager().status(&project_id).await);
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
@@ -40,10 +52,17 @@ pub async fn set_browser_view_enabled(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Current status. Cheap: reads in-process state only, never the container.
|
||||
/// Current status. Cheap: the session map in this process plus the stored flag,
|
||||
/// never the container.
|
||||
///
|
||||
/// The two are independent on purpose — this is what the pane reads on mount,
|
||||
/// and after an app restart the honest answer is "enabled, nothing running".
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id).await)
|
||||
pub async fn get_browser_view_status(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
|
||||
}
|
||||
|
||||
/// Probe the container for Playwright without starting anything.
|
||||
@@ -110,7 +129,9 @@ pub async fn open_browser_view_popout(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||
return Err(
|
||||
"The browser view isn't running. Start it before opening it in its own window."
|
||||
@@ -209,7 +230,9 @@ pub async fn open_page_in_container_browser(
|
||||
// the user to go and press Start in the Browser tab themselves — and from
|
||||
// the terminal's URL prompt, with no indication that was even needed.
|
||||
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if status.state != BrowserViewState::Running {
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
@@ -229,7 +252,9 @@ pub async fn open_page_in_container_browser(
|
||||
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||
// window of its own or it lands somewhere the user isn't looking.
|
||||
if show_window {
|
||||
let status = manager().status(&project_id).await;
|
||||
let status = manager()
|
||||
.status(&project_id, enabled_for(&state, &project_id))
|
||||
.await;
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
let name = state
|
||||
.projects_store
|
||||
@@ -311,6 +336,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
|
||||
Ok(popout::match_window(&project_id))
|
||||
}
|
||||
|
||||
/// The project's stored browser-view opt-in.
|
||||
///
|
||||
/// The manager holds no copy of this — see
|
||||
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
|
||||
/// status call reads it here, the way `get_auth_bridge_status` does. A project
|
||||
/// that has gone away reads as off, which is the only answer that can be given
|
||||
/// about a record that no longer exists.
|
||||
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
|
||||
state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled)
|
||||
}
|
||||
|
||||
/// The project's container, or a sentence saying why there isn't one.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
|
||||
@@ -34,14 +34,22 @@
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
|
||||
//! Off by default and per-project opt-in. The opt-in itself is
|
||||
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
|
||||
//! `auth_bridge_enabled` and read from the store on demand rather than cached
|
||||
//! here — so the pane comes back the way it was left. What does *not* persist
|
||||
//! is the session: nothing starts a viewer on app start, so a project left
|
||||
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
|
||||
//! for one. That is deliberate, and the reason the flag and the session are
|
||||
//! separate ideas — see [`BrowserViewManager::status`].
|
||||
//!
|
||||
//! One supervisor task per session owns the proxy and the viewer process, and it
|
||||
//! is the only thing that tears them down, so every way a session can end funnels
|
||||
//! through one code path:
|
||||
//!
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
@@ -59,7 +67,10 @@
|
||||
//! orphan is reachable on container loopback only: the host-side port dies with
|
||||
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
|
||||
//! precisely so the bridge will not mirror an orphan the next time the app
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it.
|
||||
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
|
||||
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
|
||||
//! which is exactly the state that invites the user to press the button that
|
||||
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
|
||||
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BrowserViewStatus {
|
||||
/// The per-project opt-in. Off by default.
|
||||
/// The per-project opt-in, read from the persisted project record. Off by
|
||||
/// default, and true without a `Running` state whenever the view is turned
|
||||
/// on but has nothing up — a stopped container, or an app that has just
|
||||
/// restarted and does not auto-start viewers.
|
||||
pub enabled: bool,
|
||||
pub state: BrowserViewState,
|
||||
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
|
||||
@@ -201,17 +215,20 @@ struct Session {
|
||||
|
||||
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
|
||||
|
||||
/// Live sessions, and nothing else.
|
||||
///
|
||||
/// The per-project opt-in deliberately is **not** a field here. It lives on
|
||||
/// the project record as
|
||||
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
|
||||
/// is read from [`ProjectsStore`] at each use, exactly as
|
||||
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
|
||||
/// one copy, durable across a restart, and impossible to get out of step with
|
||||
/// what the Config tab shows. A cached copy here was the previous design and
|
||||
/// its only observable behaviour was forgetting the user's choice on every
|
||||
/// app start.
|
||||
#[derive(Default)]
|
||||
pub struct BrowserViewManager {
|
||||
sessions: SessionMap,
|
||||
/// The per-project opt-in.
|
||||
///
|
||||
/// NOTE: in memory only, so it does not survive an app restart. The durable
|
||||
/// home for this is a `browser_view_enabled: bool` field on
|
||||
/// `models::Project` (see the report) — `models/project.rs` is out of scope
|
||||
/// for this change, so the flag lives here and the wiring is otherwise
|
||||
/// identical to `auth_bridge_enabled`.
|
||||
enabled: Mutex<std::collections::HashSet<String>>,
|
||||
next_epoch: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
|
||||
}
|
||||
|
||||
impl BrowserViewManager {
|
||||
pub async fn is_enabled(&self, project_id: &str) -> bool {
|
||||
self.enabled.lock().await.contains(project_id)
|
||||
}
|
||||
|
||||
async fn set_enabled(&self, project_id: &str, enabled: bool) {
|
||||
let mut set = self.enabled.lock().await;
|
||||
if enabled {
|
||||
set.insert(project_id.to_string());
|
||||
} else {
|
||||
set.remove(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status without touching the container.
|
||||
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
|
||||
let enabled = self.is_enabled(project_id).await;
|
||||
///
|
||||
/// `enabled` is passed in rather than looked up, the way
|
||||
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
|
||||
/// the caller's to read from the store, and keeping it out of here is what
|
||||
/// stops a second copy of it appearing. A project whose view is enabled but
|
||||
/// whose container is stopped — or whose app has just restarted — reports
|
||||
/// `enabled: true` with a state of `Off`, which is the honest answer.
|
||||
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
|
||||
match self.sessions.lock().await.get(project_id) {
|
||||
Some(session) => BrowserViewStatus {
|
||||
enabled,
|
||||
@@ -261,6 +271,14 @@ impl BrowserViewManager {
|
||||
///
|
||||
/// Idempotent: a call while a live session exists returns that session's
|
||||
/// status untouched, so re-opening the tab does not restart the dashboard.
|
||||
///
|
||||
/// This is the single funnel for turning the view **on**, so it is also
|
||||
/// where the durable flag is written — both call sites (the toggle and
|
||||
/// `open_page_in_container_browser`, which opens a page and then shows it)
|
||||
/// mean "on", and neither can forget. The **off** direction is not
|
||||
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
|
||||
/// that are not the user changing their mind, so the command owns that
|
||||
/// write. See [`Self::stop`].
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
@@ -268,7 +286,7 @@ impl BrowserViewManager {
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
self.set_enabled(&project_id, true).await;
|
||||
store.set_browser_view_enabled(&project_id, true)?;
|
||||
|
||||
// Bind the answer before acting on it: `status()` takes the same lock,
|
||||
// and this mutex is not reentrant.
|
||||
@@ -279,7 +297,7 @@ impl BrowserViewManager {
|
||||
.get(&project_id)
|
||||
.is_some_and(|s| !s.supervisor.is_finished());
|
||||
if already_live {
|
||||
return Ok(self.status(&project_id).await);
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
@@ -364,14 +382,21 @@ impl BrowserViewManager {
|
||||
},
|
||||
);
|
||||
|
||||
let status = self.status(&project_id).await;
|
||||
let status = self.status(&project_id, true).await;
|
||||
emit(&app, &project_id, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Stop one project's view and wait until its host port has been released.
|
||||
///
|
||||
/// Tears the *session* down and deliberately leaves the durable flag alone.
|
||||
/// Most callers are not the user turning the feature off — a migration
|
||||
/// removes the container out from under a running view
|
||||
/// (`migration_commands`), and the container can stop for any other reason
|
||||
/// — and persisting `false` for those would quietly opt the project out of
|
||||
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
|
||||
/// the one caller that means it, and it writes the flag itself first.
|
||||
pub async fn stop(&self, project_id: &str) {
|
||||
self.set_enabled(project_id, false).await;
|
||||
// Remove under the lock, then release it before awaiting: the
|
||||
// supervisor takes the same lock to deregister itself on exit.
|
||||
let session = self.sessions.lock().await.remove(project_id);
|
||||
@@ -483,7 +508,12 @@ async fn supervise(
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
let _ = popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
// Straight from the store, like the auth bridge's own teardown emit: the
|
||||
// session is over, but the project may well still be opted in — a stopped
|
||||
// container is not a changed mind, and the pane has to show the difference.
|
||||
let enabled = store
|
||||
.get(&project_id)
|
||||
.is_some_and(|p| p.browser_view_enabled);
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
@@ -915,6 +945,25 @@ mod tests {
|
||||
assert!(s.url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_opt_in_and_the_live_session_are_separate_answers() {
|
||||
let manager = BrowserViewManager::default();
|
||||
|
||||
// Exactly what the pane reads on mount after an app restart of a
|
||||
// project that was left enabled: the durable flag says on, and nothing
|
||||
// auto-starts, so the state is honestly `Off`. The old in-memory flag
|
||||
// could not express this — it came back `false` and the pane silently
|
||||
// showed the feature as never having been turned on.
|
||||
let status = manager.status("p1", true).await;
|
||||
assert!(status.enabled);
|
||||
assert_eq!(status.state, BrowserViewState::Off);
|
||||
assert!(status.url.is_none());
|
||||
|
||||
// The flag belongs to the caller, read from the store. The manager
|
||||
// keeps no copy, so it has nothing to contradict it with.
|
||||
assert!(!manager.status("p1", false).await.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
|
||||
let mut d = PlaywrightDetection::default();
|
||||
|
||||
@@ -92,8 +92,111 @@ fn pick_recorded_lineage(
|
||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||
/// be called on demand, not polled.
|
||||
/// Reported as `probe_error` when there is genuinely nothing to read: no
|
||||
/// container, stopped or otherwise, and no snapshot image.
|
||||
///
|
||||
/// It used to be reported for a *stopped* container too, which was simply
|
||||
/// untrue — the container was sitting right there — and it disabled Update on
|
||||
/// exactly the long-lived projects that had never been recreated and so had no
|
||||
/// snapshot to fall back on.
|
||||
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
|
||||
|
||||
/// Where [`get_container_staleness`] reads the project's *current* filesystem
|
||||
/// from, in descending order of how current the answer is.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ProbeSource {
|
||||
/// `docker exec` into the live container. The only source that includes
|
||||
/// everything installed since the last commit *in this session*.
|
||||
RunningContainer,
|
||||
/// Commit the stopped container's writable layer to a throwaway image and
|
||||
/// probe that. Exactly as current as the container, which is what makes it
|
||||
/// preferable to the snapshot — see below.
|
||||
StoppedContainer,
|
||||
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
|
||||
Snapshot,
|
||||
/// Nothing to read: no container, no snapshot.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Pick the probe source. `container_running` is `None` when the project has no
|
||||
/// container at all, `Some(false)` when it has a stopped one.
|
||||
///
|
||||
/// **A stopped container outranks the snapshot.** The snapshot image is not a
|
||||
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
|
||||
/// config-change recreate) or inside a migration, so a project that has never
|
||||
/// hit either has *no snapshot at all*, however long it has been in use, and
|
||||
/// one that has is stale by everything installed since. The container's
|
||||
/// writable layer is the truth in both cases. This is the same argument
|
||||
/// [`mig::manifest_from_container`] already makes for the running case; it does
|
||||
/// not stop applying when the container is stopped.
|
||||
///
|
||||
/// Getting this wrong is what made a stopped, never-recreated project report
|
||||
/// "no container or snapshot image yet" — with its container sitting right
|
||||
/// there — and left Update disabled on the projects that most needed it.
|
||||
fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) -> ProbeSource {
|
||||
match (container_running, snapshot_exists) {
|
||||
(Some(true), _) => ProbeSource::RunningContainer,
|
||||
(Some(false), _) => ProbeSource::StoppedContainer,
|
||||
(None, true) => ProbeSource::Snapshot,
|
||||
(None, false) => ProbeSource::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reported as `probe_error` when another operation owns the project and there
|
||||
/// is no snapshot image to read instead. Deliberately not a claim about the
|
||||
/// container: nothing is wrong with it, the answer is simply not safe to take
|
||||
/// right now. See [`stopped_probe_policy`].
|
||||
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
|
||||
|
||||
/// What to do about a stopped container, whose probe is the expensive one: it
|
||||
/// commits the writable layer before it can read anything.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum StoppedProbe {
|
||||
/// Commit and probe. The current answer, and the default.
|
||||
Commit,
|
||||
/// Probe the snapshot image instead. Less current — it lags the container by
|
||||
/// everything installed since the last commit — but it allocates nothing and
|
||||
/// touches nothing, which is what makes it the right answer while another
|
||||
/// operation owns the container.
|
||||
SnapshotInstead,
|
||||
/// Report rather than guess.
|
||||
Defer,
|
||||
}
|
||||
|
||||
/// Pick what to do about a stopped container.
|
||||
///
|
||||
/// **Never commits while the project is claimed.** `get_container_staleness`
|
||||
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
|
||||
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
|
||||
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
|
||||
/// *recreate* losing is not, because `start_project_container` removes the old
|
||||
/// container with a hard `?`, so a non-404 from a remove that raced this commit
|
||||
/// fails the whole Start with an opaque "Failed to remove container". Reading
|
||||
/// the claim costs nothing and takes that failure off the table.
|
||||
fn stopped_probe_policy(project_is_busy: bool, snapshot_exists: bool) -> StoppedProbe {
|
||||
match (project_is_busy, snapshot_exists) {
|
||||
(false, _) => StoppedProbe::Commit,
|
||||
(true, true) => StoppedProbe::SnapshotInstead,
|
||||
(true, false) => StoppedProbe::Defer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
|
||||
/// on demand, not polled.
|
||||
///
|
||||
/// **Not read-only, despite only reporting.** The stopped-container path commits
|
||||
/// a throwaway image and force-removes it, which makes this a writer of a
|
||||
/// `triple-c-probe-*` image and puts it in the class of thing
|
||||
/// [`crate::project_lock`] exists for — and it takes no claim. That is
|
||||
/// deliberate: this is what the migration banner calls to decide whether to
|
||||
/// offer an update, including while a migration is in flight, so refusing it
|
||||
/// under a claim would blank the banner exactly when it has the most to say.
|
||||
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
|
||||
/// migration can remove the container out from under the commit, and the result
|
||||
/// is a `probe_error` the user can retry, never a damaged container or a
|
||||
/// mislabelled image. Two overlapping probes cannot collide either, because
|
||||
/// probe image names are unique per call; see
|
||||
/// [`crate::docker::container::get_probe_image_name`].
|
||||
#[tauri::command]
|
||||
pub async fn get_container_staleness(
|
||||
project_id: String,
|
||||
@@ -145,16 +248,62 @@ pub async fn get_container_staleness(
|
||||
};
|
||||
|
||||
// ── Probes ───────────────────────────────────────────────────────────
|
||||
let running = match &container_id {
|
||||
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
|
||||
None => false,
|
||||
let container_running = match &container_id {
|
||||
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
|
||||
None => None,
|
||||
};
|
||||
let from_manifest = if running {
|
||||
mig::manifest_from_container(container_id.as_ref().unwrap()).await
|
||||
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
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())
|
||||
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
|
||||
let from_manifest = match (
|
||||
pick_probe_source(container_running, snapshot_exists),
|
||||
&container_id,
|
||||
) {
|
||||
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
|
||||
(ProbeSource::StoppedContainer, Some(id)) => {
|
||||
let busy = crate::project_lock::held(&project_id).is_some();
|
||||
match stopped_probe_policy(busy, snapshot_exists) {
|
||||
StoppedProbe::Commit => {
|
||||
match mig::manifest_from_stopped_container_cached(id).await {
|
||||
Ok(m) => Ok(m),
|
||||
// **Never let a failed commit cost an answer the
|
||||
// snapshot could have given.** Before stopped
|
||||
// containers were readable at all, a stopped project
|
||||
// fell straight through to its snapshot, so surfacing
|
||||
// this error where the snapshot exists would make the
|
||||
// banner *worse* than it was — and the ways this fails
|
||||
// are the ones where the fallback matters most: a full
|
||||
// disk (the commit has to allocate the whole writable
|
||||
// layer; the snapshot probe allocates nothing) and a
|
||||
// 409 from an operation that claimed the project after
|
||||
// the check above.
|
||||
Err(e) if snapshot_exists => {
|
||||
log::warn!(
|
||||
"Probing the stopped container for project {} failed ({}) — \
|
||||
falling back to its snapshot image, which may lag it",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
StoppedProbe::SnapshotInstead => {
|
||||
log::info!(
|
||||
"Project {} is claimed by another operation — probing its snapshot image \
|
||||
rather than committing the container",
|
||||
project_id
|
||||
);
|
||||
mig::manifest_from_image(&snapshot_image).await
|
||||
}
|
||||
StoppedProbe::Defer => Err(PROJECT_BUSY.to_string()),
|
||||
}
|
||||
}
|
||||
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
|
||||
// `container_running` is `Some` exactly when `container_id` is, so the
|
||||
// two arms above are the only ones those variants can reach. This arm
|
||||
// is `ProbeSource::Nothing` — and now *only* that: it used to also
|
||||
// swallow every stopped container, which is the bug.
|
||||
(_, _) => Err(NOTHING_TO_PROBE.to_string()),
|
||||
};
|
||||
|
||||
let (from_manifest, base_manifest) = match from_manifest {
|
||||
@@ -1964,6 +2113,59 @@ mod tests {
|
||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_probed_rather_than_reported_missing() {
|
||||
// The regression: a container that exists but is stopped, with no
|
||||
// snapshot ever taken, read as "nothing to compare against".
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), false),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
|
||||
// The snapshot lags the container by everything installed since the
|
||||
// last commit, in both states.
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(true), true),
|
||||
ProbeSource::RunningContainer
|
||||
);
|
||||
assert_eq!(
|
||||
pick_probe_source(Some(false), true),
|
||||
ProbeSource::StoppedContainer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
|
||||
assert_eq!(pick_probe_source(None, true), ProbeSource::Snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
|
||||
// The one case the "no container or snapshot image yet" message may
|
||||
// still describe.
|
||||
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
|
||||
assert_eq!(stopped_probe_policy(false, false), StoppedProbe::Commit);
|
||||
assert_eq!(stopped_probe_policy(false, true), StoppedProbe::Commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
|
||||
// The snapshot lags, but a stale answer beats failing someone's Start.
|
||||
assert_eq!(
|
||||
stopped_probe_policy(true, true),
|
||||
StoppedProbe::SnapshotInstead
|
||||
);
|
||||
// Nothing to fall back to: say so instead of committing anyway.
|
||||
assert_eq!(stopped_probe_policy(true, false), StoppedProbe::Defer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
|
||||
@@ -1100,6 +1100,15 @@ pub async fn update_project(
|
||||
|
||||
project.container_id = stored.container_id;
|
||||
project.status = stored.status;
|
||||
// `browser_view_enabled` is owned by `set_browser_view_enabled` and is
|
||||
// restored here rather than taken from the payload, exactly like
|
||||
// `container_id` and `status` above. The Config tab has no control for it
|
||||
// — the Browser tab's toggle is the only way it ever changes — so the
|
||||
// project object the frontend round-trips carries whatever it was told at
|
||||
// load time and would silently undo a toggle made since. `auth_bridge_enabled`
|
||||
// is different and does arrive through this save: the Config tab edits it,
|
||||
// which is why the reconcile below follows whatever was just persisted.
|
||||
project.browser_view_enabled = stored.browser_view_enabled;
|
||||
project.created_at = stored.created_at;
|
||||
project.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
///
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||
/// WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
let settings = state.settings_store.get();
|
||||
build_claude_terminal_cmd(
|
||||
project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
session_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell line run immediately before `exec claude` in every Claude terminal
|
||||
/// session.
|
||||
///
|
||||
/// `container/entrypoint.sh` already runs `claude update` when the container
|
||||
/// starts, but containers here use a stop/start (and often just keep running)
|
||||
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
||||
/// it per session is what keeps a week-old container current.
|
||||
///
|
||||
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
||||
/// network, npm registry down) so a session always opens, and `timeout 60`
|
||||
/// bounds how long a user waits for a terminal.
|
||||
///
|
||||
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
||||
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
||||
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
||||
/// races two updaters against the same `~/.claude/bin` install, as does
|
||||
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
||||
/// behind a friendly message and the very next line (`exec claude`) would run
|
||||
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
||||
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
||||
/// other holder just updated, so there is nothing left to do.
|
||||
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
||||
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
||||
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
||||
);
|
||||
|
||||
/// Single-quote one argument for interpolation into a shell script string.
|
||||
fn shell_quote_arg(arg: &str) -> String {
|
||||
format!(" '{}'", arg.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||
/// profile rather than the whole [`AppState`].
|
||||
fn build_claude_terminal_cmd(
|
||||
project: &Project,
|
||||
global_aws_profile: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default();
|
||||
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
if let Some(name) = session_name {
|
||||
if !name.is_empty() {
|
||||
cmd.push("-n".to_string());
|
||||
cmd.push(name.to_string());
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||
.unwrap_or_default();
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one (same escaping style as name_flag above).
|
||||
let permission_flags: String = permission_args
|
||||
.iter()
|
||||
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
@@ -75,9 +114,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||
use crate::models::Project;
|
||||
|
||||
/// A dropped file must be named the way the *user* named it.
|
||||
///
|
||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||
@@ -338,6 +382,122 @@ mod tests {
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
/// A `Project` with only the fields these tests care about set; the rest
|
||||
/// come through serde so the test does not have to track every field.
|
||||
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "Test",
|
||||
"paths": [],
|
||||
"container_id": null,
|
||||
"status": "running",
|
||||
"backend": backend,
|
||||
"bedrock_config": bedrock_config,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"full_permissions": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"created_at": "now",
|
||||
"updated_at": "now"
|
||||
}))
|
||||
.expect("test project deserializes")
|
||||
}
|
||||
|
||||
/// Every Claude session updates the CLI before launching it.
|
||||
///
|
||||
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||
/// containers are long-lived, so a stale CLI is the normal case without
|
||||
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||
/// wrapper rather than a bare `claude` argv.
|
||||
#[test]
|
||||
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
assert_eq!(cmd[1], "-c");
|
||||
assert!(
|
||||
cmd[2].contains(UPDATE_PRELUDE),
|
||||
"plain path must run the update prelude: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||
// The update has to happen *before* the exec, which never returns.
|
||||
assert!(
|
||||
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(
|
||||
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||
"the update must stay time-bounded and non-fatal"
|
||||
);
|
||||
}
|
||||
|
||||
/// The session name is interpolated into a shell script, so a quote in it
|
||||
/// must not break out of its single-quoted argument.
|
||||
#[test]
|
||||
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("anthropic", serde_json::Value::Null),
|
||||
None,
|
||||
Some("Bob's tab; rm -rf /"),
|
||||
);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||
"session name must be single-quote escaped: {}",
|
||||
cmd[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Permission flags travel the same escaped path, and an empty name adds
|
||||
/// no `-n` at all.
|
||||
#[test]
|
||||
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||
"got: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||
}
|
||||
|
||||
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
||||
/// prelude, immediately before the exec.
|
||||
#[test]
|
||||
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&project("bedrock", serde_json::json!({
|
||||
"auth_method": "profile",
|
||||
"aws_region": "us-east-1",
|
||||
"aws_profile": "acme",
|
||||
"model_id": null,
|
||||
"disable_prompt_caching": false
|
||||
})),
|
||||
None,
|
||||
Some("it's fine"),
|
||||
);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
let script = &cmd[2];
|
||||
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
||||
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
||||
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
||||
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
||||
assert!(
|
||||
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
||||
"prelude must precede the exec: {}",
|
||||
script
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
|
||||
/// container makes. The reaper's only handle on a leftover — see
|
||||
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
|
||||
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
|
||||
|
||||
/// The throwaway image a staleness probe of a **stopped** container commits to.
|
||||
///
|
||||
/// **Unique per call**, and both halves of the name earn their place: the
|
||||
/// container id prefix makes a leftover traceable in `docker images`, and the
|
||||
/// counter makes two overlapping probes independent.
|
||||
///
|
||||
/// An earlier version of this was deliberately *stable* per container, on the
|
||||
/// theory that the next probe would move the tag off an abandoned image and
|
||||
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
|
||||
/// over. A container id does not survive a recreate, so for most leftovers
|
||||
/// there is no "next probe of the same container" and the image was stranded
|
||||
/// permanently; and a stable name made two concurrent probes fight over one
|
||||
/// tag, where whichever finished first force-removed the image the other was
|
||||
/// still reading and turned a healthy project into a bogus `probe_error`.
|
||||
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
|
||||
/// is what collects the leftovers instead.
|
||||
pub fn get_probe_image_name(container_id: &str) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let short: String = container_id.chars().take(12).collect();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{}{}-{}-{}:latest",
|
||||
PROBE_IMAGE_PREFIX,
|
||||
short,
|
||||
nanos,
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Commit a **stopped** container's filesystem to a throwaway image, returning
|
||||
/// its name. The caller owns the image and must remove it.
|
||||
///
|
||||
/// This exists so a stopped project can be read at all. `docker exec` needs a
|
||||
/// running container and the snapshot image is not a checkpoint — see
|
||||
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
|
||||
/// this there is no way to see inside a project that is merely stopped.
|
||||
///
|
||||
/// ## Why it is tagged at all
|
||||
///
|
||||
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
|
||||
/// that `docker commit` copies off the container is exactly the pair
|
||||
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
|
||||
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
|
||||
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
|
||||
/// `commit_container` hands back `id: None` every time and there is no
|
||||
/// reference left to probe. Neither existing commit site notices, because both
|
||||
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
|
||||
///
|
||||
/// So the image needs a name, a tagged image is not dangling, and the sweep
|
||||
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
|
||||
/// is, and [`get_probe_image_name`] carries the rest of that argument.
|
||||
///
|
||||
/// ## What is in the image, and what is not
|
||||
///
|
||||
/// `pause: false` because nothing is running — pausing a stopped container is
|
||||
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
|
||||
/// passes `false`.
|
||||
///
|
||||
/// Secrets are blanked from the env for the same reason
|
||||
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
|
||||
/// full ENV into the image, and "it only lives a few seconds" is not a property
|
||||
/// this function can promise after a crash.
|
||||
///
|
||||
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
|
||||
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
|
||||
/// because a commit stacks a layer and never rewrites one — but that scrub is a
|
||||
/// `docker exec`, which is exactly what a stopped container cannot serve, and
|
||||
/// scrubbing is not wanted anyway: the probe's whole job is to report the
|
||||
/// filesystem as it actually is. What makes it acceptable is that this copies
|
||||
/// bytes that are *already on this disk* in the container's own writable layer,
|
||||
/// into an image that is never pushed, never created from, and reaped — so it
|
||||
/// duplicates data inside one trust domain rather than widening it. That
|
||||
/// argument depends on the reaping actually happening; treat
|
||||
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
|
||||
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_probe_image_name(container_id);
|
||||
let (repo, tag) = image_name
|
||||
.rsplit_once(':')
|
||||
.map(|(r, t)| (r.to_string(), t.to_string()))
|
||||
.expect("get_probe_image_name always emits a tag");
|
||||
|
||||
docker
|
||||
.commit_container(
|
||||
CommitContainerOptions {
|
||||
container: container_id.to_string(),
|
||||
repo,
|
||||
tag,
|
||||
pause: false,
|
||||
..Default::default()
|
||||
},
|
||||
Config::<String> {
|
||||
env: Some(blanked_secret_env()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
|
||||
|
||||
Ok(image_name)
|
||||
}
|
||||
|
||||
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
|
||||
/// name in [`SECRET_ENV_KEYS`].
|
||||
fn env_holds_a_secret(env: &[String]) -> bool {
|
||||
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||
}
|
||||
|
||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
||||
/// only has the image name (the project record is already gone by then).
|
||||
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
|
||||
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
|
||||
/// (which only has the image name — the project record is already gone by
|
||||
/// then), and the staleness probe's throwaway commit, which has only an id.
|
||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
log::info!("Removed image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
@@ -4464,6 +4577,29 @@ mod tests {
|
||||
assert!(env_holds_a_secret(&env));
|
||||
}
|
||||
|
||||
/// The probe image's name must be **unique per call**. A stable name was
|
||||
/// tried and is wrong twice over: a container id does not survive a
|
||||
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
|
||||
/// next probe of the same container"; and two concurrent probes sharing one
|
||||
/// tag means whichever finishes first force-removes the image the other is
|
||||
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
|
||||
#[test]
|
||||
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
|
||||
let id = "75993e6d5e1ab473b029a408c5ff0339";
|
||||
let a = get_probe_image_name(id);
|
||||
let b = get_probe_image_name(id);
|
||||
assert_ne!(a, b, "two probes of one container must not share a tag");
|
||||
|
||||
// The prefix is the reaper's only handle on a leftover, so every name
|
||||
// has to carry it — and it must not be the snapshot namespace, which is
|
||||
// what a project is rebuilt from.
|
||||
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
|
||||
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
|
||||
// Traceable back to its container, which is the point of the prefix.
|
||||
assert!(a.contains("75993e6d5e1a"), "{}", a);
|
||||
assert!(a.ends_with(":latest"), "{}", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
|
||||
let clean = SnapshotScrubReport {
|
||||
|
||||
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove throwaway images left behind by a staleness probe of a stopped
|
||||
/// container — [`super::container::commit_container_for_probe`]'s commits.
|
||||
///
|
||||
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
|
||||
/// gives no image id back from a commit and there has to be something to probe.
|
||||
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
|
||||
/// — which collects every other kind of orphan this app can leave — will never
|
||||
/// see one. Without this, a probe that dies between its commit and its own
|
||||
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
|
||||
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
|
||||
/// UI to find it either. That is the one leak in this app with no floor on it,
|
||||
/// so this runs at startup beside [`reap_probe_containers`].
|
||||
///
|
||||
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
|
||||
/// filter, so a second copy of the app probing a project on the same daemon has
|
||||
/// images matching this glob, and removing one mid-capture fails that probe with
|
||||
/// "No such image" — the bogus `probe_error` the staleness work exists to get
|
||||
/// rid of. In-process state cannot see the other instance, so age is the only
|
||||
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
|
||||
/// `find` over a root filesystem, not a multi-minute job.
|
||||
///
|
||||
/// Never fails the caller. Housekeeping, like every other sweep here.
|
||||
pub async fn reap_probe_images() {
|
||||
use bollard::image::{ListImagesOptions, RemoveImageOptions};
|
||||
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::warn!("Could not reap leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filters = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
|
||||
)]);
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe images: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for image in images {
|
||||
// Unlike a container summary, an image summary always carries a
|
||||
// `Created`, so there is no unknown-age case to defend against here.
|
||||
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
|
||||
log::info!(
|
||||
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
|
||||
to another Triple-C instance's live probe",
|
||||
image.repo_tags,
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// By **tag**, never by image id. A `force` removal by id untags an
|
||||
// image everywhere, so an id that happens to carry another name loses
|
||||
// that name too — which is how a test fixture that tagged
|
||||
// `alpine:latest` into this namespace deleted the user's alpine. A real
|
||||
// leftover has exactly the one probe tag, so removing the tag removes
|
||||
// the image; anything else keeps whatever other names it has.
|
||||
for tag in image
|
||||
.repo_tags
|
||||
.iter()
|
||||
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
|
||||
{
|
||||
log::info!("Removing leftover probe image {}", tag);
|
||||
if let Err(e) = docker
|
||||
.remove_image(
|
||||
tag,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
|
||||
Ok(parse_manifest(&out))
|
||||
}
|
||||
|
||||
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||
/// the container's `FinishedAt` at the time it was captured.
|
||||
///
|
||||
/// **Sound because a stopped container's writable layer cannot change.** Nothing
|
||||
/// can write to it while it is not running, so a manifest captured after it
|
||||
/// stopped stays true until it is started again — and `FinishedAt` moves on
|
||||
/// every stop, which is what makes the key exact rather than merely plausible.
|
||||
///
|
||||
/// This exists because `get_container_staleness` is called from a `useEffect`
|
||||
/// that fires whenever the container settles, so simply opening a stopped
|
||||
/// project's Overview probes it. Uncached that meant a `docker commit` of the
|
||||
/// whole writable layer per visit — measured at 44 s on a real project — where
|
||||
/// before this feature the same visit cost one throwaway container or nothing at
|
||||
/// all. A regression like that is not worth the answer it buys.
|
||||
///
|
||||
/// Capped, because a `Manifest` of a real container is a few MB: this only has
|
||||
/// to serve "the project whose page is open", so a handful of entries is the
|
||||
/// whole working set and the oldest is dropped past that.
|
||||
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
|
||||
Option<Vec<(String, String, Manifest)>>,
|
||||
> = std::sync::Mutex::new(None);
|
||||
|
||||
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
|
||||
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
|
||||
|
||||
/// `FinishedAt` for a container, the cache's validity token. `None` when it
|
||||
/// cannot be read, which is never treated as a hit.
|
||||
async fn container_finished_at(container_id: &str) -> Option<String> {
|
||||
let docker = get_docker().ok()?;
|
||||
docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()?
|
||||
.state?
|
||||
.finished_at
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
|
||||
/// when the container has not been started since it was taken.
|
||||
///
|
||||
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
|
||||
pub async fn manifest_from_stopped_container_cached(
|
||||
container_id: &str,
|
||||
) -> Result<Manifest, String> {
|
||||
let finished_at = container_finished_at(container_id).await;
|
||||
|
||||
if let Some(token) = &finished_at {
|
||||
let guard = STOPPED_MANIFEST_CACHE.lock();
|
||||
if let Ok(cache) = guard {
|
||||
if let Some(entries) = cache.as_ref() {
|
||||
if let Some((_, _, manifest)) = entries
|
||||
.iter()
|
||||
.find(|(id, tok, _)| id == container_id && tok == token)
|
||||
{
|
||||
log::debug!(
|
||||
"Reusing the cached manifest for stopped container {}",
|
||||
container_id
|
||||
);
|
||||
return Ok(manifest.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = manifest_from_stopped_container(container_id).await?;
|
||||
|
||||
// Only cacheable if the container's state could be read at all; an unknown
|
||||
// `FinishedAt` means there is no token that could later be compared.
|
||||
if let Some(token) = finished_at {
|
||||
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
|
||||
let entries = cache.get_or_insert_with(Vec::new);
|
||||
entries.retain(|(id, _, _)| id != container_id);
|
||||
entries.push((container_id.to_string(), token, manifest.clone()));
|
||||
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
|
||||
entries.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a **stopped** container.
|
||||
///
|
||||
/// Commits the container's writable layer to a throwaway image, probes that,
|
||||
/// and removes it. This is as current as [`manifest_from_container`] — it reads
|
||||
/// the same filesystem — and it is why a stopped project no longer has to fall
|
||||
/// back to its snapshot image, which may not exist at all and lags the
|
||||
/// container by everything installed since the last commit when it does.
|
||||
///
|
||||
/// The image is removed on every path, including a failed probe. See
|
||||
/// [`super::container::commit_container_for_probe`] for what a crash in the
|
||||
/// window between the two costs, and why it is bounded.
|
||||
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
|
||||
let image = super::container::commit_container_for_probe(container_id).await?;
|
||||
|
||||
let manifest = manifest_from_image(&image)
|
||||
.await
|
||||
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
|
||||
|
||||
if let Err(e) = super::container::remove_image_by_name(&image).await {
|
||||
log::warn!(
|
||||
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
|
||||
collects it at the next app start; the orphan sweep never will, because it is tagged",
|
||||
image,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
manifest
|
||||
}
|
||||
|
||||
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
|
||||
///
|
||||
/// Deliberately the **ID**, not a repo digest: locally built images and custom
|
||||
@@ -2146,4 +2353,258 @@ mod tests {
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
|
||||
// ── Live Docker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// The cache serves a second read of an unchanged stopped container, and —
|
||||
/// the half that matters — stops serving it the moment the container is
|
||||
/// started and stopped again. If invalidation were wrong this would report a
|
||||
/// filesystem the project no longer has, and a migration would be planned
|
||||
/// against it.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_manifest_cache
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let first = format!("/opt/cache-marker-a-{}", std::process::id());
|
||||
let second = format!("/opt/cache-marker-b-{}", std::process::id());
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 600",
|
||||
]);
|
||||
let cleanup = || {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", &id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let cold = manifest_from_stopped_container_cached(&id).await;
|
||||
let cold_ms = t0.elapsed().as_millis();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let warm = manifest_from_stopped_container_cached(&id).await;
|
||||
let warm_ms = t1.elapsed().as_millis();
|
||||
|
||||
// Restart, change the filesystem, stop again — `FinishedAt` moves.
|
||||
docker_cli(&["start", &id]);
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
let after_restart = manifest_from_stopped_container_cached(&id).await;
|
||||
|
||||
cleanup();
|
||||
|
||||
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
|
||||
|
||||
let cold = cold.expect("cold read");
|
||||
let warm = warm.expect("warm read");
|
||||
let after_restart = after_restart.expect("read after restart");
|
||||
|
||||
assert!(has(&cold, &first), "cold read missed {}", first);
|
||||
assert!(has(&warm, &first), "warm read missed {}", first);
|
||||
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
|
||||
assert!(
|
||||
warm_ms * 5 < cold_ms.max(5),
|
||||
"the second read cost {} ms against a cold {} ms — it re-committed \
|
||||
instead of using the cache",
|
||||
warm_ms,
|
||||
cold_ms
|
||||
);
|
||||
|
||||
// The restart must have invalidated it: the new directory has to show up.
|
||||
assert!(
|
||||
has(&after_restart, &second),
|
||||
"a restart did not invalidate the cache — {} is missing, so this is \
|
||||
a stale manifest of a filesystem the container no longer has",
|
||||
second
|
||||
);
|
||||
assert!(has(&after_restart, &first), "the restart lost {}", first);
|
||||
}
|
||||
|
||||
/// The reaper finds a leftover probe image by prefix and — crucially —
|
||||
/// refuses to remove a young one, because that image may be another
|
||||
/// Triple-C instance's live probe. Only a real daemon can say whether the
|
||||
/// `reference=` glob matches the names `get_probe_image_name` produces.
|
||||
///
|
||||
/// The fixture is **committed**, not tagged and not built. An image's
|
||||
/// `Created` is its own, not its tag's, so tagging something already on disk
|
||||
/// into this namespace yields a fixture the reaper is right to call ancient
|
||||
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
|
||||
/// one looks ancient too. A commit stamps *now*, verified against Engine
|
||||
/// 29.6, which is also how real probe images get their age.
|
||||
///
|
||||
/// Both of those mistakes were made here first, and one of them deleted an
|
||||
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
|
||||
/// tag rather than by image id.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture reaper_spares
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
|
||||
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
|
||||
use std::process::Command;
|
||||
|
||||
fn docker_out(args: &[&str]) -> std::process::Output {
|
||||
Command::new("docker").args(args).output().expect("docker CLI")
|
||||
}
|
||||
|
||||
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "alpine:latest".to_string());
|
||||
let name = crate::docker::container::get_probe_image_name("reapertest01234");
|
||||
|
||||
// A never-started container is enough to commit from, and leaves the
|
||||
// daemon's run state alone entirely.
|
||||
let created = docker_out(&["create", &base, "true"]);
|
||||
assert!(
|
||||
created.status.success(),
|
||||
"could not create the fixture container from {}: {}",
|
||||
base,
|
||||
String::from_utf8_lossy(&created.stderr)
|
||||
);
|
||||
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
|
||||
|
||||
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
|
||||
let _ = docker_out(&["rm", "-f", &cid]);
|
||||
assert!(
|
||||
committed.status.success(),
|
||||
"could not commit the fixture image: {}",
|
||||
String::from_utf8_lossy(&committed.stderr)
|
||||
);
|
||||
|
||||
reap_probe_images().await;
|
||||
|
||||
let still_there = Command::new("docker")
|
||||
.args(["image", "inspect", &name])
|
||||
.output()
|
||||
.expect("docker image inspect")
|
||||
.status
|
||||
.success();
|
||||
|
||||
let _ = Command::new("docker").args(["rmi", &name]).output();
|
||||
|
||||
assert!(
|
||||
still_there,
|
||||
"a probe image committed seconds ago was reaped — that is another \
|
||||
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
|
||||
);
|
||||
}
|
||||
|
||||
/// A *stopped* container is readable, and what comes back is its writable
|
||||
/// layer rather than the image it was created from. This is the whole point
|
||||
/// of the function: the base image cannot answer it, and the project may
|
||||
/// well have no snapshot image at all.
|
||||
///
|
||||
/// Also asserts the throwaway commit leaves nothing behind, which no unit
|
||||
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
|
||||
/// the probe image is *tagged*, so a leak never shows up as a dangling
|
||||
/// image and a dangling-set assertion here would pass either way.
|
||||
///
|
||||
/// Ignored because it needs Docker and commits a container; run it with
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture stopped_container
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
|
||||
async fn a_stopped_container_is_read_from_its_writable_layer() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
fn probe_images() -> Vec<String> {
|
||||
let mut ids: Vec<String> = docker_cli(&[
|
||||
"images", "-q",
|
||||
"--filter",
|
||||
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
|
||||
])
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
|
||||
let marker = format!("/opt/probe-marker-{}", std::process::id());
|
||||
|
||||
// Another instance's live probe images are allowed to exist; what must
|
||||
// hold is that this probe adds none of its own.
|
||||
let before = probe_images();
|
||||
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 300",
|
||||
]);
|
||||
let cleanup = |id: &str| {
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["rm", "-f", id])
|
||||
.output();
|
||||
};
|
||||
|
||||
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
|
||||
docker_cli(&["stop", "-t", "1", &id]);
|
||||
|
||||
let result = manifest_from_stopped_container(&id).await;
|
||||
|
||||
cleanup(&id);
|
||||
|
||||
let manifest = result.expect("a stopped container must be probeable");
|
||||
assert!(
|
||||
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
|
||||
"the probe read the image, not the container's writable layer: {} missing",
|
||||
marker
|
||||
);
|
||||
// Non-empty package sets prove the probe script really ran, rather than
|
||||
// parsing an empty transcript into an empty-but-Ok manifest.
|
||||
assert!(
|
||||
!manifest.apt_manual.is_empty(),
|
||||
"apt-mark showmanual came back empty, so the probe did not run"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
probe_images(),
|
||||
before,
|
||||
"the throwaway probe image was not cleaned up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod logging;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod url_open;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -263,12 +264,20 @@ pub fn run() {
|
||||
// logged warning rather than a failed start.
|
||||
//
|
||||
// Ordering matters. Probes are removed first because a probe holds
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// an image open and the sweep will not force — both the probe
|
||||
// containers and the probe images, the latter being the one orphan
|
||||
// the sweep can never reach on its own; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
// Probe *images* too, and for a sharper reason: a probe
|
||||
// container merely pins an image the sweep then refuses to
|
||||
// touch, whereas a leftover probe image is tagged and so
|
||||
// nothing else in this app can ever collect it. See
|
||||
// `reap_probe_images`.
|
||||
crate::docker::reap_probe_images().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
@@ -544,6 +553,9 @@ pub fn run() {
|
||||
commands::update_commands::check_image_update,
|
||||
// Help
|
||||
commands::help_commands::get_help_content,
|
||||
// Opening a link in the host browser (see `url_open` for why this
|
||||
// is not `@tauri-apps/plugin-opener` on Linux)
|
||||
url_open::open_url_external,
|
||||
// Install helper
|
||||
commands::install_helper_commands::detect_install_options,
|
||||
commands::install_helper_commands::run_docker_install,
|
||||
@@ -926,7 +938,6 @@ mod tests {
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,10 +3,19 @@
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
/// under Wayland, killing the webview and leaving a blank window — see
|
||||
/// triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// **This is not the only cause of a blank window, and the error text alone
|
||||
/// does not tell them apart.** An earlier version of this comment quoted
|
||||
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
|
||||
/// the error this fixes. The AppImage produces that same string for an
|
||||
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
|
||||
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
|
||||
/// DT_NEEDED on that library, so the EGL driver failed to load before any
|
||||
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
|
||||
/// which cost a round of debugging that started from the comment rather than
|
||||
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
@@ -54,6 +63,12 @@
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
///
|
||||
/// That leak is now plugged rather than merely documented: `url_open` hands
|
||||
/// the opener a child environment with this variable (and the AppImage's own
|
||||
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
|
||||
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
|
||||
/// children no longer inherit it.
|
||||
#[cfg(target_os = "linux")]
|
||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
@@ -129,6 +144,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||
// environment this app was started with, and the workaround below is one
|
||||
// of the things that must not leak into it (see triple-c#34). Anything
|
||||
// added here that mutates the environment belongs after this line.
|
||||
triple_c_lib::url_open::capture_pristine_environment();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
|
||||
@@ -132,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `auth_bridge_enabled` defaults to **on**, and the default is what makes
|
||||
/// `claude login` work at all.
|
||||
///
|
||||
/// The login flow binds a *random* ephemeral loopback port inside the
|
||||
/// container and then sends the host's browser to `127.0.0.1:<that port>`.
|
||||
/// On the host nothing is listening there, so the callback lands on a closed
|
||||
/// port and the CLI waits for a redirect that can never arrive. The bridge
|
||||
/// mirrors the container's loopback listeners onto the same host port, which
|
||||
/// is the only thing that closes that loop — so off-by-default made a hang the
|
||||
/// out-of-the-box experience.
|
||||
///
|
||||
/// Returning `true` from a `#[serde(default)]` helper (rather than flipping the
|
||||
/// constructor alone) is deliberate: existing `projects.json` records were
|
||||
/// written before this field existed, or while it was off, and an absent key is
|
||||
/// what the default is read for. A project that wants the old behaviour turns
|
||||
/// the toggle off, which persists an explicit `false`.
|
||||
fn default_auth_bridge_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How much autonomy Claude Code is granted inside the container.
|
||||
///
|
||||
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||
@@ -336,17 +356,30 @@ pub struct Project {
|
||||
pub sandbox_mode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// The auth bridge: while the container runs, its loopback listeners are
|
||||
/// mirrored onto the host's loopback so browser OAuth callbacks
|
||||
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||
/// Purely host-side — it deliberately has no container-recreation label,
|
||||
/// because toggling it changes nothing about the container itself.
|
||||
#[serde(default)]
|
||||
///
|
||||
/// **On by default**, and opt-*out* rather than opt-in — see
|
||||
/// [`default_auth_bridge_enabled`] for why the default is the feature.
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
pub auth_bridge_enabled: bool,
|
||||
/// Opt in to the browser-view pane, which watches and takes over the
|
||||
/// browser Claude drives with Playwright inside the container. Purely
|
||||
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
||||
/// container-recreation label.
|
||||
///
|
||||
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
|
||||
/// rather than keeping its own copy, so the pane comes back the way it was
|
||||
/// left. Off by default, and unlike the auth bridge it stays that way — a
|
||||
/// view costs a container exec, a Node daemon and a host port, and a
|
||||
/// container without Playwright cannot serve one at all.
|
||||
///
|
||||
/// Durable does **not** mean auto-started: nothing brings a viewer up on
|
||||
/// app start, so a project left enabled reports `enabled` with a state of
|
||||
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
|
||||
#[serde(default)]
|
||||
pub browser_view_enabled: bool,
|
||||
/// Grant the container what a VPN client needs to build a tunnel:
|
||||
@@ -639,7 +672,7 @@ impl Project {
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: false,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
auth_bridge_enabled: default_auth_bridge_enabled(),
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: default_use_shared_auth_token(),
|
||||
@@ -885,4 +918,69 @@ mod tests {
|
||||
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(round_tripped, partial);
|
||||
}
|
||||
|
||||
// ── The host-side per-project toggles ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
|
||||
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
|
||||
// record written by a shipped binary and has no `auth_bridge_enabled`
|
||||
// key at all. Without this, every existing project keeps hanging on
|
||||
// `claude login` until its owner finds the toggle.
|
||||
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
assert!(project.auth_bridge_enabled);
|
||||
|
||||
// The browser view is the other way round and must stay so: it costs a
|
||||
// Node daemon, a container exec loop and a host port, and most
|
||||
// containers have no Playwright to serve it with.
|
||||
assert!(!project.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_auth_bridge_off_survives_the_default() {
|
||||
// Opt-out has to be expressible, or the toggle does nothing across a
|
||||
// restart. An explicit `false` in the file beats the default.
|
||||
let json = r#"{ "auth_bridge_enabled": false }"#;
|
||||
#[derive(Deserialize)]
|
||||
struct JustTheFlag {
|
||||
#[serde(default = "default_auth_bridge_enabled")]
|
||||
auth_bridge_enabled: bool,
|
||||
}
|
||||
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
|
||||
assert!(!parsed.auth_bridge_enabled);
|
||||
|
||||
// And a saved project always writes the key, so the choice is pinned
|
||||
// rather than re-defaulted on the next load.
|
||||
let mut p = Project::new("demo".to_string(), Vec::new());
|
||||
p.auth_bridge_enabled = false;
|
||||
let round_tripped: Project =
|
||||
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert!(!round_tripped.auth_bridge_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
assert!(p.auth_bridge_enabled);
|
||||
assert!(!p.browser_view_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
|
||||
// `ProjectsStore::new` runs every record through this before
|
||||
// deserialising. If it inserted either key — even as `false` — the
|
||||
// serde default above would never be consulted for an existing project
|
||||
// and this change would be a no-op on exactly the projects it is for.
|
||||
let legacy = serde_json::json!({
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"path": "/home/u/demo",
|
||||
});
|
||||
let migrated = Project::migrate_from_value(legacy);
|
||||
let obj = migrated.as_object().unwrap();
|
||||
assert!(obj.contains_key("paths"), "the migration should still do its own job");
|
||||
assert!(!obj.contains_key("auth_bridge_enabled"));
|
||||
assert!(!obj.contains_key("browser_view_enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,21 @@ impl ProjectsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Granular setter for the browser view's opt-in, for the same reason
|
||||
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
|
||||
/// the Config tab may be holding an older copy of the whole record.
|
||||
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
p.browser_view_enabled = enabled;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Project {} not found", project_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
@@ -338,4 +353,61 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
|
||||
/// is the real user's; the fields are right here, so the granular setters
|
||||
/// can be exercised against a directory the test owns.
|
||||
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
|
||||
ProjectsStore {
|
||||
projects: Mutex::new(projects),
|
||||
file_path: dir.join("projects.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
|
||||
// The point of the whole exercise: before this the flag lived in a
|
||||
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
|
||||
let dir = temp_dir("browser-view");
|
||||
let project = Project::new("demo".to_string(), Vec::new());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
assert!(store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
// Durable, not merely in memory — this is what a restart reads.
|
||||
let on_disk: Vec<Project> =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
|
||||
assert!(on_disk[0].browser_view_enabled);
|
||||
|
||||
store.set_browser_view_enabled(&id, false).unwrap();
|
||||
assert!(!store.get(&id).unwrap().browser_view_enabled);
|
||||
|
||||
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_granular_toggle_leaves_every_other_field_alone() {
|
||||
// Why these setters exist at all: the Config tab can be holding an
|
||||
// older copy of the whole record while the pane flips one flag.
|
||||
let dir = temp_dir("granular");
|
||||
let mut project = Project::new("demo".to_string(), Vec::new());
|
||||
project.claude_instructions = Some("keep me".to_string());
|
||||
let id = project.id.clone();
|
||||
let store = store_over(&dir, vec![project]);
|
||||
|
||||
store.set_browser_view_enabled(&id, true).unwrap();
|
||||
store.set_auth_bridge_enabled(&id, false).unwrap();
|
||||
|
||||
let saved = store.get(&id).unwrap();
|
||||
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
|
||||
assert!(saved.browser_view_enabled);
|
||||
assert!(!saved.auth_bridge_enabled);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
//! 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 reliable signal
|
||||
/// there is.
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// 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());
|
||||
|
||||
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}"));
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
|
||||
writer_handle.abort();
|
||||
}
|
||||
|
||||
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
|
||||
/// copied so the web terminal cannot drift from it — a duplicated `const` with
|
||||
/// a "keep these identical" comment is only as good as the next reader.
|
||||
use crate::commands::terminal_commands::UPDATE_PRELUDE;
|
||||
|
||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
|
||||
let permission_args = project.effective_permission_mode().cli_args();
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
cmd.extend(permission_args);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
// The args are interpolated into a shell script string below, so
|
||||
// single-quote each one.
|
||||
let permission_flags: String = permission_args
|
||||
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
||||
.collect();
|
||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||
];
|
||||
}
|
||||
|
||||
let profile = aws_commands::resolve_profile_for_project(
|
||||
project,
|
||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||
);
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
@@ -260,9 +267,11 @@ else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{update_prelude}
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
update_prelude = UPDATE_PRELUDE,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||
import { openUrlExternal } from "../lib/tauri-commands";
|
||||
import { useDocker } from "../hooks/useDocker";
|
||||
import Modal from "./ui/Modal";
|
||||
import Button from "./ui/Button";
|
||||
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
||||
const handleOpenDocs = async () => {
|
||||
if (!options) return;
|
||||
try {
|
||||
await openUrl(options.docs_url);
|
||||
await openUrlExternal(options.docs_url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open docs URL:", e);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function StatusBar({ stt }: Props) {
|
||||
const {
|
||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
||||
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
projects: s.projects,
|
||||
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
|
||||
terminalHasSelection: s.terminalHasSelection,
|
||||
activeSessionId: s.activeSessionId,
|
||||
sttEnabled: s.appSettings?.stt?.enabled,
|
||||
terminalAtBottom: s.terminalAtBottom,
|
||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
toggleNotesDock: s.toggleNotesDock,
|
||||
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||
releaseActiveMouse: s.releaseActiveMouse,
|
||||
}))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
{/* Right-aligned controls: mouse release + Notes + STT mic */}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{activeSessionId && !terminalAtBottom && (
|
||||
{activeSessionId && terminalMouseCaptured && (
|
||||
<button
|
||||
onClick={() => scrollActiveToBottom()}
|
||||
data-mouse-release="true"
|
||||
onClick={() => releaseActiveMouse()}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Scroll the terminal to the latest output"
|
||||
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
|
||||
>
|
||||
Jump to Current ↓
|
||||
🖱 Mouse captured — release
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
setBrowserViewMatchWindow,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import OpenPageDialog from "./OpenPageDialog";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
@@ -338,7 +339,7 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// Prefer the probe: it is the fresher of the two, and it is the one that
|
||||
// reflects an install that just finished.
|
||||
const probed = detection ?? status.detection;
|
||||
const ready = isUsable(probed);
|
||||
const ready = isBrowserViewUsable(probed);
|
||||
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
||||
// apt package, so it never shows up in `browsers`, and a container that has
|
||||
// it is not missing a browser.
|
||||
@@ -539,11 +540,6 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
|
||||
function isUsable(d: PlaywrightDetection | null): boolean {
|
||||
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||
*
|
||||
@@ -627,7 +623,7 @@ function Setup({
|
||||
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||
}) {
|
||||
const busy = job !== null;
|
||||
const havePackages = isUsable(detection);
|
||||
const havePackages = isBrowserViewUsable(detection);
|
||||
const missing = missingParts(detection);
|
||||
const browsers = detection?.browsers ?? [];
|
||||
const chrome = detection?.chrome_channel ?? null;
|
||||
|
||||
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
|
||||
hasClaudeToken: vi.fn(),
|
||||
clearClaudeToken: vi.fn(),
|
||||
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
||||
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
|
||||
}));
|
||||
|
||||
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
||||
|
||||
const openUrl = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: (...args: unknown[]) => openUrl(...args),
|
||||
}));
|
||||
const openUrlExternal = vi.fn();
|
||||
|
||||
/** Captured event handlers, keyed by event name, so tests can emit. */
|
||||
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
||||
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
|
||||
|
||||
const link = await screen.findByRole("link", { name: url });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
|
||||
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
|
||||
});
|
||||
|
||||
it("ignores output belonging to a different project", async () => {
|
||||
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
|
||||
|
||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
});
|
||||
|
||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||
@@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => {
|
||||
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a hyperlink belonging to a different project", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { cancelClaudeToken } from "../../lib/tauri-commands";
|
||||
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openUrl(target);
|
||||
await openUrlExternal(target);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
authErrorMessage(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "../../lib/types";
|
||||
import { openUrlExternal } from "../../lib/tauri-commands";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { formatBytes } from "../../lib/formatBytes";
|
||||
@@ -19,7 +19,7 @@ export default function UpdateDialog({
|
||||
}: Props) {
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await openUrl(url);
|
||||
await openUrlExternal(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open URL:", e);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import TerminalView, { supersedes } from "./TerminalView";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import {
|
||||
uploadHostFileToTerminal,
|
||||
openUrlExternal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import {
|
||||
chooseSignInTarget,
|
||||
resetBrowserSupportCache,
|
||||
} from "../../hooks/useSignInOpenTarget";
|
||||
import type { AuthBridgeStatus, PlaywrightDetection } from "../../lib/types";
|
||||
import { URL_TOAST_SELECTOR } from "./UrlToast";
|
||||
|
||||
/**
|
||||
* The window-wide native drag-drop listener, captured at registration.
|
||||
@@ -15,6 +24,18 @@ const dragDrop = vi.hoisted(() => ({
|
||||
handler: null as null | ((event: unknown) => unknown),
|
||||
}));
|
||||
|
||||
/**
|
||||
* What the project's container answers about itself.
|
||||
*
|
||||
* `TerminalView` asks two questions on mount — is the auth bridge live, and is
|
||||
* there a browser inside to open a page in — because together they decide which
|
||||
* of the URL toast's two buttons leads for a sign-in link.
|
||||
*/
|
||||
const containerEnv = vi.hoisted(() => ({
|
||||
bridge: { enabled: false, active_ports: [], conflicts: [] } as unknown,
|
||||
detection: null as unknown,
|
||||
}));
|
||||
|
||||
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
|
||||
const ptyOutput = vi.hoisted(() => ({
|
||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||
@@ -44,6 +65,9 @@ vi.mock("../../lib/tauri-commands", () => ({
|
||||
awsSsoRefresh: vi.fn(async () => {}),
|
||||
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
||||
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
||||
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
||||
openUrlExternal: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -53,10 +77,6 @@ vi.mock("@tauri-apps/api/event", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
||||
@@ -127,6 +147,14 @@ beforeEach(() => {
|
||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||
dragDrop.handler = null;
|
||||
ptyOutput.listeners.clear();
|
||||
vi.mocked(openUrlExternal).mockReset();
|
||||
vi.mocked(openUrlExternal).mockResolvedValue(undefined);
|
||||
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
|
||||
containerEnv.detection = null;
|
||||
// The Playwright probe is memoized across mounts (it is a container exec), so
|
||||
// a case that changes the answer has to drop what an earlier one cached.
|
||||
resetBrowserSupportCache();
|
||||
useAppState.setState({ toasts: [] });
|
||||
document.body.innerHTML = "";
|
||||
useAppState.setState({ sessions: [] });
|
||||
});
|
||||
@@ -370,15 +398,34 @@ describe("TerminalView — where a dropped file lands", () => {
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
||||
// The regression this file could not see. The toggle is `absolute top-2
|
||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
||||
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
|
||||
// The regression this file could not see. Chrome like the URL toast is a
|
||||
// *sibling* of the xterm host painted over the pane, so
|
||||
// `elementFromPoint` returns it rather than the host — and a gate asking
|
||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||
// with no message and no log line. jsdom never ran that branch.
|
||||
const view = await mountWithLayout();
|
||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
||||
stubElementFromPoint(toggle);
|
||||
//
|
||||
// The original fixture was the always-rendered "▼ Following" toggle. That
|
||||
// control is retired and the mouse-release button that could have replaced
|
||||
// it lives in the status bar now, so the toast is what stands in — it is
|
||||
// real chrome over the pane, which is the only property under test.
|
||||
await mountWithLayout();
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({
|
||||
payload: Array.from(
|
||||
new TextEncoder().encode(
|
||||
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
|
||||
),
|
||||
),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
const toast = document.querySelector(URL_TOAST_SELECTOR);
|
||||
if (!toast) throw new Error("URL toast not shown");
|
||||
stubElementFromPoint(toast);
|
||||
|
||||
await drop(780, 10);
|
||||
|
||||
@@ -539,6 +586,214 @@ 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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
expect(chooseSignInTarget(off, null)).toBe("host");
|
||||
// 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");
|
||||
// Playwright too old to bind: the pane cannot show it either.
|
||||
expect(chooseSignInTarget(off, usableDetection({ has_bind: false }))).toBe("host");
|
||||
});
|
||||
|
||||
it("answers host when nothing is known at all", () => {
|
||||
expect(chooseSignInTarget(null, null)).toBe("host");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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 — focus on request", () => {
|
||||
/** Mount, then deliberately give focus away, so what the assertions below
|
||||
* observe is the *request* taking effect and never the focus `active`
|
||||
@@ -594,3 +849,88 @@ describe("TerminalView — focus on request", () => {
|
||||
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 { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
@@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
||||
import {
|
||||
awsSsoRefresh,
|
||||
openPageInContainerBrowser,
|
||||
openUrlExternal,
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||
import UrlToast, {
|
||||
URL_TOAST_PRIMARY_SELECTOR,
|
||||
URL_TOAST_SELECTOR,
|
||||
@@ -99,8 +100,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
||||
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
||||
|
||||
const ssoBufferRef = useRef("");
|
||||
const ssoTriggeredRef = useRef(false);
|
||||
@@ -219,14 +220,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, []);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
// Tracks user intent to follow output — only set to false by explicit user
|
||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||||
const autoFollowRef = useRef(true);
|
||||
const lastUserScrollTimeRef = useRef(0);
|
||||
// True while the program in the container holds mouse reporting open (any of
|
||||
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
||||
const [mouseCaptured, setMouseCaptured] = useState(false);
|
||||
const mouseCapturedRef = useRef(false);
|
||||
|
||||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||
// and the unmount-cleanup effect further down).
|
||||
@@ -251,10 +249,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
//
|
||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
||||
// drop on its own account — asking "is this element mine?" once turned every
|
||||
// pixel under that chrome into a permanent dead zone.
|
||||
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
||||
// sibling of the host rather than a child. Nothing painted over the pane
|
||||
// refuses a drop on its own account — asking "is this element mine?" once
|
||||
// turned every pixel under that chrome into a permanent dead zone.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
@@ -315,12 +313,60 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
};
|
||||
}, [sessionId, sendInput]);
|
||||
|
||||
/**
|
||||
* Reconcile the badge with xterm's live mouse-tracking mode.
|
||||
*
|
||||
* There is no event for this, but there does not need to be a poll either:
|
||||
* the mode only ever changes because the container printed a DECSET/DECRST
|
||||
* sequence, so checking once per write covers every transition, exactly when
|
||||
* it happens. The ref gate keeps the common case (mode unchanged, thousands
|
||||
* of writes a second) down to one string comparison and no re-render.
|
||||
*/
|
||||
const syncMouseCapture = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const captured = term.modes.mouseTrackingMode !== "none";
|
||||
if (captured === mouseCapturedRef.current) return;
|
||||
mouseCapturedRef.current = captured;
|
||||
setMouseCaptured(captured);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Take the mouse back from a program that grabbed it and never let go.
|
||||
*
|
||||
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
|
||||
* tracking modes set. xterm goes on routing clicks, drags and — under
|
||||
* `?1003` — every pointer *move* to the PTY, which kills text selection and
|
||||
* floods the prompt with escape bytes. The result reads as a frozen
|
||||
* terminal, and until now the only exit was closing the tab.
|
||||
*
|
||||
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
|
||||
* xterm's own parser and never onto the wire. The program that asked for
|
||||
* tracking is usually already gone; if it is not, telling it the user pulled
|
||||
* the mouse back would only invite it to grab again on its next repaint.
|
||||
*/
|
||||
const releaseMouse = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
// The three tracking modes, then the two encodings they report in. All
|
||||
// five, because a program is free to have set any combination and a
|
||||
// leftover encoding mode outlives the tracking mode that motivated it.
|
||||
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
|
||||
}, [syncMouseCapture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
// Let the user select text even while a program holds the mouse.
|
||||
// xterm's force-selection modifier is Shift everywhere *except* macOS,
|
||||
// where it is Option and is gated behind this option, which defaults to
|
||||
// false — so without this line Mac users have no force-select at all and
|
||||
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
||||
// first. `SelectionService.shouldForceSelection`.
|
||||
macOptionClickForcesSelection: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
@@ -364,7 +410,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
console.warn("Refusing to open a link that failed validation");
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
// Same failure reporting as the toast's Open button — see the long note
|
||||
// on `handleOpenUrl`, including what this catch does *not* catch on
|
||||
// Linux. A click that appears to do nothing is the complaint either way.
|
||||
openUrlExternal(safe).catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open that link in your browser",
|
||||
detail: String(e),
|
||||
// A dead opener fails for every link in the buffer. One card.
|
||||
dedupeKey: "host-open-failed",
|
||||
}),
|
||||
);
|
||||
}, { urlRegex });
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
@@ -391,6 +448,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
useAppState.getState().sttToggle();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
|
||||
// a key because the failure this recovers from is *the pointer not
|
||||
// working* — a control you have to click can be unreachable in exactly
|
||||
// the situation that calls for it.
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
|
||||
releaseMouse();
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||
@@ -501,42 +566,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
|
||||
const selectionDisposable = term.onSelectionChange(() => {
|
||||
setTerminalHasSelection(term.hasSelection());
|
||||
@@ -599,15 +628,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data, () => {
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
if (!isAtBottomRef.current) {
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Scrolling on new output is xterm's own job, and it already gets it
|
||||
// right: it follows the tail while the viewport is at the bottom and
|
||||
// holds position while you are reading further up. The manual
|
||||
// `scrollToBottom()` that used to live here fought that second half.
|
||||
term.write(data, syncMouseCapture);
|
||||
detector.feed(data);
|
||||
|
||||
// Scan for SSO refresh marker in terminal output
|
||||
@@ -649,11 +674,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = null;
|
||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||
// Whether the viewport was following the tail has to be sampled
|
||||
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
|
||||
// afterwards cannot tell "was at the bottom" from "was pushed off it".
|
||||
const wasAtBottom =
|
||||
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
// Only re-anchor a viewport that was already on the tail. This
|
||||
// observer fires for any pane size change — opening the Notes dock,
|
||||
// dragging the sidebar, resizing the window — and none of those are a
|
||||
// reason to yank someone away from the scrollback they are reading.
|
||||
if (wasAtBottom) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -667,14 +699,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
osc52Disposable.dispose();
|
||||
relayDisposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
selectionDisposable.dispose();
|
||||
setTerminalHasSelection(false);
|
||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||
resizeObserver.disconnect();
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
@@ -723,10 +752,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
|
||||
if (active) {
|
||||
// Same rule as the resize observer: re-anchor only what was already
|
||||
// anchored, so a tab left scrolled up comes back where it was left.
|
||||
const wasAtBottom =
|
||||
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
fitRef.current?.fit();
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
if (wasAtBottom) term.scrollToBottom();
|
||||
term.focus();
|
||||
}
|
||||
}, [active, gpuRenderingSetting]);
|
||||
@@ -767,20 +798,58 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [imagePasteMsg]);
|
||||
|
||||
/**
|
||||
* Hand the prompted URL to the host's browser.
|
||||
*
|
||||
* Two things here are ordering, not decoration:
|
||||
*
|
||||
* - **The toast is dismissed on success only.** It used to go first, so 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.
|
||||
* - **The failure is a toast, not a `console.error`.** Same `pushToast` the
|
||||
* container-browser branch below uses, because from the user's side the
|
||||
* two actions fail identically: nothing happens.
|
||||
*
|
||||
* What this does *not* cover, and must not be described as covering: on Linux
|
||||
* `xdg-open` routinely exits 0 having done nothing useful, so the most common
|
||||
* Linux failure resolves this promise and reports success. Stripping the
|
||||
* leaked AppImage environment before the browser is spawned is what addresses
|
||||
* that; this is the complement that catches everything which does report.
|
||||
*/
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
// Validated again at the sink. `promptUrl` is the only writer and already
|
||||
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||
// precisely when it matters that the last thing before `openUrl` checks.
|
||||
// precisely when it matters that the last thing before the opener checks.
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
dismissUrlPrompt();
|
||||
return;
|
||||
}
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
openUrlExternal(safe)
|
||||
.then(() => dismissUrlPrompt())
|
||||
.catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open it in your browser",
|
||||
detail: String(e),
|
||||
dedupeKey: "host-open-failed",
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, dismissUrlPrompt]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -826,39 +895,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
);
|
||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
autoFollowRef.current = true;
|
||||
setIsAutoFollow(true);
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
||||
// control, but only while it's the active (visible) terminal.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setTerminalAtBottom(isAtBottom);
|
||||
setScrollActiveToBottom(handleScrollToBottom);
|
||||
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
||||
|
||||
// On unmount, if this was the active terminal, clear the status-bar scroll
|
||||
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
||||
// unmount — the deactivating terminal stays mounted but hidden — so this
|
||||
// only fires when the active session is actually closed.)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeRef.current) {
|
||||
setTerminalAtBottom(true);
|
||||
setScrollActiveToBottom(() => {});
|
||||
}
|
||||
};
|
||||
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
||||
|
||||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -876,20 +912,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleToggleAutoFollow = useCallback(() => {
|
||||
const next = !autoFollowRef.current;
|
||||
autoFollowRef.current = next;
|
||||
setIsAutoFollow(next);
|
||||
if (next) {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
// Surface the capture state and its escape hatch to the status bar, but only
|
||||
// while this is the visible terminal.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setTerminalMouseCaptured(mouseCaptured);
|
||||
setReleaseActiveMouse(releaseMouse);
|
||||
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||
|
||||
// On unmount, if this was the active terminal, clear the status-bar state so
|
||||
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
||||
// the deactivating terminal stays mounted but hidden — so this only fires
|
||||
// when the active session is actually closed.)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeRef.current) {
|
||||
setTerminalMouseCaptured(false);
|
||||
setReleaseActiveMouse(() => {});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -904,6 +946,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
signInDefault={signInDefault}
|
||||
onDismiss={dismissUrlPrompt}
|
||||
/>
|
||||
)}
|
||||
@@ -915,18 +958,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
{/* Auto-follow toggle - top right */}
|
||||
<button
|
||||
onClick={handleToggleAutoFollow}
|
||||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||||
isAutoFollow
|
||||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||||
}`}
|
||||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||||
>
|
||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||
</button>
|
||||
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||
FitAddon measures the host element it's mounted into; padding there
|
||||
causes the grid to overhang and clip the rightmost column / bottom
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
|
||||
|
||||
describe("Anthropic sign-in links", () => {
|
||||
// The callback listener a `claude login` is waiting on is *inside* the
|
||||
// container. Sending the user to their host browser completes the sign-in
|
||||
// and then posts the result where nothing is listening, and the terminal
|
||||
// hangs to its timeout — so for these, and only these, the container-side
|
||||
// browser leads.
|
||||
// container, so a sign-in is the one case where the host browser may be the
|
||||
// wrong lead. Whether it actually is depends on the project — a live auth
|
||||
// bridge carries the callback back, and the container-side alternative is
|
||||
// not installed on a fresh project — so the owner decides and passes
|
||||
// `signInDefault`. This component only renders the decision.
|
||||
const SIGN_IN =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
||||
|
||||
@@ -180,12 +183,13 @@ describe("UrlToast", () => {
|
||||
.filter((t) => t === "Open" || t === "In container");
|
||||
}
|
||||
|
||||
it("puts the container browser first", () => {
|
||||
it("puts the container browser first when the caller asks for it", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -195,6 +199,42 @@ describe("UrlToast", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leads with the host when the caller says so, without hiding the other", () => {
|
||||
// A live auth bridge, or a container with no browser installed. The pair
|
||||
// is unchanged; only the order and which one is filled.
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="host"
|
||||
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.
|
||||
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
||||
/auth bridge/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to the host when the caller passes nothing", () => {
|
||||
// The safe fallback: the answer more likely to work, and the one that
|
||||
// reports its own failure.
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(actions()).toEqual(["Open", "In container"]);
|
||||
});
|
||||
|
||||
it("keeps the host browser available as a fallback", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
@@ -202,6 +242,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={onOpen}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -211,12 +252,14 @@ describe("UrlToast", () => {
|
||||
|
||||
it("leaves an ordinary URL alone", () => {
|
||||
// A `gh auth login` device code, a docs page, a preview build — the host
|
||||
// browser is the right answer for all of them and stays the default.
|
||||
// browser is the right answer for all of them and stays the default,
|
||||
// whatever the project's sign-in preference happens to be.
|
||||
render(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -232,6 +275,7 @@ describe("UrlToast", () => {
|
||||
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -37,6 +37,18 @@ interface Props {
|
||||
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||
* the project has no browser to open it in. */
|
||||
onOpenInContainer?: () => void;
|
||||
/**
|
||||
* Which action leads for a *sign-in* link (see the note below). Nothing else
|
||||
* in the toast moves: both buttons are offered either way, in either order.
|
||||
*
|
||||
* 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. `"host"` is the default here
|
||||
* for the same reason it is the fallback there: it is the answer that is more
|
||||
* likely to work, and the one that reports its own failure.
|
||||
*/
|
||||
signInDefault?: "host" | "container";
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -57,17 +69,20 @@ interface Props {
|
||||
* text swaps with no animation, and a user reading URL A can click Open on URL
|
||||
* B that arrived a second later.
|
||||
*
|
||||
* ## Anthropic sign-in links default to the container's browser
|
||||
* ## Anthropic sign-in links get their default from the caller
|
||||
*
|
||||
* For an ordinary URL the host browser is the right answer and stays the
|
||||
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
|
||||
* is waiting on is inside the container, so a host browser completes the sign-in
|
||||
* and then posts the result somewhere nothing is listening, and the terminal
|
||||
* hangs until it times out. Making the host button primary there was quietly
|
||||
* steering every user into that. The container-side browser closes the loop
|
||||
* with no host round trip and no auth bridge, so it leads — and the host button
|
||||
* stays, because a user who has the auth bridge on, or who wants their existing
|
||||
* browser session, still needs it.
|
||||
* default, unconditionally. A sign-in is the one case where it might not be:
|
||||
* the callback listener the CLI is waiting on is inside the container, so a
|
||||
* host browser can complete the sign-in and then post the result where nothing
|
||||
* is listening, leaving the terminal to hang to its timeout.
|
||||
*
|
||||
* *Can*, not *does* — which is why this is no longer decided from the URL. The
|
||||
* auth bridge mirrors that container listener onto the same host port, and the
|
||||
* container-side alternative is Playwright's dashboard pane, which a fresh
|
||||
* project has not installed. Both of those are project facts, so the owner
|
||||
* passes {@link Props.signInDefault} and this only renders it: the leading
|
||||
* button is filled and comes first, the other keeps its place beside it.
|
||||
*
|
||||
* ## Reachable without a mouse, and it does not take focus to manage it
|
||||
*
|
||||
@@ -96,6 +111,7 @@ export default function UrlToast({
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
signInDefault = "host",
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -103,18 +119,22 @@ export default function UrlToast({
|
||||
// Only when there is somewhere to send it: without `onOpenInContainer` the
|
||||
// host button is the only action there is, so it stays primary.
|
||||
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
||||
// A sign-in link the caller has decided is better completed inside the
|
||||
// container. Everything below keys off this rather than off `signIn`, so the
|
||||
// two orderings differ only in which of the pair leads.
|
||||
const containerLeads = signIn && signInDefault === "container";
|
||||
|
||||
// `Button` already owns the filled/outlined variants — including the rule
|
||||
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||
// foreground/link accent and fails WCAG AA behind white text.
|
||||
const hostButton = (
|
||||
<Button
|
||||
variant={signIn ? "secondary" : "primary"}
|
||||
data-url-toast-primary={signIn ? undefined : "true"}
|
||||
variant={containerLeads ? "secondary" : "primary"}
|
||||
data-url-toast-primary={containerLeads ? undefined : "true"}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
title={
|
||||
signIn
|
||||
containerLeads
|
||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||
: undefined
|
||||
}
|
||||
@@ -128,8 +148,8 @@ export default function UrlToast({
|
||||
// the container's own loopback, which is where the tool waiting for it is
|
||||
// listening — no host round trip, no auth bridge.
|
||||
<Button
|
||||
variant={signIn ? "primary" : "secondary"}
|
||||
data-url-toast-primary={signIn ? "true" : undefined}
|
||||
variant={containerLeads ? "primary" : "secondary"}
|
||||
data-url-toast-primary={containerLeads ? "true" : undefined}
|
||||
onClick={onOpenInContainer}
|
||||
className="flex-shrink-0"
|
||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||
@@ -235,14 +255,14 @@ export default function UrlToast({
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
Sign-in link — the callback listener is inside the container.
|
||||
Opening it there closes the loop; the host browser needs the auth
|
||||
bridge.
|
||||
{containerLeads
|
||||
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
|
||||
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signIn ? (
|
||||
{containerLeads ? (
|
||||
<>
|
||||
{containerButton}
|
||||
{hostButton}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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. */
|
||||
export type SignInOpenTarget = "host" | "container";
|
||||
|
||||
/**
|
||||
* 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 fallbacks land on the host, for different reasons:
|
||||
*
|
||||
* - With the bridge live, 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, 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.
|
||||
*
|
||||
* 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";
|
||||
if (canOpenPageInContainerBrowser(detection)) return "container";
|
||||
return "host";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const [target, setTarget] = useState<SignInOpenTarget>("host");
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setTarget("host");
|
||||
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: this only picks which button is
|
||||
// filled in, and the fallback is the one that reports its own failures.
|
||||
.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", () => {
|
||||
/** Everything that is painted over a pane and is not a blocker. */
|
||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
||||
// host — so "does the pane contain what is painted here?" made the
|
||||
// terminal's top-right corner a dead zone no user action could clear.
|
||||
["the Following/Paused toggle", () => document.createElement("button")],
|
||||
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
|
||||
// and a *sibling* of the xterm host — so "does the pane contain what is
|
||||
// painted here?" made the terminal's top-right corner a dead zone no
|
||||
// user action could clear. (The retired Following toggle held the same
|
||||
// corner and produced the original bug.)
|
||||
["the mouse-release badge", () => document.createElement("button")],
|
||||
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||
// pane, and its error cards stay until dismissed.
|
||||
["a toast card", () => document.createElement("div")],
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
*
|
||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
|
||||
* of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||
* Permanent dead zones no user action could clear.
|
||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||
|
||||
@@ -350,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
|
||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||
//
|
||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
||||
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
|
||||
// rather than polling) → migrateProjectToBase → the project sits
|
||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||
// rollbackMigration.
|
||||
//
|
||||
@@ -361,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
|
||||
//
|
||||
// Progress arrives on the existing `container-progress` event.
|
||||
|
||||
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
||||
/**
|
||||
* Runs two container/image filesystem probes; not for polling.
|
||||
*
|
||||
* **Not read-only, despite only reporting.** When the container is *stopped*
|
||||
* the backend has to commit its writable layer to a throwaway image before it
|
||||
* can read anything — `docker exec` needs a running container — so this writes
|
||||
* (and then removes) an image. The result is cached per stop, so repeat calls
|
||||
* while the container stays stopped are cheap, but the first one after each stop
|
||||
* pays for a commit of the whole layer: seconds on a small project, tens of
|
||||
* seconds on a large one. Do not add a caller that fires more often than "the
|
||||
* container settled into a new state" without re-reading
|
||||
* `get_container_staleness`'s doc comment first.
|
||||
*/
|
||||
export const getContainerStaleness = (projectId: string) =>
|
||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||
|
||||
@@ -386,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
|
||||
* app crash shows up here as phase "interrupted". */
|
||||
export const getMigrationState = (projectId: string) =>
|
||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
||||
|
||||
/** Open a URL in the user's own browser.
|
||||
*
|
||||
* Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On
|
||||
* Linux the app ships as an AppImage whose environment leaks into everything
|
||||
* it spawns, which kills a *cold-launched* browser before it paints while
|
||||
* `xdg-open` still exits 0 — so the plugin path reported success and did
|
||||
* nothing (triple-c#34). The Rust side hands the child a repaired environment
|
||||
* and re-validates the URL, which matters because these URLs originate in an
|
||||
* untrusted container. macOS and Windows still reach the plugin, just from
|
||||
* Rust, so there is no platform branch here.
|
||||
*
|
||||
* Rejects with a string already phrased for a toast. */
|
||||
export const openUrlExternal = (url: string) =>
|
||||
invoke<void>("open_url_external", { url });
|
||||
|
||||
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
|
||||
* A direct port of `usable_sign_in_link` in
|
||||
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
||||
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
|
||||
* only thing standing between any of this and `openUrl`, and duplicating its
|
||||
* only thing standing between any of this and `openUrlExternal`, and
|
||||
* duplicating its
|
||||
* rules here would be a second place for them to go stale.
|
||||
*
|
||||
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
|
||||
@@ -293,7 +294,7 @@ export class UrlDetector {
|
||||
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
|
||||
// swallowed into the middle of a match becomes a URL that renders as one
|
||||
// thing in the toast and resolves as another. Everything emitted here is
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MAX_RELAY_URL_LENGTH,
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
isAnthropicSignInUrl,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
urlOrigin,
|
||||
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
|
||||
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAnthropicSignInUrl", () => {
|
||||
// Classification only. Where a sign-in link should be opened is decided by
|
||||
// `hooks/useSignInOpenTarget.ts`, from facts about the project — this answers
|
||||
// the narrower question of whether it is a sign-in link at all, and it does
|
||||
// so through the same allowlist the sign-in flow itself uses.
|
||||
it("recognises the links `claude setup-token` and `claude login` print", () => {
|
||||
expect(
|
||||
isAnthropicSignInUrl(
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAnthropicSignInUrl("https://platform.claude.com/oauth/code/callback?x=1"),
|
||||
).toBe(true);
|
||||
expect(isAnthropicSignInUrl("https://console.anthropic.com/login?x=1")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is not fooled by a host that merely contains an allowed domain", () => {
|
||||
// The thing the allowlist exists for: `claude.ai.evil.tld` ends with
|
||||
// neither `claude.ai` nor `.claude.ai`.
|
||||
expect(isAnthropicSignInUrl("https://claude.ai.evil.tld/oauth/authorize")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAnthropicSignInUrl("https://notclaude.ai/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the full validator, not just the host test", () => {
|
||||
// It runs `sanitizeRelayUrl`, so everything that cannot be opened at all
|
||||
// is not a sign-in link either — no separate, weaker copy of the rules.
|
||||
expect(isAnthropicSignInUrl("javascript:claude.ai/login")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://claude.ai@evil.tld/login")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://claude\nai/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim every allowlisted URL is a sign-in", () => {
|
||||
expect(isAnthropicSignInUrl("https://claude.ai/chat/abc")).toBe(false);
|
||||
expect(isAnthropicSignInUrl("https://www.anthropic.com/news")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves an ordinary link alone, whatever it says in its path", () => {
|
||||
// A `gh auth login` device code is the common one, and sending it to a
|
||||
// container-side browser would be actively wrong.
|
||||
expect(isAnthropicSignInUrl("https://github.com/login/device?code=A")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-6
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* URL relay — host side of `container/triple-c-open` — and the single URL
|
||||
* validator every `openUrl` call site in the app is required to go through.
|
||||
* validator every `openUrlExternal` call site in the app is required to go
|
||||
* through.
|
||||
*
|
||||
* A CLI inside the container has no browser. When it wants to open a URL
|
||||
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
|
||||
@@ -182,11 +183,22 @@ export function extendsUrl(next: string, current: string): boolean {
|
||||
/**
|
||||
* Whether this is a URL that signs the user in to Anthropic.
|
||||
*
|
||||
* Used to decide *presentation*, not permission — the toast makes the
|
||||
* container-side browser the default action for these, because the OAuth
|
||||
* callback listener is inside the container and the host has nothing to catch
|
||||
* it with. It is deliberately the same host allowlist the sign-in flow itself
|
||||
* uses, so the two cannot disagree about what a sign-in link is.
|
||||
* Classification only. It answers "is this a sign-in link", never "where should
|
||||
* it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
|
||||
* because it depends on things this module has no business knowing: whether the
|
||||
* project's auth bridge is live, and whether a browser is actually installed in
|
||||
* the container. This function stays here because the *rule* it encodes is a
|
||||
* URL rule, and it is deliberately the same host allowlist the sign-in flow
|
||||
* itself uses, so the two cannot disagree about what a sign-in link is.
|
||||
*
|
||||
* It used to carry the default with it — container-side always, on the grounds
|
||||
* that "the OAuth callback listener is inside the container and the host has
|
||||
* nothing to catch it with". Both halves of that are now wrong. The host does
|
||||
* have something to catch it with (the auth bridge mirrors the container's
|
||||
* loopback listener onto the same host port), and the container-side target is
|
||||
* not a general browser but Playwright's dashboard pane, whose browsers are
|
||||
* deliberately not baked into the image — so on a fresh project the default
|
||||
* pointed at something that was not installed, on every platform.
|
||||
*/
|
||||
export function isAnthropicSignInUrl(url: string): boolean {
|
||||
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
|
||||
|
||||
+14
-10
@@ -205,16 +205,20 @@ interface AppState {
|
||||
// UI state
|
||||
terminalHasSelection: boolean;
|
||||
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
|
||||
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
||||
sttToggle: () => 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";
|
||||
setSidebarView: (view: "projects" | "settings") => void;
|
||||
sidebarCollapsed: boolean;
|
||||
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
|
||||
// UI state
|
||||
terminalHasSelection: false,
|
||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||
terminalMouseCaptured: false,
|
||||
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
|
||||
releaseActiveMouse: () => {},
|
||||
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
|
||||
sttToggle: () => {},
|
||||
setSttToggle: (fn) => set({ sttToggle: fn }),
|
||||
terminalAtBottom: true,
|
||||
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
||||
scrollActiveToBottom: () => {},
|
||||
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
||||
sidebarView: "projects",
|
||||
setSidebarView: (view) => set({ sidebarView: view }),
|
||||
sidebarCollapsed: loadSidebarCollapsed(),
|
||||
|
||||
@@ -639,8 +639,14 @@ fi
|
||||
# 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
|
||||
# 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..."
|
||||
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: 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."
|
||||
Reference in New Issue
Block a user