Compare commits

..
Author SHA1 Message Date
jknapp 670450ccfd Merge pull request 'Make the AppImage updatable, and drop the deb and rpm' (#51) from feat/appimage-update-metadata into main
Build App / compute-version (push) Successful in 2s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m48s
Build App / build-windows (push) Successful in 4m53s
Build App / build-linux (push) Successful in 5m11s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
2026-09-03 00:28:10 +00:00
jknapp a0b9f1e19b Merge pull request 'Let the host's libwayland-client win in the AppImage' (#50) from fix/appimage-wayland-client into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m42s
Build App / build-windows (push) Successful in 4m54s
Build App / build-linux (push) Successful in 5m32s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #50
2026-09-03 00:22:03 +00:00
shadowdaoandClaude Opus 5 9fadfbc37a Make the AppImage updatable, and drop the deb and rpm
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m19s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An AppImage manager can adopt the current build but never update it: the image
carries no update information, which is the string that tells such a tool where
to look for a newer one. It also carries no AppStream metadata, so a manager
has nothing to show but a filename — appimagetool has been warning about that
on every build — and linuxdeploy leaves `Categories=` empty, which files the
app nowhere in a desktop menu.

All three are fixed while the image is already unpacked for the wayland fix, so
the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh`
is now `finalize-appimage.sh`, since it does more than unbundle.

The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is
where updates are pulled from — deliberately not `releases/latest`. `latest`
follows whichever 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. A URL that can resolve to a release with no AppImage in it
fails on users' machines and nowhere else.

The output is named for that tag too, and that is not cosmetic: zsync records
a *relative* filename which a client resolves against the .zsync URL it
fetched, so a versioned name would send every client after the build it already
has. Verified by reading the generated header — `Filename: Triple-C_x86_64
.AppImage` — and the image's own `.upd_info` section, which is where the tag
actually lives. My first guard checked the .zsync for the tag and failed
correctly, which is how that distinction got found rather than shipped.

Range requests were confirmed against the mirror before building on them: 206
with a correct content-range, so updates are real deltas rather than an 85 MB
re-download.

The .deb and .rpm go. They are two more artifacts to build, publish and keep
working for an audience already served by the one file that runs on every
distribution, and neither could ever self-update — which is now the difference
that matters. Older releases keep theirs. The Linux job passes
`--bundles appimage` rather than changing `tauri.conf.json`, so macOS and
Windows are untouched.

Verified against the real 0.4.19 artifact: it repacks, the AppStream file and
filled-in Categories land inside the image, the update string resolves to the
fixed tag, and the wayland fallback still holds. Both publisher failure paths
refuse rather than half-publishing — no token, and missing artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 17:10:44 -07:00
shadowdaoandClaude Opus 5 a3bdf6f4da Let the host's libwayland-client win in the AppImage
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m52s
Build App (Preview) / build-linux (pull_request) Successful in 5m20s
Build App (Preview) / build-windows (pull_request) Successful in 5m21s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The AppImage came up blank on CachyOS with `Could not create default EGL
display: EGL_BAD_PARAMETER. Aborting...`, and the DMA-BUF workaround already
in `main.rs` did not help — verified by finding the flag compiled into the
shipped 0.4.19 binary, where it runs unconditionally on Linux.

It is a different fault with the same error text. linuxdeploy bundles
`libwayland-client.so.0` as a GTK dependency and `AppRun.wrapped` puts the
bundled directory ahead of the host's, so the host's Mesa resolves against our
copy. `libEGL_mesa.so.0` — the driver libglvnd's `libEGL.so.1` dlopens — has a
hard DT_NEEDED on that library, so when its symbols will not resolve the
driver never loads, glvnd is left with none, and `eglGetDisplay` reports no
display. That is why `GDK_BACKEND=x11` does not dodge it, and why the symptom
is a bad-parameter error rather than a link failure.

Bisected on the reporter's machine against the released artifact — removing
`libwayland-client.so.0` from the AppDir cleared the abort, while removing
`libwayland-egl` or `libepoxy` did not. The bundled copy (Ubuntu 22.04,
wayland 1.20) is missing eleven symbols their wayland 1.26 exports, including
`wl_proxy_get_display`, `wl_proxy_get_queue`,
`wl_display_create_queue_with_name` and `wl_fixes_interface`.

Bundling a newer wayland would defer this, not fix it: the floor is set by the
host's Mesa, which updates independently of our releases, so any version we
pick is one release away from being too old again. This is a host-coupled
library like libGL and libdrm — the only correct version is the host's.

So the copy is demoted rather than deleted. It moves off the loader path into
`usr/lib/wayland-fallback`, and a hook adds that directory back only when the
host has no libwayland-client of its own — so a host without one still starts.
The ordering is safe because `AppRun.wrapped` appends the inherited
LD_LIBRARY_PATH after its own entries, making the hook a fallback and never an
override. AppRun sources hooks by name rather than globbing, so it is patched
to source this one.

X11-only hosts are unaffected: libwayland-client is a package dependency of
Mesa and GTK, so it is present even on a machine with no display server at all
— confirmed on a headless container with neither DISPLAY nor WAYLAND_DISPLAY
set. And the AppImage already runs as an X11 client everywhere, since
linuxdeploy's own hook forces GDK_BACKEND=x11.

Verified against the real 0.4.19 artifact rather than a synthetic AppDir: it
repacks, the binary and AppRun survive, and both hook branches were exercised
— host-has-it leaves LD_LIBRARY_PATH untouched, and a debian:12-slim container
with no wayland at all engages the fallback.

The comment in `main.rs` quoted this exact error as one the DMA-BUF flag
fixes. That claim sent this investigation down the wrong path first, so it is
corrected rather than left to do it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 16:57:51 -07:00
8 changed files with 479 additions and 15 deletions
+12 -3
View File
@@ -319,14 +319,23 @@ jobs:
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" 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: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true 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/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file. # Assets, not workflow artifacts — see the note at the top of this file.
+21 -3
View File
@@ -185,14 +185,23 @@ jobs:
working-directory: ./app working-directory: ./app
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" 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: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true 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/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
- name: Upload to Gitea release - name: Upload to Gitea release
@@ -270,6 +279,15 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done 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: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version] needs: [compute-version]
+20 -2
View File
@@ -679,8 +679,26 @@ deliberately out of scope — this is not a project backup.
## Packaging ## Packaging
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A `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 `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 `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 was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
+5 -3
View File
@@ -41,14 +41,16 @@ Download the build for your platform from [GitHub Releases](https://github.com/s
|----------|------|---------| |----------|------|---------|
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. | | **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. | | **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` | | **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
| **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`. > **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). > **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. > **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 ### Adding an AppImage to the app launcher
+13 -4
View File
@@ -3,10 +3,19 @@
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path /// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations /// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display: /// under Wayland, killing the webview and leaving a blank window — see
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C /// triple-c#34, reported on CachyOS/Arch with Wayland.
/// code and killing the webview before Triple-C's own logging even starts — ///
/// see triple-c#34, reported on CachyOS/Arch with Wayland. /// **This is not the only cause of a blank window, and the error text alone
/// does not tell them apart.** An earlier version of this comment quoted
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
/// the error this fixes. The AppImage produces that same string for an
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
/// DT_NEEDED on that library, so the EGL driver failed to load before any
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
/// which cost a round of debugging that started from the comment rather than
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
/// ///
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that /// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
/// variable is exported into an XWayland client's environment too, so a /// variable is exported into an XWayland client's environment too, so a
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AppStream metadata for the AppImage.
Without this an AppImage manager (Gear Lever, AppImageLauncher and the like)
can adopt the file but has nothing to show for it: no summary, no category,
no release history. appimagetool warns about its absence on every build.
The id matches `identifier` in tauri.conf.json and the .desktop basename, so
the desktop entry, the AppStream component and the AppImage all name the
same application. `@VERSION@` is substituted at build time.
-->
<component type="desktop-application">
<id>com.triple-c.desktop</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Triple-C</name>
<summary>Run Claude Code sessions in isolated Docker containers</summary>
<description>
<p>
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.
</p>
<p>Features:</p>
<ul>
<li>Per-project containers with persistent home and config volumes</li>
<li>Multiple terminal sessions per project, in one reorderable tab strip</li>
<li>Notes that can be sent straight into a running agent's prompt</li>
<li>Anthropic, AWS Bedrock, Ollama, llama.cpp and OpenAI-compatible backends</li>
<li>Remote access over a browser terminal, and speech-to-text input</li>
</ul>
</description>
<launchable type="desktop-id">Triple-C.desktop</launchable>
<categories>
<category>Development</category>
<category>Utility</category>
</categories>
<url type="homepage">https://github.com/shadowdao/triple-c</url>
<url type="bugtracker">https://github.com/shadowdao/triple-c/issues</url>
<provides>
<binary>triple-c</binary>
</provides>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
<content_rating type="oars-1.1"/>
</component>
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
#
# 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
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
# against *our* copy instead of the system one it was built against, and when
# ours is older than Mesa needs, EGL initialisation fails outright:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
#
# WebKitGTK prints that from its own C code and kills the webview, so the
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
# `wl_fixes_interface` among them. Removing this one file from the AppDir
# fixes it; removing libwayland-egl or libepoxy does not.
#
# **Building on a newer runner would not fix this.** libwayland-client is a
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
# match the compositor and Mesa actually running, not the ones the build
# machine had. Any pinned version is wrong on a system newer than the builder,
# so the only correct version is the host's. That is what AppImage excludelists
# are for; this library simply is not on linuxdeploy's.
#
# Bundling a *newer* wayland instead would not fix this either, only defer it.
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
# libwayland-client.so.0. If those symbols will not resolve, the driver never
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
# we pick is one wayland release away from being too old again.
#
# So the copy is not deleted, it is demoted. It moves to a directory that is
# not on the loader path, and a hook puts that directory on the path only when
# the host has no libwayland-client of its own. Hosts with one — which is
# every host with a graphical desktop, since Mesa itself depends on it — get
# theirs, matching their Mesa. A host without one still gets a working app.
#
# The ordering works because `AppRun.wrapped` appends the inherited
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
# lands last: a fallback, never an override.
#
# ---------------------------------------------------------------------------
# 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 <directory holding the .AppImage>
set -euo pipefail
LIB="libwayland-client.so.0"
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 <bundle/appimage directory>}"
cd "$dir"
shopt -s nullglob
images=(*.AppImage)
shopt -u nullglob
if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2
exit 0
fi
appimage="${images[0]}"
here="$PWD"
work="$(mktemp -d)"
check="$(mktemp -d)"
trap 'rm -rf "$work" "$check"' EXIT
echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root"
if [ ! -e "$root/usr/lib/$LIB" ]; then
# Not a failure: linuxdeploy may have stopped bundling it, which is the
# outcome this script exists to produce.
echo "$LIB is not bundled — leaving $appimage alone."
exit 0
fi
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash
# Fall back to the bundled libwayland-client only when the host has none.
#
# The host's copy is the correct one whenever it exists: its Mesa was built
# against it, and `libEGL.so.1` needs symbols from it before it will load.
# Ours is here so a host without any libwayland-client still starts.
#
# This runs before AppRun.wrapped, which appends the inherited
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
_tc_host_has_wayland_client() {
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
return 0
fi
local d
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
/lib /lib64 /lib/x86_64-linux-gnu; do
[ -e "$d/libwayland-client.so.0" ] && return 0
done
return 1
}
if ! _tc_host_has_wayland_client; then
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
unset -f _tc_host_has_wayland_client
HOOK_EOF
chmod +x "$root/$HOOK"
# AppRun sources each hook by name rather than globbing the directory, so a
# new hook file is inert until AppRun is told about it.
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
python3 - "$root/AppRun" <<'PATCH_EOF'
import sys
path = sys.argv[1]
src = open(path).read()
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
if exec_line not in src:
raise SystemExit("AppRun does not have the exec line this patch expects")
src = src.replace(
exec_line,
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
)
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"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE.
# -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
# worked while shipping the original bug.
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; }
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
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."
# 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."
+95
View File
@@ -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 <directory holding the artifacts>
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 <artifacts directory>}"
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-Cs 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."