diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 410870c..371d8a5 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -319,21 +319,23 @@ jobs: TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} run: | export PATH="$HOME/.cargo/bin:$PATH" - npx tauri build + # AppImage only: the .deb and .rpm were dropped in favour of the one + # artifact that runs everywhere, and building them is pure cost. + # Left as "all" in tauri.conf.json so macOS and Windows are unaffected. + npx tauri build --bundles appimage # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's # and breaks Mesa's EGL on systems newer than the build runner, so the # window comes up blank. It has to come from the host; see the script # header for the evidence and the trade. - - name: Unbundle the host-coupled Wayland client - run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Finalize the AppImage + run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage - name: Collect artifacts run: | mkdir -p artifacts cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true + cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true ls -la artifacts/ # Assets, not workflow artifacts — see the note at the top of this file. diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 9b540a0..0d4aa02 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -185,21 +185,23 @@ jobs: working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" - npx tauri build + # AppImage only: the .deb and .rpm were dropped in favour of the one + # artifact that runs everywhere, and building them is pure cost. + # Left as "all" in tauri.conf.json so macOS and Windows are unaffected. + npx tauri build --bundles appimage # linuxdeploy bundles a libwayland-client.so.0 that shadows the host's # and breaks Mesa's EGL on systems newer than the build runner, so the # window comes up blank. It has to come from the host; see the script # header for the evidence and the trade. - - name: Unbundle the host-coupled Wayland client - run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage + - name: Finalize the AppImage + run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage - name: Collect artifacts run: | mkdir -p artifacts cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true - cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true + cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true ls -la artifacts/ - name: Upload to Gitea release @@ -277,6 +279,15 @@ jobs: "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" done + # The fixed tag every installed AppImage checks for updates. Separate + # from the versioned release above because the updater's URL must never + # move, and `releases/latest` does. + - name: Publish the Linux update channel + if: gitea.event_name == 'push' + env: + GH_PAT: ${{ secrets.GH_PAT }} + run: bash scripts/publish-update-channel.sh artifacts + build-macos: runs-on: macos-latest needs: [compute-version] diff --git a/CLAUDE.md b/CLAUDE.md index 2092355..837cc43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -679,8 +679,26 @@ deliberately out of scope — this is not a project backup. ## Packaging -Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and -`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A +Linux ships as **AppImage only**, built by `build-app.yml` (releases) and +`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to +build and publish for an audience the AppImage already serves, and neither could self-update. The +Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and +Windows are untouched. + +`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are +load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as +a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a +bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up +blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by +bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our +releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream +metadata and update information**, without which an AppImage manager can adopt the app but never +update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror +(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is +newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones +that carry no AppImage. The script's post-repack assertions are the only test any of this has. + +**There is deliberately no Arch package.** A `triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on `hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 008aebd..56afc0a 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -41,14 +41,16 @@ Download the build for your platform from [GitHub Releases](https://github.com/s |----------|------|---------| | **Windows** | `Triple-C__x64-setup.exe` or `.msi` | Run the installer. | | **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 / other Linux** | `Triple-C__amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. | +| **Linux (all distributions)** | `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`. > **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher). +> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one. + +> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions. + > **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly. ### Adding an AppImage to the app launcher diff --git a/packaging/appimage/com.triple-c.desktop.appdata.xml b/packaging/appimage/com.triple-c.desktop.appdata.xml new file mode 100644 index 0000000..fd00030 --- /dev/null +++ b/packaging/appimage/com.triple-c.desktop.appdata.xml @@ -0,0 +1,56 @@ + + + + com.triple-c.desktop + CC0-1.0 + MIT + + Triple-C + Run Claude Code sessions in isolated Docker containers + + +

+ Triple-C sandboxes Claude Code inside per-project Docker containers, so an + agent can install packages, edit files and run commands without touching + the host. Each project gets its own container, its own credentials and its + own terminal sessions. +

+

Features:

+
    +
  • Per-project containers with persistent home and config volumes
  • +
  • Multiple terminal sessions per project, in one reorderable tab strip
  • +
  • Notes that can be sent straight into a running agent's prompt
  • +
  • Anthropic, AWS Bedrock, Ollama, llama.cpp and OpenAI-compatible backends
  • +
  • Remote access over a browser terminal, and speech-to-text input
  • +
