Retire the Arch package, document AppImage desktop integration #47

Merged
jknapp merged 1 commits from chore/retire-arch-packaging into main 2026-08-28 20:21:02 +00:00
7 changed files with 190 additions and 515 deletions
-368
View File
@@ -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"
+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:
+43 -3
View File
@@ -43,12 +43,52 @@ Download the build for your platform from [GitHub Releases](https://github.com/s
| **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** | `triple-c-bin-<version>-1-x86_64.pkg.tar.zst` | `sudo pacman -U ./triple-c-bin-<version>-1-x86_64.pkg.tar.zst` |
| **Other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. |
| **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`.
> **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
-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
-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."