Compare commits
9
Commits
v0.4.20
...
v0.4.23-mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 | ||
|
|
670450ccfd | ||
|
|
9fadfbc37a |
@@ -299,8 +299,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -319,21 +345,22 @@ 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
|
# 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
|
# 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
|
# window comes up blank. It has to come from the host; see the script
|
||||||
# header for the evidence and the trade.
|
# header for the evidence and the trade.
|
||||||
- name: Unbundle the host-coupled Wayland client
|
- name: Finalize the AppImage
|
||||||
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/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/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.
|
||||||
@@ -425,8 +452,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -172,8 +172,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -185,23 +211,38 @@ 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
|
# 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
|
# 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
|
# window comes up blank. It has to come from the host; see the script
|
||||||
# header for the evidence and the trade.
|
# header for the evidence and the trade.
|
||||||
- name: Unbundle the host-coupled Wayland client
|
- name: Finalize the AppImage
|
||||||
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/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
|
||||||
|
# The versioned AppImage only. The update channel's copy lives in
|
||||||
|
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||||
|
# it up and publish an 80 MB duplicate under a second name.
|
||||||
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/rpm/*.rpm artifacts/ 2>/dev/null || true
|
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
|
# A green job that published nothing is the worst outcome available:
|
||||||
|
# the release exists, carries no AppImage, and nobody is told. The
|
||||||
|
# `|| true` above is there so a missing bundle does not mask the real
|
||||||
|
# error, which makes this check the thing that catches it.
|
||||||
|
shopt -s nullglob
|
||||||
|
collected=(artifacts/*)
|
||||||
|
if [ ${#collected[@]} -eq 0 ]; then
|
||||||
|
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
@@ -277,6 +318,19 @@ 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 }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
bash scripts/publish-update-channel.sh \
|
||||||
|
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version]
|
||||||
@@ -332,8 +386,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
Executable
+317
@@ -0,0 +1,317 @@
|
|||||||
|
#!/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"
|
||||||
|
# The channel pair lives in its own directory. Left beside the versioned image
|
||||||
|
# they are picked up by the release job's `*.AppImage` glob, and every release
|
||||||
|
# then carries an eighty-megabyte byte-identical duplicate under a second name
|
||||||
|
# — which is exactly as confusing on a downloads page as it sounds.
|
||||||
|
CHANNEL_DIR="update-channel"
|
||||||
|
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"
|
||||||
|
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
|
||||||
|
# metadata is missing under any other name — while the script cheerfully
|
||||||
|
# reported it present. The AppStream id inside the file is unchanged and is
|
||||||
|
# what actually identifies the component; only the filename follows the tool.
|
||||||
|
appdata_installed_as="Triple-C.appdata.xml"
|
||||||
|
|
||||||
|
dir="${1:?usage: finalize-appimage.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
|
||||||
|
# Refused here rather than after the repack: with two present the old position
|
||||||
|
# let the script download appimagetool, repack, overwrite the versioned
|
||||||
|
# artifact and write the channel pair, *then* fail — and it silently picked
|
||||||
|
# images[0], which is glob order, i.e. the older version.
|
||||||
|
if [ ${#images[@]} -ne 1 ]; then
|
||||||
|
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
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"
|
||||||
|
|
||||||
|
# The demotion and the metadata are independent jobs, and an absent library
|
||||||
|
# must not skip the second. An early exit here also left `update-channel/`
|
||||||
|
# uncreated, which killed the publish step on a missing directory and took the
|
||||||
|
# tag and mirror jobs down with it — a half-published release.
|
||||||
|
demoted=false
|
||||||
|
if [ -e "$root/usr/lib/$LIB" ]; then
|
||||||
|
|
||||||
|
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
|
||||||
|
demoted=true
|
||||||
|
echo "Demoted $LIB to $FALLBACK_DIR."
|
||||||
|
else
|
||||||
|
echo "$LIB is not bundled — nothing to demote."
|
||||||
|
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/$appdata_installed_as"
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# The AppDir root entry is a **symlink** into usr/share/applications, so a
|
||||||
|
# plain `sed -i` replaces the link with a regular file and leaves the real entry
|
||||||
|
# untouched — two divergent copies, of which the empty one is the one that
|
||||||
|
# actually ships and the filled one is the only one a root-only guard can see.
|
||||||
|
# `--follow-symlinks` writes through. Both locations are globbed because the
|
||||||
|
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
|
||||||
|
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
|
||||||
|
[ -e "$desktop" ] || continue
|
||||||
|
if grep -q "^Categories=$" "$desktop"; then
|
||||||
|
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
|
||||||
|
echo "Filled in Categories for ${desktop#"$root"/}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "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.
|
||||||
|
rm -rf "$CHANNEL_DIR"
|
||||||
|
mkdir -p "$CHANNEL_DIR"
|
||||||
|
ARCH=x86_64 "$tool" --appimage-extract-and-run \
|
||||||
|
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
|
||||||
|
chmod +x "$CHANNEL_DIR/$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, two places.
|
||||||
|
# zsyncmake writes the .zsync into the working directory, not beside the image
|
||||||
|
# it describes, so it has to be collected rather than assumed in place.
|
||||||
|
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
|
||||||
|
|
||||||
|
cp "$CHANNEL_DIR/$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; }
|
||||||
|
|
||||||
|
if [ "$demoted" = true ]; then
|
||||||
|
[ -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."
|
||||||
|
fi
|
||||||
|
[ -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.
|
||||||
|
# Asserted positively, over every entry: the earlier form checked only that no
|
||||||
|
# *root* file held an empty value, which passed while the real entry under
|
||||||
|
# usr/share/applications shipped empty, and also passed on a missing key.
|
||||||
|
desktops=0
|
||||||
|
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
|
||||||
|
[ -e "$desktop" ] || continue
|
||||||
|
desktops=$((desktops + 1))
|
||||||
|
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|
||||||
|
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
|
||||||
|
done
|
||||||
|
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
|
||||||
|
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
|
||||||
|
|| 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 "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
|
||||||
|
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
|
||||||
|
|
||||||
|
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|
||||||
|
|| fail "the image does not carry exactly the expected update information."
|
||||||
|
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
|
||||||
|
|| fail "the .zsync names something other than $STABLE_NAME."
|
||||||
|
|
||||||
|
# The versioned release must carry one AppImage, not two. This is the guard
|
||||||
|
# for the duplicate that shipped in 0.4.20 and 0.4.21.
|
||||||
|
shopt -s nullglob
|
||||||
|
beside=(*.AppImage)
|
||||||
|
shopt -u nullglob
|
||||||
|
[ "${#beside[@]}" -eq 1 ] \
|
||||||
|
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
|
||||||
|
|
||||||
|
if [ "$demoted" = true ]; then
|
||||||
|
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
|
||||||
|
else
|
||||||
|
echo "OK: $appimage had no bundled $LIB to demote, and carries"
|
||||||
|
fi
|
||||||
|
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."
|
||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# **The tag has to exist in Gitea, not just on GitHub, and that is the whole
|
||||||
|
# reason this script touches Gitea at all.** Gitea push-mirrors this repo to
|
||||||
|
# GitHub, and a mirror push deletes remote refs that have no local counterpart.
|
||||||
|
# A tag created only by GitHub's release API therefore survives until the next
|
||||||
|
# mirror run and then vanishes — which is exactly what happened to 0.4.20 and
|
||||||
|
# 0.4.21: the release was created and both URLs verified 200 at 00:38, and the
|
||||||
|
# 13:04 mirror deleted the tag, leaving every installed copy checking a 404.
|
||||||
|
# Versioned tags never had this problem because `create-tag` creates them in
|
||||||
|
# Gitea first. So does this one, now, and before the GitHub release rather than
|
||||||
|
# after, so there is no window where the two disagree.
|
||||||
|
#
|
||||||
|
# Note what this means for verification: publishing correctly is not evidence
|
||||||
|
# the channel still works hours later. The Gitea tag is what makes it durable,
|
||||||
|
# so its absence is treated as a failure rather than a warning.
|
||||||
|
#
|
||||||
|
# Usage: GH_PAT=... GITEA_TOKEN=... GITEA_SHA=... publish-update-channel.sh <dir>
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
GITEA_API="${GITEA_API:-https://repo.anhonesthost.net/api/v1}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-CyberCoveLLC/Triple-C}"
|
||||||
|
|
||||||
|
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
|
||||||
|
: "${GITEA_TOKEN:?GITEA_TOKEN is required to anchor the $TAG tag against the mirror}"
|
||||||
|
: "${GITEA_SHA:?GITEA_SHA is required to point the $TAG tag at this build}"
|
||||||
|
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" "$@"; }
|
||||||
|
tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||||
|
# Status, not a boolean. `curl -sf` fails identically for "404, the tag is
|
||||||
|
# genuinely absent" and "503, Gitea is briefly unreachable", and treating the
|
||||||
|
# second as the first means POSTing over a tag that already exists, taking a
|
||||||
|
# 409, and aborting the last step of build-linux — which `create-tag` and
|
||||||
|
# `sync-to-github` both depend on. A transient blip would cost the release, not
|
||||||
|
# just the channel update. Same `case`-on-code idiom as `Upload to Gitea
|
||||||
|
# release` two steps above in the workflow. A refused connection reports 000
|
||||||
|
# and lands in the catch-all.
|
||||||
|
tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; }
|
||||||
|
|
||||||
|
# Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
|
||||||
|
#
|
||||||
|
# An earlier version deleted and recreated it so the tag would name the current
|
||||||
|
# build. That was worse than useless: nothing about the channel depends on
|
||||||
|
# which commit the tag points at — the update string resolves the tag by *name*
|
||||||
|
# and the assets hang off the release object — while a DELETE followed by a
|
||||||
|
# failed POST destroys a working anchor and leaves a window in which a mirror
|
||||||
|
# run prunes GitHub's copy. A transient Gitea error would have converted a
|
||||||
|
# healthy channel into a dead one, which is strictly worse than this step not
|
||||||
|
# existing. Gitea's POST /tags has no force semantics, so the DELETE was only
|
||||||
|
# ever there to get around a 409; asking first removes the need.
|
||||||
|
echo "==> Anchoring the $TAG tag in Gitea"
|
||||||
|
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
case "$anchor_probe" in
|
||||||
|
200)
|
||||||
|
echo " already anchored — left alone"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo " creating it at ${GITEA_SHA:0:9}"
|
||||||
|
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
|
||||||
|
>/dev/null
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2
|
||||||
|
echo " Refusing to guess — creating it blindly would 409 over an" >&2
|
||||||
|
echo " existing tag and abort the release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Not best-effort. Without this tag the mirror removes GitHub's and the
|
||||||
|
# channel dies silently somewhere between now and four hours from now. Reported
|
||||||
|
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
|
||||||
|
anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
[ "$anchor_code" = "200" ] || {
|
||||||
|
echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2
|
||||||
|
echo " without it the mirror would delete GitHub's copy." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Look through the authenticated list rather than /releases/tags/, which never
|
||||||
|
# returns drafts. That matters here specifically: GitHub demotes a published
|
||||||
|
# release to a draft when its tag is deleted, which is the state every mirror
|
||||||
|
# run left behind, so the by-tag lookup reports "absent" while orphaned drafts
|
||||||
|
# sit there holding 86 MB each. Reuse the newest and delete the rest, or they
|
||||||
|
# accumulate one per release forever.
|
||||||
|
echo "==> Looking for the $TAG release (drafts included)"
|
||||||
|
all_releases="$(gh "$API/releases?per_page=100")"
|
||||||
|
mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
tag = sys.argv[1]
|
||||||
|
rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag]
|
||||||
|
rs.sort(key=lambda r: r.get("created_at",""), reverse=True)
|
||||||
|
for r in rs:
|
||||||
|
print(r["id"])
|
||||||
|
' "$TAG")
|
||||||
|
|
||||||
|
release_id="${existing[0]:-}"
|
||||||
|
|
||||||
|
for stale in "${existing[@]:1}"; do
|
||||||
|
echo " deleting orphaned duplicate release $stale"
|
||||||
|
gh -X DELETE "$API/releases/$stale" >/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$release_id" ]; then
|
||||||
|
# A draft has no tag and serves no download URL, so it has to be republished.
|
||||||
|
echo " reusing release $release_id"
|
||||||
|
# `make_latest` is not optional here even though this release already exists.
|
||||||
|
# Publishing a draft is a publish transition, where the API's documented
|
||||||
|
# default is `true` — so omitting it would quietly promote this channel to
|
||||||
|
# the repository's "Latest release" and bury the versioned release a person
|
||||||
|
# actually wants from the releases page.
|
||||||
|
#
|
||||||
|
# `tag_name` is re-sent deliberately, and must be: the API removes the tag
|
||||||
|
# when a PATCH omits it. Given this whole change exists because a tag
|
||||||
|
# disappeared, that is an expensive line to tidy away.
|
||||||
|
gh -X PATCH "$API/releases/$release_id" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null
|
||||||
|
release="$(gh "$API/releases/$release_id")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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.
|
||||||
|
body_json="$(python3 -c '
|
||||||
|
import json
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": "'"$TAG"'",
|
||||||
|
"name": "Linux update channel",
|
||||||
|
"body": "Rolling AppImage build that Triple-C\u2019s 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",
|
||||||
|
}))')"
|
||||||
|
|
||||||
|
# `already_exists` is a benign, recoverable answer, not a reason to abort the
|
||||||
|
# last step of build-linux and lose the release with it. It means a release
|
||||||
|
# for this tag exists but the listing above did not show it — a draft that has
|
||||||
|
# sunk past the first page, since a draft's created_at is frozen while newer
|
||||||
|
# releases push it down. Re-ask by tag and carry on.
|
||||||
|
create_body="$(mktemp)"
|
||||||
|
create_code="$(curl -s -o "$create_body" -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" \
|
||||||
|
-X POST "$API/releases" -d "$body_json")"
|
||||||
|
|
||||||
|
case "$create_code" in
|
||||||
|
201)
|
||||||
|
release="$(cat "$create_body")"
|
||||||
|
;;
|
||||||
|
422)
|
||||||
|
if grep -q "already_exists" "$create_body"; then
|
||||||
|
echo " a release for $TAG already exists but was not listed — reusing it"
|
||||||
|
release="$(gh "$API/releases/tags/$TAG")"
|
||||||
|
else
|
||||||
|
echo "FAILED: GitHub rejected the release (422):" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: creating the $TAG release returned $create_code:" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$create_body"
|
||||||
|
|
||||||
|
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One asset at a time, delete immediately followed by upload. Deleting both up
|
||||||
|
# front leaves the channel holding a fresh AppImage and no .zsync if the second
|
||||||
|
# upload fails, and a client that cannot fetch the .zsync simply stops updating
|
||||||
|
# — no error anyone here would see.
|
||||||
|
asset_ids="$(printf '%s' "$release" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
keep = set(sys.argv[1:])
|
||||||
|
out = {}
|
||||||
|
for a in json.load(sys.stdin).get("assets", []):
|
||||||
|
if a["name"] in keep:
|
||||||
|
out[a["name"]] = a["id"]
|
||||||
|
print(json.dumps(out))
|
||||||
|
' "${ASSETS[@]}")"
|
||||||
|
|
||||||
|
# --retry/--max-time/--http1.1 for the reason the Gitea upload steps in this
|
||||||
|
# repo carry them: real mid-stream failures on large assets (curl 92 and 28).
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
stale_id="$(printf '%s' "$asset_ids" | python3 -c 'import sys,json;print(json.load(sys.stdin).get(sys.argv[1],""))' "$asset")"
|
||||||
|
if [ -n "$stale_id" ]; then
|
||||||
|
echo "==> Replacing $asset (dropping superseded asset $stale_id)"
|
||||||
|
gh -X DELETE "$API/releases/assets/$stale_id" >/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
|
||||||
|
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
|
||||||
|
-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.
|
||||||
|
# Size as well as status: a 200 only proves something is served at the
|
||||||
|
# address, not that it is this build. GitHub accepting a truncated upload
|
||||||
|
# would pass a status-only check and then fail every client's checksum.
|
||||||
|
echo "==> Verifying the published URLs"
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
url="https://github.com/$REPO/releases/download/$TAG/$asset"
|
||||||
|
local_size="$(stat -c %s "$asset")"
|
||||||
|
|
||||||
|
headers="$(curl -sIL "$url" | tr -d '\r')"
|
||||||
|
code="$(printf '%s\n' "$headers" | awk '/^HTTP\//{c=$2} END{print c}')"
|
||||||
|
served="$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:"{n=$2} END{print n}')"
|
||||||
|
|
||||||
|
[ "$code" = "200" ] || { echo "FAILED: $url returned ${code:-no status}" >&2; exit 1; }
|
||||||
|
[ "$served" = "$local_size" ] \
|
||||||
|
|| { echo "FAILED: $url serves ${served:-unknown} bytes, built $local_size." >&2; exit 1; }
|
||||||
|
echo " $code $served bytes $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Drop the bundled libwayland-client.so.0 out of a built AppImage.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# Usage: unbundle-wayland-client.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"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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.
|
|
||||||
ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null
|
|
||||||
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."
|
|
||||||
|
|
||||||
echo "OK: $appimage now prefers the host $LIB, with a bundled fallback."
|
|
||||||
Reference in New Issue
Block a user