+
+ + Triple-C.desktop + + Development + Utility + + + https://github.com/shadowdao/triple-c + https://github.com/shadowdao/triple-c/issues + + + triple-c + + + + + + + +
diff --git a/scripts/unbundle-wayland-client.sh b/scripts/finalize-appimage.sh similarity index 54% rename from scripts/unbundle-wayland-client.sh rename to scripts/finalize-appimage.sh index b6e7367..502bc87 100755 --- a/scripts/unbundle-wayland-client.sh +++ b/scripts/finalize-appimage.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash # -# Drop the bundled libwayland-client.so.0 out of a built AppImage. +# Post-process a built AppImage: make it start on modern Mesa, and make it +# adoptable and updatable by an AppImage manager. +# +# Tauri hands off to linuxdeploy, which offers no hook between building the +# AppDir and packing it, so both jobs are done by unpacking the finished image +# and repacking it. That is also why the update information is embedded here +# rather than passed to the bundler. +# +# --------------------------------------------------------------------------- +# 1. The bundled Wayland client +# --------------------------------------------------------------------------- # # linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of # GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's @@ -44,7 +54,34 @@ # LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports # lands last: a fallback, never an override. # -# Usage: unbundle-wayland-client.sh +# --------------------------------------------------------------------------- +# 2. Metadata an AppImage manager needs +# --------------------------------------------------------------------------- +# +# Two things, neither of which the bundler produces: +# +# * AppStream metadata, so a manager can show what the app is rather than a +# bare filename. appimagetool warns about its absence on every build. +# * Update information embedded in the image — the string that tells a +# manager where to look for a newer build. Without it the app can be +# adopted but never updated, which is the whole point. +# +# The update URL is a **fixed** tag on the GitHub mirror, which is where +# updates are pulled from, rather than `releases/latest`. `latest` follows +# whatever release is newest, and the Gitea-to-GitHub backfill creates one +# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which +# carry no AppImage. A fixed tag cannot be pointed at a release that has none, +# and is equally immune to a release marked prerelease. +# +# The output is named for the fixed tag too. zsync records the filename it was +# generated for and a client resolves it relative to the .zsync URL, so a +# versioned name would send every client looking for the version it already +# has. The versioned copy is written afterwards for the normal release. +# +# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what +# a desktop menu and most managers use to file the application. +# +# Usage: finalize-appimage.sh set -euo pipefail @@ -53,6 +90,15 @@ FALLBACK_DIR="usr/lib/wayland-fallback" HOOK="apprun-hooks/triple-c-wayland-fallback.sh" APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" +APP_ID="com.triple-c.desktop" +STABLE_NAME="Triple-C_x86_64.AppImage" +UPDATE_TAG="linux-latest" +UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync" +CATEGORIES="Development;Utility;" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml" + dir="${1:?usage: unbundle-wayland-client.sh }" cd "$dir" @@ -133,6 +179,30 @@ open(path, "w").write(src) PATCH_EOF fi +# --- metadata ------------------------------------------------------------- + +# Version comes from the artifact rather than a second source that could drift. +version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')" +[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; } + +if [ -f "$appdata_src" ]; then + mkdir -p "$root/usr/share/metainfo" + sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \ + "$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml" + echo "Added AppStream metadata for $version." +else + echo "No AppStream source at $appdata_src — skipping." >&2 +fi + +# linuxdeploy emits `Categories=` empty, which files the app nowhere. +for desktop in "$root"/*.desktop; do + [ -e "$desktop" ] || continue + if grep -q "^Categories=$" "$desktop"; then + sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop" + echo "Filled in Categories for $(basename "$desktop")." + fi +done + echo "Demoted $LIB to $FALLBACK_DIR; repacking." tool="$work/appimagetool" @@ -140,7 +210,14 @@ curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" chmod +x "$tool" # --appimage-extract-and-run: CI runners generally have no FUSE. -ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null +# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image. +ARCH=x86_64 "$tool" --appimage-extract-and-run \ + -u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null +chmod +x "$STABLE_NAME" + +# The versioned name is what the per-version release publishes; the stable one +# and its .zsync go to the rolling tag. Same bytes, two names. +cp "$STABLE_NAME" "$appimage" chmod +x "$appimage" # The guards are the test. Each one is a way the repack could look like it @@ -156,4 +233,25 @@ fail() { echo "FAILED: $1" >&2; exit 1; } grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook." [ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." -echo "OK: $appimage now prefers the host $LIB, with a bundled fallback." +# An empty Categories or missing metadata ships an image a manager cannot file +# or describe, and both fail silently at runtime rather than at build time. +grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty." +[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \ + || fail "AppStream metadata did not make it into the image."; } + +# The update string is the difference between adoptable and updatable. It +# lives in the image's own `.upd_info` ELF section, not in the .zsync — the +# .zsync only records a *relative* filename, which a client resolves against +# the URL it fetched the .zsync from. That is exactly why the output is named +# for the fixed tag: a versioned name here resolves to the build the client +# already has. +[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing." +[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync." + +readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \ + || fail "the image carries no update information for the $UPDATE_TAG tag." +grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \ + || fail "the .zsync names something other than $STABLE_NAME." + +echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream" +echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync." diff --git a/scripts/publish-update-channel.sh b/scripts/publish-update-channel.sh new file mode 100755 index 0000000..8126bcc --- /dev/null +++ b/scripts/publish-update-channel.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Publish the AppImage and its .zsync to the fixed `linux-latest` tag on the +# GitHub mirror — the URL every installed copy checks for updates. +# +# This exists because the update URL has to be one that never moves. +# `releases/latest` does move: it follows whatever release is newest, and the +# Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including +# the `-win` and `-mac` tags that carry no AppImage. Pointing a million +# installed copies at a URL that can resolve to a release with no AppImage in +# it is a failure that shows up on users' machines and nowhere else. +# +# So this tag holds exactly two files, replaced in place on every release. +# The versioned per-release artifacts are published separately and are what a +# human downloads; this is what the updater reads. +# +# It writes to GitHub rather than Gitea because that mirror is where updates +# are pulled from. Needs GH_PAT with contents write on the mirror. +# +# Usage: GH_PAT=... publish-update-channel.sh + +set -euo pipefail + +REPO="shadowdao/triple-c" +TAG="linux-latest" +API="https://api.github.com/repos/$REPO" +ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync") + +: "${GH_PAT:?GH_PAT is required to publish the update channel}" +dir="${1:?usage: publish-update-channel.sh }" +cd "$dir" + +for asset in "${ASSETS[@]}"; do + [ -e "$asset" ] || { echo "Missing $asset in $dir" >&2; exit 1; } +done + +gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; } + +echo "==> Looking for the $TAG release" +release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)" +release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)" + +if [ -z "$release_id" ]; then + echo "==> Creating it" + # Not a prerelease, but deliberately not the "latest" release either: this + # tag is a channel, and it must never displace the versioned release a + # person lands on from the releases page. + release="$(gh -X POST "$API/releases" -d "$(python3 -c ' +import json +print(json.dumps({ + "tag_name": "'"$TAG"'", + "name": "Linux update channel", + "body": "Rolling AppImage build that Triple-C’s in-app updater reads. " + "The two files here are replaced on every release; for a specific " + "version, use the versioned releases instead.", + "draft": False, + "prerelease": False, + "make_latest": "false", +}))')")" + release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')" +fi + +echo "==> Removing superseded assets from release $release_id" +printf '%s' "$release" | python3 -c ' +import sys, json +keep = set(sys.argv[1:]) +for a in json.load(sys.stdin).get("assets", []): + if a["name"] in keep: + print(a["id"]) +' "${ASSETS[@]}" | while read -r asset_id; do + [ -n "$asset_id" ] || continue + gh -X DELETE "$API/releases/assets/$asset_id" >/dev/null || true +done + +for asset in "${ASSETS[@]}"; do + echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))" + curl -sf -X POST \ + -H "Authorization: Bearer $GH_PAT" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$asset" \ + "https://uploads.github.com/repos/$REPO/releases/$release_id/assets?name=$asset" >/dev/null +done + +# The updater is only as good as this URL, and a silent failure here means +# every installed copy quietly stops updating. Confirm both are actually +# fetchable at the address the AppImage was built to check. +echo "==> Verifying the published URLs" +for asset in "${ASSETS[@]}"; do + url="https://github.com/$REPO/releases/download/$TAG/$asset" + code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")" + [ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; } + echo " $code $url" +done + +echo "OK: $TAG updated."