Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24807bd5f | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 |
@@ -1,298 +0,0 @@
|
|||||||
name: Publish Arch Package
|
|
||||||
|
|
||||||
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
|
||||||
# given release, or the latest one if none is given, and attaches the built
|
|
||||||
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
|
||||||
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
|
||||||
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
|
||||||
# as an automatic trigger: this repo's releases are assembled by
|
|
||||||
# build-app.yml across three separate platform jobs, and there is no single
|
|
||||||
# automatic event that fires only once everything (including the Linux .deb
|
|
||||||
# this workflow needs) is actually uploaded. A human deciding "this release
|
|
||||||
# is ready, go package it" is the correct trigger, the same reasoning
|
|
||||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
|
||||||
#
|
|
||||||
# ## What this does and does not do
|
|
||||||
#
|
|
||||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
|
||||||
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
|
||||||
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
|
|
||||||
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
|
||||||
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
|
||||||
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
|
||||||
# a placeholder version, and the workflow never starts from or writes to it.
|
|
||||||
#
|
|
||||||
# ## Not published to the AUR (yet)
|
|
||||||
#
|
|
||||||
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
|
||||||
# needs a maintainer AUR account and its SSH key registered as a secret here
|
|
||||||
# — both manual, one-time steps neither this workflow nor anyone but a
|
|
||||||
# maintainer can do. Until that setup happens, a downloadable release asset
|
|
||||||
# gets the same package to users without it. The AUR push step is still in
|
|
||||||
# this file's git history (see the commit that added this comment) if that
|
|
||||||
# setup is ever done and it's worth reinstating.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: >-
|
|
||||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
|
||||||
Leave empty to use the latest published GitHub release.
|
|
||||||
required: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REPO: shadowdao/triple-c
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Resolve version and find the Linux asset
|
|
||||||
id: resolve
|
|
||||||
env:
|
|
||||||
VERSION_INPUT: ${{ inputs.version }}
|
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Authenticated when the secret is available (it is, everywhere
|
|
||||||
# else in this repo's workflows) to avoid the unauthenticated
|
|
||||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
|
||||||
# lower limit, since this hits nothing but a public repo's public
|
|
||||||
# releases.
|
|
||||||
AUTH=()
|
|
||||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
|
||||||
|
|
||||||
if [ -z "${VERSION_INPUT}" ]; then
|
|
||||||
echo "No version given — resolving the latest GitHub release"
|
|
||||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
|
||||||
else
|
|
||||||
echo "Using requested version ${VERSION_INPUT}"
|
|
||||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
echo "Resolved to ${TAG}"
|
|
||||||
|
|
||||||
# Discovered from the real release, not assumed: Tauri names the
|
|
||||||
# asset after `productName` verbatim ("Triple-C"), not the
|
|
||||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
|
||||||
# of thing that silently drifts if a future Tauri upgrade changes
|
|
||||||
# bundler defaults — a hardcoded pattern here would then 404
|
|
||||||
# forever until someone noticed. `head -1` guards against a release
|
|
||||||
# somehow carrying more than one matching asset, which would
|
|
||||||
# otherwise pass the emptiness check below and then break the
|
|
||||||
# download step with two URLs on one line.
|
|
||||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
|
||||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
|
||||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
|
||||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Found asset: ${DEB_NAME}"
|
|
||||||
|
|
||||||
# For attaching the built package to this same release later —
|
|
||||||
# every release object carries its own `upload_url` regardless of
|
|
||||||
# whether it was just created or (as here) already existed, and
|
|
||||||
# the `{?name,label}` URI-template suffix has to come off before
|
|
||||||
# this is usable as a plain URL to POST to.
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
|
||||||
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Download the release asset and compute real checksums
|
|
||||||
id: checksums
|
|
||||||
env:
|
|
||||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
|
||||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
||||||
TAG: ${{ steps.resolve.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
|
||||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
|
||||||
|
|
||||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Render PKGBUILD
|
|
||||||
id: render
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.resolve.outputs.version }}
|
|
||||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
||||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
|
||||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p rendered
|
|
||||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
|
||||||
cd rendered
|
|
||||||
|
|
||||||
# Plain string replacement throughout, not sed — the source URL
|
|
||||||
# contains slashes and the repo name does too, and getting a sed
|
|
||||||
# delimiter choice AND its escaping right for that is exactly the
|
|
||||||
# kind of thing that looks correct, passes review, and breaks the
|
|
||||||
# next time someone touches it. `re.sub` with `count=1` and an
|
|
||||||
# exact `.format`-free literal match is boring and that's the
|
|
||||||
# point: every substitution below fails loudly (an assertion /
|
|
||||||
# the checks after) rather than silently no-op'ing if the
|
|
||||||
# template's shape ever drifts from what this expects.
|
|
||||||
#
|
|
||||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
|
||||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
|
||||||
# for, and this workflow always republishes the current PKGBUILD
|
|
||||||
# verbatim rather than incrementing anything, so 1 is always
|
|
||||||
# correct for what this workflow does. It is NOT correct for a
|
|
||||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
|
||||||
# would be forced back to 1, and no existing installation sees an
|
|
||||||
# upgrade. That case needs a manual pkgrel bump in the template
|
|
||||||
# before dispatching, which this workflow has no input for.
|
|
||||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
|
||||||
import re, sys
|
|
||||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
|
||||||
|
|
||||||
with open("PKGBUILD") as f:
|
|
||||||
text = f.read()
|
|
||||||
|
|
||||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
|
||||||
assert n == 1, "pkgver=... line not found"
|
|
||||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
|
||||||
assert n == 1, "pkgrel=... line not found"
|
|
||||||
|
|
||||||
# Built with a "$" variable and plain "+" concatenation rather than
|
|
||||||
# an f-string's double-brace escape for a literal brace: writing
|
|
||||||
# this as an f-string put a dollar sign directly against two open
|
|
||||||
# braces, right here in this workflow's own YAML text — and this
|
|
||||||
# runner's own expression templating scans a run: block for that
|
|
||||||
# exact two-character opening sequence and tries to evaluate
|
|
||||||
# whatever sits inside as one of ITS OWN expressions (a step
|
|
||||||
# output, a secret, ...) before the shell ever sees this script.
|
|
||||||
# "pkgver" isn't one of those, so that lookup failed and silently
|
|
||||||
# emptied this whole step rather than raising anything here.
|
|
||||||
# Spelling the dollar sign out of a variable instead means this
|
|
||||||
# file's own text never contains that trigger sequence.
|
|
||||||
DOLLAR = "$"
|
|
||||||
old_source = (
|
|
||||||
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
|
||||||
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
|
||||||
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
|
||||||
)
|
|
||||||
new_source = (
|
|
||||||
f'source=("{deb_name}::'
|
|
||||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
|
||||||
)
|
|
||||||
assert old_source in text, "source=() line does not match the expected template shape"
|
|
||||||
text = text.replace(old_source, new_source, 1)
|
|
||||||
|
|
||||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
|
||||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
|
||||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
|
||||||
|
|
||||||
with open("PKGBUILD", "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
PY
|
|
||||||
|
|
||||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
|
||||||
! grep -q "SKIP" PKGBUILD
|
|
||||||
|
|
||||||
- name: Validate with makepkg and namcap
|
|
||||||
id: build
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
|
||||||
# obvious way to write this, and was the first draft — but on a
|
|
||||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
|
||||||
# job's own container, which the daemon's host cannot resolve; the
|
|
||||||
# mount would silently attach an empty directory instead of failing
|
|
||||||
# loudly. `docker cp` moves real bytes across that boundary
|
|
||||||
# regardless of where the daemon actually lives, which is what
|
|
||||||
# makes this work under both a bind-mount-capable runner and a
|
|
||||||
# containerized one.
|
|
||||||
docker pull archlinux:latest
|
|
||||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
|
||||||
set -euo pipefail
|
|
||||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
|
||||||
useradd -m builder
|
|
||||||
chown -R builder:builder /work
|
|
||||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
|
||||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
|
||||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
|
||||||
# Named once here, inside the container, rather than guessed
|
|
||||||
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
|
||||||
# host after the fact — makepkg is the one place that actually
|
|
||||||
# knows its own output name, and `!debug` already guarantees
|
|
||||||
# this glob can only ever match the one real package (no
|
|
||||||
# -debug split package gets produced).
|
|
||||||
basename /work/*.pkg.tar.* > /work/.pkgfile
|
|
||||||
echo "--- namcap ---"
|
|
||||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
|
||||||
echo "$NAMCAP_OUT"
|
|
||||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
|
||||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
|
||||||
# uses more than one line shape for its two rule families, and
|
|
||||||
# namcap itself exits 0 regardless of what it reports, so this
|
|
||||||
# grep is the only thing standing between an E: and a green job.
|
|
||||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
|
||||||
echo "namcap reported an error — see above" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
')
|
|
||||||
mkdir -p rendered
|
|
||||||
docker cp rendered/. "${CID}:/work"
|
|
||||||
# `docker start -a` streams output and its exit code is the
|
|
||||||
# container's own — the same failure this would have hit with a
|
|
||||||
# bind mount still fails the job the same way.
|
|
||||||
docker start -a "${CID}"
|
|
||||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
|
||||||
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
|
||||||
PKG_FILE=$(cat rendered/.pkgfile)
|
|
||||||
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
|
||||||
docker rm -f "${CID}" >/dev/null
|
|
||||||
|
|
||||||
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Attach the package to the GitHub release
|
|
||||||
env:
|
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
|
||||||
TAG: ${{ steps.resolve.outputs.tag }}
|
|
||||||
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
|
||||||
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
|
||||||
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${GH_PAT}" ]; then
|
|
||||||
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A manual re-dispatch for a version that's already been packaged
|
|
||||||
# would otherwise hit GitHub's 422 "already_exists" here instead
|
|
||||||
# of just replacing the stale build with this one.
|
|
||||||
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
|
||||||
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
|
||||||
if [ -n "$EXISTING_ID" ]; then
|
|
||||||
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
|
||||||
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer ${GH_PAT}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@rendered/${PKG_FILE}" \
|
|
||||||
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
|
||||||
> /dev/null
|
|
||||||
|
|
||||||
echo "Attached ${PKG_FILE} to ${TAG}"
|
|
||||||
@@ -677,6 +677,26 @@ deliberately out of scope — this is not a project backup.
|
|||||||
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||||
as a second layer against the same failure mode.
|
as a second layer against the same failure mode.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||||
|
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||||
|
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||||
|
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||||
|
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||||
|
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||||
|
the AppImage is the Arch story.
|
||||||
|
|
||||||
|
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||||
|
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||||
|
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||||
|
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||||
|
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||||
|
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||||
|
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
- [The Interface](#the-interface)
|
- [The Interface](#the-interface)
|
||||||
@@ -32,6 +33,63 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||||
|
|
||||||
|
| Platform | File | Install |
|
||||||
|
|----------|------|---------|
|
||||||
|
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||||
|
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||||
|
| **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. |
|
||||||
|
|
||||||
|
> **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).
|
||||||
|
|
||||||
|
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||||
|
|
||||||
|
### Adding an AppImage to the app launcher
|
||||||
|
|
||||||
|
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||||
|
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||||
|
because nothing installed it — so it will not show up in your app launcher, and
|
||||||
|
running it from a file manager gives you a generic icon in the taskbar.
|
||||||
|
|
||||||
|
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||||
|
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Apps
|
||||||
|
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||||
|
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||||
|
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||||
|
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||||
|
copied or moved. To remove the entry again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-appimage.sh --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||||
|
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||||
|
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||||
|
bundle would appear in the menu and then fail to start anything.
|
||||||
|
|
||||||
|
Two follow-ups worth knowing:
|
||||||
|
|
||||||
|
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||||
|
with a newer version under a different filename, re-run the script against the
|
||||||
|
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||||
|
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||||
|
icon cache, not a failed install — see
|
||||||
|
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -1536,3 +1594,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
|||||||
```
|
```
|
||||||
|
|
||||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||||
|
|
||||||
|
### App Icon Missing After Installing (Linux)
|
||||||
|
|
||||||
|
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||||
|
|
||||||
|
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||||
|
|||||||
@@ -418,12 +418,6 @@ triple-c/
|
|||||||
│ ├── build-stt.yml # Build the STT image
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
│ ├── cleanup-releases.yml # Prune old releases
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
|
|
||||||
│
|
|
||||||
├── packaging/
|
|
||||||
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
|
|
||||||
│ ├── PKGBUILD
|
|
||||||
│ └── README.md
|
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
|
|||||||
@@ -26,12 +26,27 @@
|
|||||||
/// their own init time, which happens inside the Tauri builder that
|
/// their own init time, which happens inside the Tauri builder that
|
||||||
/// function calls into, not at binary load.
|
/// function calls into, not at binary load.
|
||||||
///
|
///
|
||||||
/// A user who has already set this themselves is left alone. That includes
|
/// A user who has already set this themselves is left alone — with one
|
||||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
/// correction. The earlier version of this function left *any* pre-set value
|
||||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||||
/// safe direction to be wrong in.
|
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||||
|
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||||
|
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||||
|
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||||
|
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||||
|
/// sees any difference.
|
||||||
|
///
|
||||||
|
/// That matters more than it looks, because the trade described above is not
|
||||||
|
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||||
|
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||||
|
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||||
|
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||||
|
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||||
|
/// this comment assumed it would degrade to, not faster. See
|
||||||
|
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||||
|
/// whether the addon is loaded at all.
|
||||||
///
|
///
|
||||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||||
@@ -39,10 +54,77 @@
|
|||||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
/// as a separate, unrelated cause.
|
/// as a separate, unrelated cause.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||||
|
|
||||||
|
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||||
|
/// already set to. Split from the mutation so it can be tested without
|
||||||
|
/// touching process-wide environment state from a parallel test runner.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum DmabufAction {
|
||||||
|
/// Not set by the user — apply the workaround.
|
||||||
|
Disable,
|
||||||
|
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||||
|
/// way to express "enabled" is for the variable not to exist.
|
||||||
|
Remove,
|
||||||
|
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||||
|
LeaveAlone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||||
|
match current {
|
||||||
|
None => DmabufAction::Disable,
|
||||||
|
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||||
|
_ => DmabufAction::LeaveAlone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn apply_webkit_wayland_workaround() {
|
fn apply_webkit_wayland_workaround() {
|
||||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
let current = std::env::var(DMABUF_VAR).ok();
|
||||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
match dmabuf_action(current.as_deref()) {
|
||||||
|
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||||
|
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||||
|
DmabufAction::LeaveAlone => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::{dmabuf_action, DmabufAction};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_gets_the_workaround() {
|
||||||
|
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||||
|
// The bug this replaces: these all previously read as "user set it,
|
||||||
|
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||||
|
// it only checks presence. There was no way to ask for the GPU path.
|
||||||
|
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::Remove,
|
||||||
|
"{value:?} should opt out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_values_are_left_alone() {
|
||||||
|
for value in ["1", "true", "yes", "anything"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::LeaveAlone,
|
||||||
|
"{value:?} should be left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
|||||||
pub gateway: GatewaySettings,
|
pub gateway: GatewaySettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||||
|
///
|
||||||
|
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||||
|
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||||
|
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||||
|
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||||
|
/// The addon therefore loads successfully and then renders every frame on
|
||||||
|
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||||
|
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||||
|
/// disabled on Linux.
|
||||||
|
///
|
||||||
|
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||||
|
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||||
|
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||||
|
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||||
|
/// the zero value has to mean "we choose", not "off", or every existing
|
||||||
|
/// settings file would silently pin the answer at whatever the default was
|
||||||
|
/// the day it was written.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal_gpu_rendering: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_stt_model() -> String {
|
fn default_stt_model() -> String {
|
||||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
|||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
gateway: GatewaySettings::default(),
|
gateway: GatewaySettings::default(),
|
||||||
global_claude_code_settings: None,
|
global_claude_code_settings: None,
|
||||||
|
terminal_gpu_rendering: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
|
||||||
|
const add = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({ add }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (v: unknown) => void;
|
||||||
|
const promise = new Promise((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillValidForm() {
|
||||||
|
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||||
|
target: { value: "my-project" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||||
|
target: { value: "/home/user/my-project" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitButton() {
|
||||||
|
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AddProjectDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the project with the name and folder entered", async () => {
|
||||||
|
add.mockResolvedValue({ id: "p1" });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AddProjectDialog onClose={onClose} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(add).toHaveBeenCalledWith("my-project", [
|
||||||
|
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
|
||||||
|
// Native `disabled` would remove the button from the accessibility tree
|
||||||
|
// exactly when it has something to say.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
expect(submitButton()).not.toBeDisabled();
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||||
|
fireEvent.keyDown(submitButton(), { key: " " });
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter in a text field submits a form regardless of the submit button's
|
||||||
|
// state, so the handler has to guard itself too.
|
||||||
|
// Modal portals to document.body, so the form is not under `container`.
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the submit button plainly available when idle", () => {
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||||
|
// is in flight, and Enter inside a text field submits the form without
|
||||||
|
// touching the button at all. Both routes end here, so the guard does too.
|
||||||
|
if (loading) return;
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Project name is required");
|
setError("Project name is required");
|
||||||
return;
|
return;
|
||||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
<Button size="md" variant="ghost" onClick={onClose}>
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
unavailable={loading}
|
||||||
|
unavailableReason="The project is being added. Wait for it to finish."
|
||||||
|
title={
|
||||||
|
loading
|
||||||
|
? "The project is being added. Wait for it to finish."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{loading ? "Adding…" : "Add Project"}
|
{loading ? "Adding…" : "Add Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only allows opening a terminal while the container runs", () => {
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
}),
|
|
||||||
).toBeDisabled();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
|||||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
// Native `disabled` would drop the button out of the accessibility tree
|
||||||
|
// and out of the tab order, taking the reason with it.
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops aria-disabled once the container is running", () => {
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows container progress inline rather than in a blocking modal", () => {
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
|||||||
import { useAppState, homeTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { useUnavailable } from "../ui/unavailable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
|
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||||
|
// attribute that hides the button — and the reason — from anyone not using a
|
||||||
|
// mouse and eyes.
|
||||||
|
const terminal = useUnavailable({
|
||||||
|
unavailable: !isRunning,
|
||||||
|
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||||
|
onClick: () => openClaudeTerminal(),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning}
|
{...terminal.controlProps}
|
||||||
onClick={() => openClaudeTerminal()}
|
title={
|
||||||
title={`Open a Claude terminal for ${project.name}`}
|
isRunning
|
||||||
|
? `Open a Claude terminal for ${project.name}`
|
||||||
|
: `${project.name} is not running. Start it to open a terminal.`
|
||||||
|
}
|
||||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
<line x1="13" y1="15" x2="17" y2="15" />
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{terminal.reasonNode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type { EnvVar } from "../../lib/types";
|
|||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
import AccordionSection from "../ui/AccordionSection";
|
import AccordionSection from "../ui/AccordionSection";
|
||||||
import Toggle from "../ui/Toggle";
|
import Toggle from "../ui/Toggle";
|
||||||
|
import SegmentedControl from "../ui/SegmentedControl";
|
||||||
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
@@ -67,6 +69,14 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
terminal_gpu_rendering: value === "auto" ? null : value === "on",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAutoCheckToggle = async () => {
|
const handleAutoCheckToggle = async () => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||||
@@ -242,6 +252,45 @@ export default function SettingsPanel() {
|
|||||||
<SttSettings />
|
<SttSettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Terminal GPU rendering"
|
||||||
|
value={
|
||||||
|
appSettings?.terminal_gpu_rendering == null
|
||||||
|
? "auto"
|
||||||
|
: appSettings.terminal_gpu_rendering
|
||||||
|
? "on"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
onChange={handleGpuRenderingChange}
|
||||||
|
segments={[
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: "Auto",
|
||||||
|
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
|
||||||
|
? "On for this platform."
|
||||||
|
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "on",
|
||||||
|
label: "On",
|
||||||
|
hint: "Always load the WebGL renderer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "off",
|
||||||
|
label: "Off",
|
||||||
|
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Takes effect when a terminal tab is next switched to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import UrlToast, {
|
|||||||
URL_TOAST_SHORTCUT,
|
URL_TOAST_SHORTCUT,
|
||||||
} from "./UrlToast";
|
} from "./UrlToast";
|
||||||
import { trimSelection } from "./trimSelection";
|
import { trimSelection } from "./trimSelection";
|
||||||
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||||
import TerminalContextMenu from "./TerminalContextMenu";
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -95,6 +96,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||||
@@ -491,7 +493,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
// Handle user input -> backend
|
// Handle user input -> backend
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
sendInput(sessionId, data);
|
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
||||||
|
// means the session is gone, which the exit listener already reports.
|
||||||
|
sendInput(sessionId, data).catch((e) =>
|
||||||
|
console.error("Failed to send terminal input:", e)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||||
@@ -684,7 +690,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
|
|
||||||
if (active) {
|
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
||||||
|
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
||||||
|
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
||||||
|
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
||||||
|
|
||||||
|
// The renderer and the activation work are independent: a terminal with
|
||||||
|
// GPU rendering switched off still has to fit and take focus when its tab
|
||||||
|
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
||||||
|
// "never re-fit, never focus".
|
||||||
|
if (active && useGpu) {
|
||||||
// Attach WebGL renderer
|
// Attach WebGL renderer
|
||||||
if (!webglRef.current) {
|
if (!webglRef.current) {
|
||||||
try {
|
try {
|
||||||
@@ -699,19 +714,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// WebGL not available, canvas renderer is fine
|
// WebGL not available, canvas renderer is fine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (webglRef.current) {
|
||||||
|
// Release the context — for inactive terminals, and when the setting
|
||||||
|
// turns GPU rendering off while this terminal is on screen.
|
||||||
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
if (autoFollowRef.current) {
|
if (autoFollowRef.current) {
|
||||||
term.scrollToBottom();
|
term.scrollToBottom();
|
||||||
}
|
}
|
||||||
term.focus();
|
term.focus();
|
||||||
} else {
|
|
||||||
// Release WebGL context for inactive terminals
|
|
||||||
if (webglRef.current) {
|
|
||||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
|
||||||
webglRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active, gpuRenderingSetting]);
|
||||||
|
|
||||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const onKeyDown = vi.fn();
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still supports the native disabled attribute", () => {
|
||||||
|
render(
|
||||||
|
<Button disabled onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first.">
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||||
|
// The reason is a description, not part of the name.
|
||||||
|
expect(button).toHaveAccessibleName("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards clicks and Enter/Space while unavailable", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still forwards keys that are not activation keys", () => {
|
||||||
|
render(
|
||||||
|
<Button
|
||||||
|
unavailable
|
||||||
|
unavailableReason="Stop the container first."
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||||
|
key: "Escape",
|
||||||
|
});
|
||||||
|
expect(onKeyDown).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaves like an ordinary button when available", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).toHaveAccessibleDescription("");
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { useUnavailable } from "./unavailable";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
export type ButtonSize = "sm" | "md";
|
export type ButtonSize = "sm" | "md";
|
||||||
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
||||||
|
* `unavailableReason` to `aria-describedby` instead of using the native
|
||||||
|
* `disabled` attribute, which would take the button out of the tab order and
|
||||||
|
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
||||||
|
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
||||||
|
* worth telling the user.
|
||||||
|
*/
|
||||||
|
unavailable?: boolean;
|
||||||
|
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
||||||
|
unavailableReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real buttons with visible bounds and a ≥24px hit target.
|
* Real buttons with visible bounds and a ≥24px hit target.
|
||||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||||
* `--accent` stays reserved for foreground/link use.
|
* `--accent` stays reserved for foreground/link use.
|
||||||
|
*
|
||||||
|
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
||||||
|
* `disabled:` variant only matches the native attribute, which `unavailable`
|
||||||
|
* deliberately does not set. Keep the two lists in step.
|
||||||
*/
|
*/
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
danger:
|
danger:
|
||||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
||||||
ghost:
|
ghost:
|
||||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -35,16 +51,30 @@ export default function Button({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
|
unavailable = false,
|
||||||
|
unavailableReason = "",
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { controlProps, reasonNode } = useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason: unavailableReason,
|
||||||
|
onClick: rest.onClick,
|
||||||
|
onKeyDown: rest.onKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type={type}
|
<button
|
||||||
{...rest}
|
type={type}
|
||||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
{...rest}
|
||||||
>
|
{...controlProps}
|
||||||
{children}
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||||
</button>
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{/* Outside the button: inside, the reason would join its accessible name. */}
|
||||||
|
{reasonNode}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
useId,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/** Keys a native `<button>` turns into a click. */
|
||||||
|
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
||||||
|
|
||||||
|
export interface UnavailableControlProps {
|
||||||
|
"aria-disabled"?: true;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnavailableControl {
|
||||||
|
/** Spread onto the control. Carries the guarded handlers. */
|
||||||
|
controlProps: UnavailableControlProps;
|
||||||
|
/**
|
||||||
|
* Render as a *sibling* of the control — inside it the reason would be
|
||||||
|
* appended to the accessible name instead of the description.
|
||||||
|
*/
|
||||||
|
reasonNode: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a control unavailable without hiding it from assistive technology.
|
||||||
|
*
|
||||||
|
* `disabled` takes an element out of the tab order *and* out of the
|
||||||
|
* accessibility tree, so the `title` explaining why it cannot be used is
|
||||||
|
* announced to nobody and shown only to a sighted user with a mouse. That is
|
||||||
|
* backwards: the people who most need the reason are the ones who never get
|
||||||
|
* it. `aria-disabled` keeps the control focusable and announced, and
|
||||||
|
* `aria-describedby` hands over the reason.
|
||||||
|
*
|
||||||
|
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
||||||
|
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
||||||
|
* along with the attributes, so a call site cannot take the announcement
|
||||||
|
* without the guard. Handlers that a form can reach without going through the
|
||||||
|
* control (Enter inside a text field submits the form) still have to guard
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
export function useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: {
|
||||||
|
unavailable: boolean;
|
||||||
|
reason: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}): UnavailableControl {
|
||||||
|
const reasonId = `${useId()}unavailable`;
|
||||||
|
|
||||||
|
if (!unavailable) {
|
||||||
|
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
controlProps: {
|
||||||
|
"aria-disabled": true,
|
||||||
|
"aria-describedby": reasonId,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
if (!ACTIVATION_KEYS.has(e.key)) {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Suppress the default action before it can become a click, submit a
|
||||||
|
// form, or scroll the page.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reasonNode: (
|
||||||
|
<span id={reasonId} className="sr-only">
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// The queue lives at module scope in useTerminal, so the command layer is
|
||||||
|
// mocked and the hook's `sendInput` is exercised through `renderHook`.
|
||||||
|
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
|
||||||
|
openTerminalSession: vi.fn(),
|
||||||
|
closeTerminalSession: vi.fn(),
|
||||||
|
terminalResize: vi.fn(),
|
||||||
|
pasteImageToTerminal: vi.fn(),
|
||||||
|
updateProject: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
|
||||||
|
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
|
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
|
||||||
|
|
||||||
|
describe("useTerminal input ordering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
terminalInput.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves order even when the underlying invokes resolve out of order", async () => {
|
||||||
|
// Make the *first* call the slowest, which is exactly the race that put a
|
||||||
|
// backspace behind the characters typed after it.
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
terminalInput.mockImplementation(
|
||||||
|
() => new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
const first = result.current.sendInput("s1", "\x7f"); // backspace
|
||||||
|
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
|
||||||
|
|
||||||
|
// Only one write may be in flight at a time.
|
||||||
|
expect(terminalInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
|
||||||
|
|
||||||
|
resolvers.shift()!();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
// The three queued keystrokes coalesce into one ordered write.
|
||||||
|
expect(terminalInput).toHaveBeenCalledTimes(2);
|
||||||
|
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
|
||||||
|
|
||||||
|
resolvers.shift()!();
|
||||||
|
await Promise.all(rest);
|
||||||
|
|
||||||
|
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
|
||||||
|
expect(sent).toBe("\x7fabc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles each caller's promise and does not drop later writes on failure", async () => {
|
||||||
|
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
|
||||||
|
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate sessions independent", async () => {
|
||||||
|
terminalInput.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
result.current.sendInput("a", "1"),
|
||||||
|
result.current.sendInput("b", "2"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
|
||||||
|
expect(bySession).toContainEqual(["a", "1"]);
|
||||||
|
expect(bySession).toContainEqual(["b", "2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session ordered write queue.
|
||||||
|
*
|
||||||
|
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
||||||
|
* that command is `async` on the Rust side Tauri spawns each one as an
|
||||||
|
* independent task. Those tasks then race for the session mutex in
|
||||||
|
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
||||||
|
* were typed in — the visible symptom was a backspace landing *after* the
|
||||||
|
* characters typed behind it. The serial writer task downstream cannot help,
|
||||||
|
* because the order is already lost by the time anything reaches the channel.
|
||||||
|
*
|
||||||
|
* The queue restores ordering the same way the web terminal gets it for free:
|
||||||
|
* one write in flight at a time, the next only after the previous resolves.
|
||||||
|
* Anything typed while a write is in flight coalesces into the next chunk,
|
||||||
|
* which also collapses a burst of typing into a couple of IPC round trips
|
||||||
|
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
||||||
|
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
||||||
|
* caller's promise still settles only when its own bytes have gone, so
|
||||||
|
* `await sendInput(...)` keeps the meaning it had.
|
||||||
|
*
|
||||||
|
* Module scope, not hook scope, because `useTerminal()` is called from several
|
||||||
|
* components (App for speech-to-text, TerminalView for typing and image paste,
|
||||||
|
* useProjectActions for tile commands). A per-hook queue would give each caller
|
||||||
|
* its own ordering and leave them racing against each other.
|
||||||
|
*/
|
||||||
|
type PendingWrite = {
|
||||||
|
bytes: number[];
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
||||||
|
|
||||||
|
async function drainInputQueue(sessionId: string): Promise<void> {
|
||||||
|
const q = inputQueues.get(sessionId);
|
||||||
|
if (!q || q.draining) return;
|
||||||
|
|
||||||
|
q.draining = true;
|
||||||
|
try {
|
||||||
|
while (q.pending.length > 0) {
|
||||||
|
// Take everything queued so far as one batch, preserving order.
|
||||||
|
const batch = q.pending.splice(0, q.pending.length);
|
||||||
|
const bytes = batch.flatMap((w) => w.bytes);
|
||||||
|
try {
|
||||||
|
await commands.terminalInput(sessionId, bytes);
|
||||||
|
batch.forEach((w) => w.resolve());
|
||||||
|
} catch (err) {
|
||||||
|
// Reject only the writes in this batch. Anything queued while it was
|
||||||
|
// in flight is still pending and gets its own attempt on the next lap.
|
||||||
|
batch.forEach((w) => w.reject(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
q.draining = false;
|
||||||
|
// Drop the entry once idle so closed sessions do not accumulate.
|
||||||
|
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let q = inputQueues.get(sessionId);
|
||||||
|
if (!q) {
|
||||||
|
q = { pending: [], draining: false };
|
||||||
|
inputQueues.set(sessionId, q);
|
||||||
|
}
|
||||||
|
q.pending.push({ bytes, resolve, reject });
|
||||||
|
void drainInputQueue(sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop any queued input for a session that is going away. */
|
||||||
|
function discardInputQueue(sessionId: string): void {
|
||||||
|
const q = inputQueues.get(sessionId);
|
||||||
|
if (!q) return;
|
||||||
|
const dropped = q.pending.splice(0, q.pending.length);
|
||||||
|
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
||||||
|
if (!q.draining) inputQueues.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||||
useAppState(
|
useAppState(
|
||||||
@@ -33,6 +113,7 @@ export function useTerminal() {
|
|||||||
const session = currentSessions.find((s) => s.id === sessionId);
|
const session = currentSessions.find((s) => s.id === sessionId);
|
||||||
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
||||||
|
|
||||||
|
discardInputQueue(sessionId);
|
||||||
await commands.closeTerminalSession(sessionId);
|
await commands.closeTerminalSession(sessionId);
|
||||||
removeSession(sessionId);
|
removeSession(sessionId);
|
||||||
|
|
||||||
@@ -54,7 +135,7 @@ export function useTerminal() {
|
|||||||
const sendInput = useCallback(
|
const sendInput = useCallback(
|
||||||
async (sessionId: string, data: string) => {
|
async (sessionId: string, data: string) => {
|
||||||
const bytes = Array.from(new TextEncoder().encode(data));
|
const bytes = Array.from(new TextEncoder().encode(data));
|
||||||
await commands.terminalInput(sessionId, bytes);
|
await enqueueInput(sessionId, bytes);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
|
||||||
|
|
||||||
|
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||||
|
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||||
|
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
|
||||||
|
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
|
||||||
|
|
||||||
|
describe("isLinuxWebview", () => {
|
||||||
|
it("recognises desktop Linux", () => {
|
||||||
|
expect(isLinuxWebview(LINUX)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count Android as desktop Linux", () => {
|
||||||
|
expect(isLinuxWebview(ANDROID)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the other desktop platforms", () => {
|
||||||
|
expect(isLinuxWebview(MAC)).toBe(false);
|
||||||
|
expect(isLinuxWebview(WINDOWS)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTerminalGpuRendering", () => {
|
||||||
|
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
|
||||||
|
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto is on elsewhere", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
|
||||||
|
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an explicit setting wins on every platform", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
|
||||||
|
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Decides whether the terminal loads `@xterm/addon-webgl`.
|
||||||
|
*
|
||||||
|
* Split out of `TerminalView` so it can be unit-tested without standing up a
|
||||||
|
* terminal, and so the platform rule lives in exactly one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
|
||||||
|
export function isLinuxWebview(userAgent: string): boolean {
|
||||||
|
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective WebGL setting.
|
||||||
|
*
|
||||||
|
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
|
||||||
|
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux —
|
||||||
|
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
|
||||||
|
* which leaves WebGL present but software-rasterised, so loading the addon is
|
||||||
|
* slower than the canvas renderer it would otherwise have fallen back to.
|
||||||
|
*/
|
||||||
|
export function resolveTerminalGpuRendering(
|
||||||
|
setting: boolean | null | undefined,
|
||||||
|
userAgent: string,
|
||||||
|
): boolean {
|
||||||
|
if (typeof setting === "boolean") return setting;
|
||||||
|
return !isLinuxWebview(userAgent);
|
||||||
|
}
|
||||||
@@ -290,6 +290,12 @@ export interface AppSettings {
|
|||||||
stt: SttSettings;
|
stt: SttSettings;
|
||||||
gateway: GatewaySettings;
|
gateway: GatewaySettings;
|
||||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||||
|
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
|
||||||
|
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
|
||||||
|
* backed by software rasterisation and the addon ends up slower than the
|
||||||
|
* canvas renderer it would otherwise fall back to. See
|
||||||
|
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||||
|
terminal_gpu_rendering: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What `preview_settings_import` returns before anything is applied —
|
/** What `preview_settings_import` returns before anything is applied —
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# Maintainer: Triple-C Contributors
|
|
||||||
#
|
|
||||||
# This file is regenerated by .gitea/workflows/publish-arch-package.yml on every
|
|
||||||
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
|
|
||||||
# already-uploaded release asset, never guessed. Editing pkgver/source/
|
|
||||||
# sha256sums by hand here only matters until the next automated run overwrites
|
|
||||||
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
|
|
||||||
# maintained normally.
|
|
||||||
#
|
|
||||||
# "-bin" rather than building from source: this repackages the same .deb
|
|
||||||
# build-app.yml already produces and publishes, so a user gets exactly the
|
|
||||||
# binary the project ships and tests, and `makepkg` never needs a Rust
|
|
||||||
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
|
|
||||||
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
|
|
||||||
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
|
|
||||||
# with the installing system's — true for essentially every currently
|
|
||||||
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
|
|
||||||
# ships, and forward compatibility is the direction that holds.
|
|
||||||
pkgname=triple-c-bin
|
|
||||||
pkgver=0.4.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Sandbox Claude Code inside Docker containers"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/shadowdao/triple-c"
|
|
||||||
license=('MIT')
|
|
||||||
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
|
|
||||||
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
|
|
||||||
# Arch container, and re-ran `ldd` on the actual binary until nothing came
|
|
||||||
# back "not found". `pango` and `libayatana-appindicator` were both in an
|
|
||||||
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
|
|
||||||
# transitively, and namcap correctly flags declaring it as redundant), and
|
|
||||||
# libayatana-appindicator is in Tauri's own linux dependency list but this
|
|
||||||
# binary never links it at all: there is no tray icon or menu in this app
|
|
||||||
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
|
|
||||||
# same reason), so it was never a real dependency to begin with.
|
|
||||||
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
|
|
||||||
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
|
|
||||||
optdepends=('docker: to actually run the sandboxed containers'
|
|
||||||
'xdg-utils: opening links from the app in your default browser')
|
|
||||||
provides=('triple-c')
|
|
||||||
conflicts=('triple-c')
|
|
||||||
# !strip: the upstream .deb's binary is already the release build Tauri
|
|
||||||
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
|
|
||||||
# no benefit. It's also what actually suppresses makepkg's debug-package
|
|
||||||
# machinery here (debug-package extraction requires strip; verified in a
|
|
||||||
# real build — with !strip alone, no debug package is produced at all).
|
|
||||||
# !debug is kept anyway, explicit about intent rather than relying on that
|
|
||||||
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
|
|
||||||
# tree containing a dangling .build-id symlink, which is a real namcap
|
|
||||||
# error (not just the empty-directory warning it looks like) — there is no
|
|
||||||
# debug info in this release binary for the machinery to have extracted in
|
|
||||||
# the first place.
|
|
||||||
options=('!strip' '!debug')
|
|
||||||
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
|
|
||||||
# lowercase Cargo binary name — verified against the real release, not
|
|
||||||
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
|
|
||||||
# because the .deb itself carries no license file — namcap flags an MIT
|
|
||||||
# package with nothing under /usr/share/licenses/ as an error, correctly.
|
|
||||||
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
|
|
||||||
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
|
|
||||||
sha256sums=('SKIP'
|
|
||||||
'SKIP')
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "$srcdir"
|
|
||||||
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
|
|
||||||
# (part of base-devel's binutils) pulls just the payload out. Extracting
|
|
||||||
# that tar directly into $pkgdir works here with no path rewriting at all:
|
|
||||||
# verified against the real archive, whose entire payload is
|
|
||||||
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
|
|
||||||
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
|
|
||||||
# this app carries no separate resource directory under usr/lib/, so there
|
|
||||||
# is nothing that could disagree between Debian's and Arch's package trees
|
|
||||||
# for it to land in the wrong place.
|
|
||||||
#
|
|
||||||
# Globbed rather than named literally: the publish workflow discovers the
|
|
||||||
# real asset name from the release itself specifically so a Tauri bundler
|
|
||||||
# naming change can't silently break this — naming the file again here
|
|
||||||
# would throw that away and fail this one line with an opaque "No such
|
|
||||||
# file or directory" instead. `source=()` above guarantees exactly one
|
|
||||||
# `*_amd64.deb` entry, so the glob can only ever match that one file.
|
|
||||||
ar x ./*_amd64.deb
|
|
||||||
tar xf data.tar.* -C "$pkgdir"
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Arch / CachyOS package
|
|
||||||
|
|
||||||
`PKGBUILD` here is the `triple-c-bin` package's template — see triple-c#34
|
|
||||||
(the "I would like to also have an Arch/CachyOS native version" part of it).
|
|
||||||
It's written to AUR conventions (and may go there eventually — see
|
|
||||||
"Publishing" below) but isn't published to the AUR yet.
|
|
||||||
|
|
||||||
## Why "-bin"
|
|
||||||
|
|
||||||
It repackages the same `.deb` `build-app.yml` already produces, rather than
|
|
||||||
building from source. That means `makepkg` never needs a Rust toolchain,
|
|
||||||
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
|
|
||||||
itself — and a user gets exactly the binary the project ships and tests,
|
|
||||||
built on Ubuntu 24.04 in CI. Verified end to end against a real release
|
|
||||||
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
|
|
||||||
against a real `ldd` of the actual binary (two packages that looked right
|
|
||||||
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
|
|
||||||
to be real dependencies of *this* binary and were dropped), and ran a real
|
|
||||||
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
`.gitea/workflows/publish-arch-package.yml` does the actual work: given a
|
|
||||||
version (or "latest" if none is given), it finds that release's real Linux
|
|
||||||
asset on GitHub, downloads it, computes real checksums, renders this
|
|
||||||
template into a version-specific PKGBUILD, validates it with `makepkg` and
|
|
||||||
`namcap` inside a real Arch container, and attaches the resulting
|
|
||||||
`.pkg.tar.zst` to that same GitHub release as a downloadable asset —
|
|
||||||
installable by hand with `sudo pacman -U`.
|
|
||||||
|
|
||||||
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
|
|
||||||
header comment for why an automatic trigger isn't safe here (the same reason
|
|
||||||
`sync-release.yml` didn't work and was removed in triple-c#32).
|
|
||||||
|
|
||||||
**Not on the AUR yet.** Publishing there would need a maintainer AUR account
|
|
||||||
and its SSH key added as a secret on this repo — both manual, one-time steps
|
|
||||||
on https://aur.archlinux.org that only a maintainer can do. The workflow's
|
|
||||||
git history still has the AUR-push step from before this was descoped, if
|
|
||||||
that setup happens later and it's worth reinstating.
|
|
||||||
|
|
||||||
## What's hand-maintained vs. generated
|
|
||||||
|
|
||||||
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
|
|
||||||
the workflow rewrites them for every real publish and never commits the
|
|
||||||
result back here, so don't read this file's `pkgver` as "the last published
|
|
||||||
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
|
|
||||||
edited by hand normally, the same as any other PKGBUILD.
|
|
||||||
|
|
||||||
**A hand-edit made to the rendered PKGBUILD attached to a GitHub release is
|
|
||||||
not this file.** Every run renders fresh from *this* repo's template, so a
|
|
||||||
packaging fix belongs here, not in a downloaded copy — the next dispatch for
|
|
||||||
that version would just overwrite it anyway.
|
|
||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Register a Triple-C AppImage with the desktop, so it appears in the app
|
||||||
|
# launcher with its icon instead of only being runnable from a file manager.
|
||||||
|
#
|
||||||
|
# An AppImage is a single executable file and nothing more: it ships a
|
||||||
|
# `.desktop` entry and icons *inside* itself, but nothing on the host ever
|
||||||
|
# reads them, because nothing installed it. This script does what a package
|
||||||
|
# manager's install hooks would — copies the icons into the user's icon theme
|
||||||
|
# and writes a `.desktop` entry pointing at wherever the AppImage actually
|
||||||
|
# lives.
|
||||||
|
#
|
||||||
|
# ./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||||
|
# ./scripts/install-appimage.sh --uninstall
|
||||||
|
#
|
||||||
|
# Everything goes under ~/.local/share, so there is no sudo and no root-owned
|
||||||
|
# file to clean up later. The AppImage itself is never copied or moved — the
|
||||||
|
# launcher entry points at the path you give here, so keep the file somewhere
|
||||||
|
# stable (`~/Apps` or `~/.local/bin`, not `~/Downloads`) or re-run this after
|
||||||
|
# moving it.
|
||||||
|
#
|
||||||
|
# Extraction uses `--appimage-extract`, which unpacks the payload directly and
|
||||||
|
# needs no FUSE. So this script works even on a system where *running* the
|
||||||
|
# AppImage would need `fuse2` installed first.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_ID="triple-c"
|
||||||
|
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||||
|
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
|
||||||
|
DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop"
|
||||||
|
|
||||||
|
refresh_caches() {
|
||||||
|
# Both are best-effort: a minimal desktop may ship neither, and neither
|
||||||
|
# failing means the install did not work.
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f -t "${ICON_DIR}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall() {
|
||||||
|
rm -f "${DESKTOP_FILE}"
|
||||||
|
find "${ICON_DIR}" -name "${APP_ID}.png" -delete 2>/dev/null || true
|
||||||
|
refresh_caches
|
||||||
|
echo "Removed the Triple-C launcher entry and icons."
|
||||||
|
echo "The AppImage itself was not touched."
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--uninstall" ]; then
|
||||||
|
uninstall
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APPIMAGE="${1:-}"
|
||||||
|
if [ -z "${APPIMAGE}" ]; then
|
||||||
|
echo "usage: $0 [--uninstall] /path/to/Triple-C_<version>_amd64.AppImage" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -f "${APPIMAGE}" ]; then
|
||||||
|
echo "No such file: ${APPIMAGE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An absolute path, because the .desktop Exec line is read from anywhere.
|
||||||
|
APPIMAGE=$(cd "$(dirname "${APPIMAGE}")" && printf '%s/%s' "$(pwd)" "$(basename "${APPIMAGE}")")
|
||||||
|
|
||||||
|
if [ ! -x "${APPIMAGE}" ]; then
|
||||||
|
echo "Making ${APPIMAGE} executable"
|
||||||
|
chmod +x "${APPIMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
# shellcheck disable=SC2064 # WORK is expanded now on purpose.
|
||||||
|
trap "rm -rf '${WORK}'" EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Extracting bundled icons from $(basename "${APPIMAGE}")..."
|
||||||
|
( cd "${WORK}" && "${APPIMAGE}" --appimage-extract >/dev/null )
|
||||||
|
|
||||||
|
SRC="${WORK}/squashfs-root"
|
||||||
|
if [ ! -d "${SRC}/usr/share/icons/hicolor" ]; then
|
||||||
|
echo "That AppImage has no bundled icons — is it really Triple-C?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy every size the bundle ships, keeping the theme's directory layout.
|
||||||
|
COUNT=0
|
||||||
|
while IFS= read -r icon; do
|
||||||
|
[ -n "${icon}" ] || continue
|
||||||
|
rel=${icon#"${SRC}/usr/share/icons/hicolor/"}
|
||||||
|
install -Dm644 "${icon}" "${ICON_DIR}/${rel}"
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
|
done <<EOF
|
||||||
|
$(find "${SRC}/usr/share/icons/hicolor" -name "${APP_ID}.png")
|
||||||
|
EOF
|
||||||
|
echo "Installed ${COUNT} icon size(s) into ${ICON_DIR}"
|
||||||
|
|
||||||
|
# Written rather than copied from the bundle. The bundled entry has
|
||||||
|
# `Exec=triple-c`, which resolves only inside the running AppImage's own mount
|
||||||
|
# — from the host it names a binary that is not on PATH, so the launcher entry
|
||||||
|
# would appear and then fail to start anything. `Categories` is empty in the
|
||||||
|
# bundle too, which leaves the entry to fall into "Other" in most menus.
|
||||||
|
# `StartupWMClass` is kept exactly as the bundle sets it: it is what lets the
|
||||||
|
# shell match the running window to this entry, so the taskbar shows the real
|
||||||
|
# icon instead of a generic placeholder.
|
||||||
|
mkdir -p "${DESKTOP_DIR}"
|
||||||
|
cat > "${DESKTOP_FILE}" <<DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Triple-C
|
||||||
|
Comment=Run Claude Code sandboxed in Docker containers
|
||||||
|
Exec=${APPIMAGE} %U
|
||||||
|
Icon=${APP_ID}
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;
|
||||||
|
StartupWMClass=${APP_ID}
|
||||||
|
DESKTOP
|
||||||
|
chmod 644 "${DESKTOP_FILE}"
|
||||||
|
echo "Wrote ${DESKTOP_FILE}"
|
||||||
|
|
||||||
|
refresh_caches
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Triple-C should now be in your app launcher."
|
||||||
|
echo "If the icon is generic or the entry is missing, log out and back in —"
|
||||||
|
echo "see \"App Icon Missing After Installing (Linux)\" in HOW-TO-USE.md."
|
||||||
Reference in New Issue
Block a user