Compare commits

...
13 Commits
Author SHA1 Message Date
jknapp b24807bd5f Merge pull request 'Keep disabled controls in the accessibility tree so their reason is announced' (#49) from fix/disabled-control-accessibility into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m55s
Build App / build-linux (push) Successful in 5m20s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #49
2026-09-02 18:53:20 +00:00
shadowdaoandClaude Opus 5 1eb91a35eb Give the terminal and Add Project buttons a reason a screen reader can hear
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.

The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:57 -07:00
shadowdaoandClaude Opus 5 aa0a574091 Announce unavailable controls instead of hiding them behind disabled
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.

`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:50 -07:00
jknapp ed1dc8502c Merge pull request 'Retire the Arch package, document AppImage desktop integration' (#47) from chore/retire-arch-packaging into main
Secret Scan / scan (push) Successful in 5s
2026-08-28 20:21:01 +00:00
jknapp bd08ce8be2 Merge pull request 'Fix terminal input reordering and Linux terminal rendering' (#46) from fix/terminal-input-ordering-and-linux-rendering into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m43s
Build App / build-windows (push) Successful in 4m56s
Build App / build-linux (push) Successful in 5m28s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
2026-08-28 20:20:54 +00:00
shadowdaoandClaude Opus 5 7a5c0c1f13 Retire the Arch package, document AppImage desktop integration
Secret Scan / scan (push) Successful in 8s
Secret Scan / scan (pull_request) Successful in 8s
The `triple-c-bin` package was never on the AUR, so installing it meant
downloading a file and running `pacman -U` — the same gesture as making an
AppImage executable, for a second artifact to keep building. And being
`workflow_dispatch`-only it reached 1 release in 28 (only v0.4.16 has a
`.pkg.tar.zst`), while HOW-TO-USE.md told Arch and CachyOS users to download
it from every release. A distribution channel that is absent 27 times out of
28 is worse than not promising one.

`packaging/arch/` and `.gitea/workflows/publish-arch-package.yml` are
preserved whole on `hold/arch-packaging`, the same way the disk panel and
drag-out work were held rather than deleted. What would make an Arch package
worth having is an AUR account and its SSH key as a repo secret — both
one-time manual steps that never happened; the workflow's own header already
said as much about its AUR push step.

This also closes the gap that prompted the review: nothing validated the
PKGBUILD until someone manually dispatched the workflow, making it the only
packaging path with no CI coverage. Removing it removes the untested surface
rather than adding a job to test something nobody installs.

In its place, `scripts/install-appimage.sh` does what a package manager's
install hooks would. An AppImage carries a `.desktop` entry and icons inside
itself, but nothing on the host reads them, so it never appears in the app
launcher. The script extracts the bundled icons into the user's icon theme
and writes a launcher entry — no sudo, nothing outside `~/.local/share`, and
the AppImage itself is never copied or moved.

Two details it gets right on purpose:

  * The `Exec` line is rewritten, not copied. The bundled entry says
    `Exec=triple-c`, which resolves only inside the running AppImage's own
    mount — a verbatim copy gives a launcher entry that starts nothing.
  * Extraction uses `--appimage-extract`, which needs no FUSE, so the script
    works on a machine where *running* the AppImage would first need
    `fuse2` installed. That requirement is now documented too: Arch and
    CachyOS do not ship FUSE 2 by default.

Verified against the real artifact — the AppImage from this repo's own
preview-3a49a67 release: 4 icon sizes install, `desktop-file-validate` passes
with no warnings, `--uninstall` leaves nothing behind, and shellcheck is
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
2026-08-28 13:11:26 -07:00
shadowdaoandClaude Opus 5 3a49a67c1f Fix terminal input reordering and Linux terminal rendering
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m25s
Build App (Preview) / build-windows (pull_request) Successful in 5m32s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.

The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.

**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.

`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.

**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.

**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. 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 and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.

Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
2026-08-28 12:51:18 -07:00
jknapp 88d6bed6db Merge pull request 'Document the Wayland icon-cache-needs-relogin gotcha' (#45) from docs/wayland-icon-cache-note into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 23:15:00 +00:00
shadow-test 6cc48b3266 Document the Wayland icon-cache-needs-relogin gotcha
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
A user hit this after installing the new Arch/CachyOS package (triple-c#34):
icon missing in the app menu, taskbar, and titlebar alike, with no error
in the app's own log. Root cause has nothing to do with the app or its
packaging — GNOME/KDE cache the installed-app list and resolved icons in
the shell process's memory at startup, and Wayland has no equivalent to
X11's soft shell-restart trick to force a live reload. Logging out and
back in fixed it for them.
2026-08-27 15:53:19 -07:00
jknapp 0fad306c25 Merge pull request 'Add an Installation section to HOW-TO-USE.md' (#43) from docs/installation-instructions into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 22:37:19 +00:00
jknapp 8beb62b12c Merge pull request 'Mirror the Arch package to the Gitea release too' (#44) from fix/arch-package-mirror-to-gitea into main
Secret Scan / scan (push) Successful in 4s
2026-08-27 22:21:58 +00:00
shadow-test f2cfc0be8f Also attach the Arch package to the matching Gitea release
Secret Scan / scan (push) Successful in 10s
Secret Scan / scan (pull_request) Successful in 7s
The workflow only ever uploaded to the GitHub release — the Gitea release
for the same version (the plain, unsuffixed vX.Y.Z tag build-app.yml's
Linux job creates, which already holds the .deb/.rpm/.AppImage) never got
it, so it looked missing to anyone checking releases on Gitea instead of
GitHub.

New step mirrors build-app.yml's own Gitea upload step exactly: same
get-or-create-by-tag, delete-existing-asset, upload-as-octet-stream shape,
same REGISTRY_TOKEN secret. Verified the read side (release lookup, asset
listing) against the real v0.4.16 release before writing this — resolves
to the correct release id and correctly finds no existing asset yet.
2026-08-27 15:14:54 -07:00
shadow-test 99c9dd3cc2 Add an Installation section — nothing told a new user how to get the app
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
HOW-TO-USE.md's Prerequisites jumped straight to Docker and a Claude Code
account, assuming Triple-C was already installed; the app itself had no
download/install instructions anywhere in the docs. Covers all six release
assets, including the new Arch/CachyOS .pkg.tar.zst (triple-c#34) that
publish-arch-package.yml now attaches to each release.
2026-08-27 15:06:43 -07:00
23 changed files with 1025 additions and 484 deletions
-298
View File
@@ -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}"
+20
View File
@@ -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`
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
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+64
View File
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
## Table of Contents
- [Installation](#installation)
- [Prerequisites](#prerequisites)
- [First Launch](#first-launch)
- [The Interface](#the-interface)
@@ -32,6 +33,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
### 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.
### 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.
-6
View File
@@ -418,12 +418,6 @@ triple-c/
│ ├── build-stt.yml # Build the STT image
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
│ ├── cleanup-releases.yml # Prune old releases
│ └── publish-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
├── package.json # React, xterm.js, zustand, tailwindcss
+90 -8
View File
@@ -26,12 +26,27 @@
/// their own init time, which happens inside the Tauri builder that
/// function calls into, not at binary load.
///
/// A user who has already set this themselves is left alone. That includes
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
/// rather than presence-only — not verified against WebKitGTK's own source,
/// so if it turns out to be presence-only, `=0` still reads as "set" here
/// and disables DMA-BUF the same as any other value, which is at least the
/// safe direction to be wrong in.
/// A user who has already set this themselves is left alone — with one
/// correction. The earlier version of this function left *any* pre-set value
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
/// at all a user could set to get the accelerated path back: the escape hatch
/// the comment described did not exist. `0`, `false` and empty are now treated
/// as an explicit opt-out and the variable is *removed*, which is the only
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
/// means disabled on Linux, so nobody who was not deliberately overriding this
/// sees any difference.
///
/// That matters more than it looks, because the trade described above is not
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
/// the canvas renderer here: its constructor throws only when WebGL is
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
/// software rasterisation. So the addon loads happily and every terminal frame
/// is rendered on the CPU and copied, which is slower than the canvas renderer
/// this comment assumed it would degrade to, not faster. See
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
/// whether the addon is loaded at all.
///
/// This env var also leaks to whatever the app spawns afterwards — notably
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
@@ -39,10 +54,77 @@
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause.
#[cfg(target_os = "linux")]
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
/// already set to. Split from the mutation so it can be tested without
/// touching process-wide environment state from a parallel test runner.
#[cfg(target_os = "linux")]
#[derive(Debug, PartialEq, Eq)]
enum DmabufAction {
/// Not set by the user — apply the workaround.
Disable,
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
/// way to express "enabled" is for the variable not to exist.
Remove,
/// Set to something meaning "disabled". Already what we want; leave it.
LeaveAlone,
}
#[cfg(target_os = "linux")]
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
match current {
None => DmabufAction::Disable,
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" => DmabufAction::Remove,
_ => DmabufAction::LeaveAlone,
},
}
}
#[cfg(target_os = "linux")]
fn apply_webkit_wayland_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
let current = std::env::var(DMABUF_VAR).ok();
match dmabuf_action(current.as_deref()) {
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
DmabufAction::LeaveAlone => {}
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::{dmabuf_action, DmabufAction};
#[test]
fn unset_gets_the_workaround() {
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
}
#[test]
fn falsey_values_opt_out_by_removing_the_variable() {
// The bug this replaces: these all previously read as "user set it,
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
// it only checks presence. There was no way to ask for the GPU path.
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
assert_eq!(
dmabuf_action(Some(value)),
DmabufAction::Remove,
"{value:?} should opt out"
);
}
}
#[test]
fn other_values_are_left_alone() {
for value in ["1", "true", "yes", "anything"] {
assert_eq!(
dmabuf_action(Some(value)),
DmabufAction::LeaveAlone,
"{value:?} should be left alone"
);
}
}
}
+21
View File
@@ -135,6 +135,26 @@ pub struct AppSettings {
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
/// Whether the terminal loads `@xterm/addon-webgl`.
///
/// `None` is "auto", and auto is not the same answer on every platform.
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
/// does not remove WebGL — it leaves it backed by software rasterisation.
/// The addon therefore loads successfully and then renders every frame on
/// the CPU, which is slower than the canvas renderer it would otherwise
/// have fallen back to. So auto means enabled on macOS and Windows, and
/// disabled on Linux.
///
/// `Some(true)` / `Some(false)` force it either way on any platform. A
/// Linux user running X11, or one whose driver stack is unaffected, can
/// turn it back on; anyone seeing terminal lag can turn it off without
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
/// the zero value has to mean "we choose", not "off", or every existing
/// settings file would silently pin the answer at whatever the default was
/// the day it was written.
#[serde(default)]
pub terminal_gpu_rendering: Option<bool>,
}
fn default_stt_model() -> String {
@@ -226,6 +246,7 @@ impl Default for AppSettings {
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
global_claude_code_settings: None,
terminal_gpu_rendering: None,
}
}
}
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import AddProjectDialog from "./AddProjectDialog";
const add = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ add }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => null),
}));
/** A promise whose resolution this test controls, so `loading` can be held open. */
function deferred() {
let resolve!: (v: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function fillValidForm() {
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "my-project" },
});
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
target: { value: "/home/user/my-project" },
});
}
function submitButton() {
return screen.getByRole("button", { name: /Add Project|Adding/ });
}
describe("AddProjectDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds the project with the name and folder entered", async () => {
add.mockResolvedValue({ id: "p1" });
const onClose = vi.fn();
render(<AddProjectDialog onClose={onClose} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(add).toHaveBeenCalledWith("my-project", [
{ host_path: "/home/user/my-project", mount_name: "my-project" },
]),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps the submit button announced, and explains why, while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
// Native `disabled` would remove the button from the accessibility tree
// exactly when it has something to say.
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
expect(submitButton()).not.toBeDisabled();
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
await act(async () => resolve({ id: "p1" }));
});
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
fireEvent.click(submitButton());
fireEvent.keyDown(submitButton(), { key: "Enter" });
fireEvent.keyDown(submitButton(), { key: " " });
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("ignores a form submit raised from elsewhere while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
// Enter in a text field submits a form regardless of the submit button's
// state, so the handler has to guard itself too.
// Modal portals to document.body, so the form is not under `container`.
const form = document.querySelector("form");
expect(form).not.toBeNull();
fireEvent.submit(form!);
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("leaves the submit button plainly available when idle", () => {
render(<AddProjectDialog onClose={vi.fn()} />);
expect(submitButton()).not.toHaveAttribute("aria-disabled");
expect(submitButton()).toHaveAccessibleDescription("");
});
});
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
// The submit button is `aria-disabled` rather than `disabled` while an add
// is in flight, and Enter inside a text field submits the form without
// touching the button at all. Both routes end here, so the guard does too.
if (loading) return;
if (!name.trim()) {
setError("Project name is required");
return;
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
<Button size="md" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
<Button
size="md"
variant="primary"
type="submit"
form={formId}
unavailable={loading}
unavailableReason="The project is being added. Wait for it to finish."
title={
loading
? "The project is being added. Wait for it to finish."
: undefined
}
>
{loading ? "Adding…" : "Add Project"}
</Button>
</>
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
});
it("only allows opening a terminal while the container runs", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
expect(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
).toBeDisabled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click(
screen.getByRole("button", {
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
});
it("keeps the terminal button announced, and explains why, while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
// Native `disabled` would drop the button out of the accessibility tree
// and out of the tab order, taking the reason with it.
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/is not running/i);
});
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
});
it("drops aria-disabled once the container is running", () => {
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).not.toHaveAccessibleDescription(/is not running/i);
});
it("shows container progress inline rather than in a blocking modal", () => {
setStore({ containerProgress: { "test-1": "Pulling image…" } });
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
+18 -4
View File
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
import { useAppState, homeTabKey } from "../../store/appState";
import { useProjectActions } from "../../hooks/useProjectActions";
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { useUnavailable } from "../ui/unavailable";
interface Props {
project: Project;
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
const isTransitioning =
project.status === "starting" || project.status === "stopping";
// A terminal needs a running container. Saying so out loud beats a `disabled`
// attribute that hides the button — and the reason — from anyone not using a
// mouse and eyes.
const terminal = useUnavailable({
unavailable: !isRunning,
reason: `${project.name} is not running. Start it to open a terminal.`,
onClick: () => openClaudeTerminal(),
});
return (
<div
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
</button>
<button
type="button"
disabled={!isRunning}
onClick={() => openClaudeTerminal()}
title={`Open a Claude terminal for ${project.name}`}
{...terminal.controlProps}
title={
isRunning
? `Open a Claude terminal for ${project.name}`
: `${project.name} is not running. Start it to open a terminal.`
}
aria-label={`Open a Claude terminal for ${project.name}`}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
>
<svg
className="w-3.5 h-3.5"
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
<line x1="13" y1="15" x2="17" y2="15" />
</svg>
</button>
{terminal.reasonNode}
</div>
</div>
);
@@ -15,6 +15,8 @@ import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
import AccordionSection from "../ui/AccordionSection";
import Toggle from "../ui/Toggle";
import SegmentedControl from "../ui/SegmentedControl";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
@@ -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 () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
@@ -242,6 +252,45 @@ export default function SettingsPanel() {
<SttSettings />
</AccordionSection>
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
<div className="space-y-2">
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
<SegmentedControl
label="Terminal GPU rendering"
value={
appSettings?.terminal_gpu_rendering == null
? "auto"
: appSettings.terminal_gpu_rendering
? "on"
: "off"
}
onChange={handleGpuRenderingChange}
segments={[
{
value: "auto",
label: "Auto",
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
? "On for this platform."
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
},
{
value: "on",
label: "On",
hint: "Always load the WebGL renderer.",
},
{
value: "off",
label: "Off",
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
},
]}
/>
<p className="text-xs text-[var(--text-secondary)]">
Takes effect when a terminal tab is next switched to.
</p>
</div>
</AccordionSection>
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
<div className="space-y-2">
{appVersion && (
+26 -9
View File
@@ -28,6 +28,7 @@ import UrlToast, {
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu";
interface Props {
@@ -95,6 +96,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
@@ -491,7 +493,11 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle user input -> backend
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.
@@ -684,7 +690,16 @@ export default function TerminalView({ sessionId, active }: Props) {
const term = termRef.current;
if (!term) return;
if (active) {
// Auto on macOS/Windows, off on Linux, overridable either way — see
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
// WebKitGTK is slower than xterm's canvas renderer, not faster.
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
// The renderer and the activation work are independent: a terminal with
// GPU rendering switched off still has to fit and take focus when its tab
// becomes active. Keeping these in one branch made "GPU off" silently mean
// "never re-fit, never focus".
if (active && useGpu) {
// Attach WebGL renderer
if (!webglRef.current) {
try {
@@ -699,19 +714,21 @@ export default function TerminalView({ sessionId, active }: Props) {
// 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();
if (autoFollowRef.current) {
term.scrollToBottom();
}
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.
// A keyboard user who has just jumped into the toast is mid-decision, and
+77
View File
@@ -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);
});
});
+41 -11
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useUnavailable } from "./unavailable";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md";
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
/**
* Unavailable, but still announced. Renders `aria-disabled` and wires
* `unavailableReason` to `aria-describedby` instead of using the native
* `disabled` attribute, which would take the button out of the tab order and
* out of the accessibility tree reason and all. Clicks and Enter/Space are
* guarded for you. Prefer this over `disabled` whenever there is a reason
* worth telling the user.
*/
unavailable?: boolean;
/** Why the button cannot be used. Required for `unavailable` to say anything. */
unavailableReason?: string;
}
/**
* Real buttons with visible bounds and a 24px hit target.
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
* `--accent` stays reserved for foreground/link use.
*
* The `aria-disabled:` class mirrors below exist because Tailwind's
* `disabled:` variant only matches the native attribute, which `unavailable`
* deliberately does not set. Keep the two lists in step.
*/
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
secondary:
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
danger:
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
ghost:
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
};
const SIZES: Record<ButtonSize, string> = {
@@ -35,16 +51,30 @@ export default function Button({
size = "sm",
className = "",
type = "button",
unavailable = false,
unavailableReason = "",
children,
...rest
}: Props) {
const { controlProps, reasonNode } = useUnavailable({
unavailable,
reason: unavailableReason,
onClick: rest.onClick,
onKeyDown: rest.onKeyDown,
});
return (
<button
type={type}
{...rest}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{children}
</button>
<>
<button
type={type}
{...rest}
{...controlProps}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{children}
</button>
{/* Outside the button: inside, the reason would join its accessible name. */}
{reasonNode}
</>
);
}
+87
View File
@@ -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>
),
};
}
+83
View File
@@ -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"]);
});
});
+82 -1
View File
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
/**
* Per-session ordered write queue.
*
* Every keystroke used to be its own `invoke("terminal_input")`, and because
* that command is `async` on the Rust side Tauri spawns each one as an
* independent task. Those tasks then race for the session mutex in
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
* were typed in the visible symptom was a backspace landing *after* the
* characters typed behind it. The serial writer task downstream cannot help,
* because the order is already lost by the time anything reaches the channel.
*
* The queue restores ordering the same way the web terminal gets it for free:
* one write in flight at a time, the next only after the previous resolves.
* Anything typed while a write is in flight coalesces into the next chunk,
* which also collapses a burst of typing into a couple of IPC round trips
* rather than one per key. Concatenating the byte arrays is safe a PTY
* cannot tell one write of "ab" from writes of "a" then "b" and each
* caller's promise still settles only when its own bytes have gone, so
* `await sendInput(...)` keeps the meaning it had.
*
* Module scope, not hook scope, because `useTerminal()` is called from several
* components (App for speech-to-text, TerminalView for typing and image paste,
* useProjectActions for tile commands). A per-hook queue would give each caller
* its own ordering and leave them racing against each other.
*/
type PendingWrite = {
bytes: number[];
resolve: () => void;
reject: (reason: unknown) => void;
};
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
async function drainInputQueue(sessionId: string): Promise<void> {
const q = inputQueues.get(sessionId);
if (!q || q.draining) return;
q.draining = true;
try {
while (q.pending.length > 0) {
// Take everything queued so far as one batch, preserving order.
const batch = q.pending.splice(0, q.pending.length);
const bytes = batch.flatMap((w) => w.bytes);
try {
await commands.terminalInput(sessionId, bytes);
batch.forEach((w) => w.resolve());
} catch (err) {
// Reject only the writes in this batch. Anything queued while it was
// in flight is still pending and gets its own attempt on the next lap.
batch.forEach((w) => w.reject(err));
}
}
} finally {
q.draining = false;
// Drop the entry once idle so closed sessions do not accumulate.
if (q.pending.length === 0) inputQueues.delete(sessionId);
}
}
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
let q = inputQueues.get(sessionId);
if (!q) {
q = { pending: [], draining: false };
inputQueues.set(sessionId, q);
}
q.pending.push({ bytes, resolve, reject });
void drainInputQueue(sessionId);
});
}
/** Drop any queued input for a session that is going away. */
function discardInputQueue(sessionId: string): void {
const q = inputQueues.get(sessionId);
if (!q) return;
const dropped = q.pending.splice(0, q.pending.length);
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
if (!q.draining) inputQueues.delete(sessionId);
}
export function useTerminal() {
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
useAppState(
@@ -33,6 +113,7 @@ export function useTerminal() {
const session = currentSessions.find((s) => s.id === sessionId);
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
discardInputQueue(sessionId);
await commands.closeTerminalSession(sessionId);
removeSession(sessionId);
@@ -54,7 +135,7 @@ export function useTerminal() {
const sendInput = useCallback(
async (sessionId: string, data: string) => {
const bytes = Array.from(new TextEncoder().encode(data));
await commands.terminalInput(sessionId, bytes);
await enqueueInput(sessionId, bytes);
},
[],
);
+39
View File
@@ -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);
});
});
+28
View File
@@ -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);
}
+6
View File
@@ -290,6 +290,12 @@ export interface AppSettings {
stt: SttSettings;
gateway: GatewaySettings;
global_claude_code_settings: ClaudeCodeSettings | null;
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
* backed by software rasterisation and the addon ends up slower than the
* canvas renderer it would otherwise fall back to. See
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
terminal_gpu_rendering: boolean | null;
}
/** What `preview_settings_import` returns before anything is applied
-86
View File
@@ -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"
}
-52
View File
@@ -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.
+127
View File
@@ -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."