diff --git a/.gitea/workflows/publish-arch-package.yml b/.gitea/workflows/publish-arch-package.yml
deleted file mode 100644
index b9c59f6..0000000
--- a/.gitea/workflows/publish-arch-package.yml
+++ /dev/null
@@ -1,368 +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 attaches
-# the resulting `.pkg.tar.zst` — installable by hand with `pacman -U` — to
-# *both* the GitHub release it was built from and the corresponding Gitea
-# release (the plain, unsuffixed `vX.Y.Z` tag build-app.yml's Linux job
-# creates; the `-win`/`-mac` suffixed Gitea releases are a different tag and
-# don't get this asset). 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
- GITEA_URL: ${{ gitea.server_url }}
- REPO: ${{ gitea.repository }}
-
-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} on GitHub"
-
- - name: Attach the package to the Gitea release
- env:
- TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- TAG: ${{ steps.resolve.outputs.tag }}
- PKG_FILE: ${{ steps.build.outputs.pkg_file }}
- run: |
- set -euo pipefail
-
- # Same get-or-create-by-tag, delete-existing-asset,
- # upload-as-octet-stream shape build-app.yml's own Gitea upload
- # step already uses — this is expected to always hit the "reuse"
- # branch, since build-app.yml's Linux job already created this
- # exact release for this exact tag; the create fallback is here
- # only so this doesn't hard-depend on that ordering.
- HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
- -H "Authorization: token ${TOKEN}" \
- "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
- case "${HTTP_CODE}" in
- 200)
- echo "Release ${TAG} already exists on Gitea, reusing"
- ;;
- 404)
- echo "Creating release ${TAG} on Gitea"
- curl -fsS -X POST \
- -H "Authorization: token ${TOKEN}" \
- -H "Content-Type: application/json" \
- -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\"}" \
- "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
- ;;
- *)
- echo "Unexpected ${HTTP_CODE} looking up release ${TAG} on Gitea:" >&2
- cat release.json >&2
- exit 1
- ;;
- esac
-
- RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json')).get('id',''))")
- if [ -z "${RELEASE_ID}" ]; then
- echo "No Gitea release id for ${TAG}; refusing to upload into nothing:" >&2
- cat release.json >&2
- exit 1
- fi
-
- EXISTING_ID=$(curl -sS \
- -H "Authorization: token ${TOKEN}" \
- "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
- | python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${PKG_FILE}")
- if [ -n "${EXISTING_ID}" ]; then
- echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
- curl -fsS -X DELETE \
- -H "Authorization: token ${TOKEN}" \
- "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
- fi
-
- curl -fsS --http1.1 \
- --retry 5 --retry-all-errors --retry-delay 5 \
- --max-time 600 \
- -X POST \
- -H "Authorization: token ${TOKEN}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary "@rendered/${PKG_FILE}" \
- "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${PKG_FILE}"
-
- echo "Attached ${PKG_FILE} to ${TAG} on Gitea"
diff --git a/CLAUDE.md b/CLAUDE.md
index c03fb36..2092355 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -677,6 +677,26 @@ deliberately out of scope — this is not a project backup.
specifically to make them unmissable — the frontend's `
`/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:
diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md
index 8975bb4..008aebd 100644
--- a/HOW-TO-USE.md
+++ b/HOW-TO-USE.md
@@ -43,12 +43,52 @@ Download the build for your platform from [GitHub Releases](https://github.com/s
| **macOS** | `Triple-C__universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
| **Debian / Ubuntu** | `Triple-C__amd64.deb` | `sudo apt install ./Triple-C__amd64.deb` |
| **Fedora / RHEL** | `Triple-C--1.x86_64.rpm` | `sudo dnf install ./Triple-C--1.x86_64.rpm` |
-| **Arch / CachyOS** | `triple-c-bin--1-x86_64.pkg.tar.zst` | `sudo pacman -U ./triple-c-bin--1-x86_64.pkg.tar.zst` |
-| **Other Linux** | `Triple-C__amd64.AppImage` | `chmod +x` it, then run it directly. |
+| **Arch / CachyOS / other Linux** | `Triple-C__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`.
-> **Arch / CachyOS note:** This package is not on the AUR — it's a `pacman`-installable file built and attached to each GitHub release by a maintainer-triggered step (`.gitea/workflows/publish-arch-package.yml`), so it can lag behind the very latest release by a bit. See [`packaging/arch/README.md`](packaging/arch/README.md) for details, including why "-bin" and what's verified about it.
+> **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
diff --git a/TECHNICAL.md b/TECHNICAL.md
index 7f05c04..c82189d 100644
--- a/TECHNICAL.md
+++ b/TECHNICAL.md
@@ -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
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
deleted file mode 100644
index 4c4405c..0000000
--- a/packaging/arch/PKGBUILD
+++ /dev/null
@@ -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"
-}
diff --git a/packaging/arch/README.md b/packaging/arch/README.md
deleted file mode 100644
index a3b24e6..0000000
--- a/packaging/arch/README.md
+++ /dev/null
@@ -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.
diff --git a/scripts/install-appimage.sh b/scripts/install-appimage.sh
new file mode 100755
index 0000000..b3e44f3
--- /dev/null
+++ b/scripts/install-appimage.sh
@@ -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__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 < "${DESKTOP_FILE}" <