Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 |
@@ -43,7 +43,18 @@ name: Build App (Preview)
|
||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||
# release; the point of a preview is the build you are testing now.
|
||||
#
|
||||
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||
# (never valid semver) already blocks it independently. (The previous
|
||||
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||
# since build-app.yml's inline mirror already does what it was meant to do
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
@@ -70,12 +81,23 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||
# The bundle version fields never see this — see "Set app version" in
|
||||
# each build job — but it is baked into the binary as
|
||||
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||
# An installed preview otherwise reports the same bare number a
|
||||
# production build would, indistinguishable in the About panel and to
|
||||
# `check_for_updates`. See triple-c#32.
|
||||
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute preview version
|
||||
id: version
|
||||
run: |
|
||||
@@ -86,21 +108,60 @@ jobs:
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number is computed exactly as build-app.yml does it, so a
|
||||
# preview is labelled with the version the release it previews would
|
||||
# carry. This used to be hard-coded `.0`, which made every preview
|
||||
# installer claim to be x.y.0 no matter what it contained.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||
# The patch number must be the same "one past the highest patch
|
||||
# already used" build-app.yml computes for a real release — not a
|
||||
# distance from the latest tag. It used to be
|
||||
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||
# own history section documents as broken for exactly this reason:
|
||||
# it resets to zero on every tag cut, so previews went *backwards*
|
||||
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||
# a preview number from later colliding with a real release's.
|
||||
#
|
||||
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||
# / `-win` suffixed ones a partially-published release can leave
|
||||
# behind) means a preview built right before a release computes the
|
||||
# exact number that release is about to take — e.g. `0.4.13` for
|
||||
# both. That makes the two numerically *equal*, not "preview less
|
||||
# than release" — plain semver ordering does not make a
|
||||
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||
# `check_for_updates` compares against the bare, stripped
|
||||
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||
# check, which relaxes that one comparison to `>=` specifically so
|
||||
# "a release exists at my own number" reads as an update. See
|
||||
# triple-c#32.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||
# HEAD can be a commit a release was already cut from. Without this,
|
||||
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||
# that release — and produce exactly the "preview outranks
|
||||
# production" failure triple-c#32 was filed over, just reintroduced
|
||||
# through the manual-dispatch door instead of the automatic one.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||
SUFFIX="preview.${SHORT_SHA}"
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
@@ -249,6 +310,13 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
env:
|
||||
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||
# no room for a suffix), so this is the only place a preview build
|
||||
# can still tell itself apart from a production one. See
|
||||
# triple-c#32.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
@@ -361,6 +429,9 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
env:
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
@@ -489,6 +560,8 @@ jobs:
|
||||
working-directory: ./app
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
name: Publish AUR Package
|
||||
|
||||
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
|
||||
# for a given release, or the latest one if none is given. Manual dispatch
|
||||
# only — deliberately not triggered by `release` or `push`, for the same
|
||||
# reason sync-release.yml (removed in triple-c#32) never worked safely as an
|
||||
# automatic trigger: this repo's releases are assembled by build-app.yml
|
||||
# across three separate platform jobs, and there is no single automatic event
|
||||
# that fires only once everything (including the Linux .deb this workflow
|
||||
# needs) is actually uploaded. A human deciding "this release is ready, go
|
||||
# package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step)
|
||||
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
|
||||
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
|
||||
# a hand-maintained template with a placeholder version, and every real,
|
||||
# published version lives only in AUR's own git history, which is where a
|
||||
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
|
||||
# a hand-edit made directly in the AUR repo (outside this workflow) is
|
||||
# silently overwritten the next time this runs, since every run renders fresh
|
||||
# from this repo's template rather than starting from AUR's current state.
|
||||
#
|
||||
# ## Required secret
|
||||
#
|
||||
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
|
||||
# account that has already created (or been given co-maintainer access to)
|
||||
# the `triple-c-bin` package. This workflow cannot create that AUR account or
|
||||
# register the key for you — both are manual, one-time steps on
|
||||
# https://aur.archlinux.org. Until this secret exists, every run fails at the
|
||||
# "Push to AUR" step with a clear error rather than silently doing nothing.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||
Leave empty to use the latest published GitHub release.
|
||||
required: false
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and find the Linux asset
|
||||
id: resolve
|
||||
env:
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Authenticated when the secret is available (it is, everywhere
|
||||
# else in this repo's workflows) to avoid the unauthenticated
|
||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||
# lower limit, since this hits nothing but a public repo's public
|
||||
# releases.
|
||||
AUTH=()
|
||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||
|
||||
if [ -z "${VERSION_INPUT}" ]; then
|
||||
echo "No version given — resolving the latest GitHub release"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||
else
|
||||
echo "Using requested version ${VERSION_INPUT}"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||
fi
|
||||
|
||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
VERSION="${TAG#v}"
|
||||
echo "Resolved to ${TAG}"
|
||||
|
||||
# Discovered from the real release, not assumed: Tauri names the
|
||||
# asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||
# of thing that silently drifts if a future Tauri upgrade changes
|
||||
# bundler defaults — a hardcoded pattern here would then 404
|
||||
# forever until someone noticed. `head -1` guards against a release
|
||||
# somehow carrying more than one matching asset, which would
|
||||
# otherwise pass the emptiness check below and then break the
|
||||
# download step with two URLs on one line.
|
||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found asset: ${DEB_NAME}"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download the release asset and compute real checksums
|
||||
id: checksums
|
||||
env:
|
||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||
|
||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD
|
||||
id: render
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p rendered
|
||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||
cd rendered
|
||||
|
||||
# Plain string replacement throughout, not sed — the source URL
|
||||
# contains slashes and the repo name does too, and getting a sed
|
||||
# delimiter choice AND its escaping right for that is exactly the
|
||||
# kind of thing that looks correct, passes review, and breaks the
|
||||
# next time someone touches it. `re.sub` with `count=1` and an
|
||||
# exact `.format`-free literal match is boring and that's the
|
||||
# point: every substitution below fails loudly (an assertion /
|
||||
# the checks after) rather than silently no-op'ing if the
|
||||
# template's shape ever drifts from what this expects.
|
||||
#
|
||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||
# for, and this workflow always republishes the current PKGBUILD
|
||||
# verbatim rather than incrementing anything, so 1 is always
|
||||
# correct for what this workflow does. It is NOT correct for a
|
||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||
# would be forced back to 1, and no existing installation sees an
|
||||
# upgrade. That case needs a manual pkgrel bump in the template
|
||||
# before dispatching, which this workflow has no input for.
|
||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||
import re, sys
|
||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||
|
||||
with open("PKGBUILD") as f:
|
||||
text = f.read()
|
||||
|
||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||
assert n == 1, "pkgver=... line not found"
|
||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||
assert n == 1, "pkgrel=... line not found"
|
||||
|
||||
old_source = (
|
||||
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
|
||||
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
|
||||
f'Triple-C_${{pkgver}}_amd64.deb"'
|
||||
)
|
||||
new_source = (
|
||||
f'source=("{deb_name}::'
|
||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||
)
|
||||
assert old_source in text, "source=() line does not match the expected template shape"
|
||||
text = text.replace(old_source, new_source, 1)
|
||||
|
||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||
|
||||
with open("PKGBUILD", "w") as f:
|
||||
f.write(text)
|
||||
PY
|
||||
|
||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||
# obvious way to write this, and was the first draft — but on a
|
||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||
# job's own container, which the daemon's host cannot resolve; the
|
||||
# mount would silently attach an empty directory instead of failing
|
||||
# loudly. `docker cp` moves real bytes across that boundary
|
||||
# regardless of where the daemon actually lives, which is what
|
||||
# makes this work under both a bind-mount-capable runner and a
|
||||
# containerized one.
|
||||
docker pull archlinux:latest
|
||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||
useradd -m builder
|
||||
chown -R builder:builder /work
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||
echo "--- namcap ---"
|
||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||
echo "$NAMCAP_OUT"
|
||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||
# uses more than one line shape for its two rule families, and
|
||||
# namcap itself exits 0 regardless of what it reports, so this
|
||||
# grep is the only thing standing between an E: and a green job.
|
||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||
echo "namcap reported an error — see above" >&2
|
||||
exit 1
|
||||
fi
|
||||
')
|
||||
mkdir -p rendered
|
||||
docker cp rendered/. "${CID}:/work"
|
||||
# `docker start -a` streams output and its exit code is the
|
||||
# container's own — the same failure this would have hit with a
|
||||
# bind mount still fails the job the same way.
|
||||
docker start -a "${CID}"
|
||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||
docker rm -f "${CID}" >/dev/null
|
||||
|
||||
- name: Push to AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
|
||||
echo "the one-time AUR account setup this needs before it can publish anything." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
# Created with the final mode before any bytes land in it, rather
|
||||
# than a plain redirect followed by chmod, which leaves the key
|
||||
# world-readable for whatever window falls between the two calls.
|
||||
install -m 600 /dev/null ~/.ssh/aur
|
||||
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
# TOFU, not verification — accepted here because pinning AUR's
|
||||
# actual host key needs a value fetched from somewhere trusted
|
||||
# ahead of time, which this workflow doesn't have, and getting a
|
||||
# pinned value wrong fails every future run rather than just this
|
||||
# one. A keyscan failure below surfaces later as an opaque
|
||||
# "Host key verification failed" rather than a clear one here.
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
|
||||
|
||||
git clone "${AUR_REPO}" aur-repo
|
||||
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
|
||||
cd aur-repo
|
||||
git config user.name "Triple-C CI"
|
||||
git config user.email "noreply@triple-c.invalid"
|
||||
git add PKGBUILD .SRCINFO
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change from what's already published on AUR for ${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "triple-c-bin: update to ${VERSION}"
|
||||
# AUR itself uses `master`, which is what a fresh, not-yet-created
|
||||
# AUR package's empty repo advertises on clone — but the *local*
|
||||
# branch name after cloning an empty repo falls back to whatever
|
||||
# this runner's `init.defaultBranch` is if the server sends no
|
||||
# symref, so naming the destination explicitly is what keeps this
|
||||
# working if that default is ever `main` instead of `master`.
|
||||
git push origin HEAD:master
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
+8
-3
@@ -412,13 +412,18 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
|
||||
│
|
||||
├── packaging/
|
||||
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
|
||||
│ ├── PKGBUILD
|
||||
│ └── README.md
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
|
||||
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -696,7 +696,7 @@ pub async fn add_project(
|
||||
pub async fn remove_project(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<ProjectRemovalReport, String> {
|
||||
// **H-2: the only writer of these three categories that held nothing.**
|
||||
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
||||
// both named volumes — and a compaction resolves that same tag when its
|
||||
@@ -722,12 +722,76 @@ pub async fn remove_project(
|
||||
// holding an entire snapshot image that nothing will ever reference again.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// Stop and remove container if it exists
|
||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Stop and remove container if it exists. Everything named in `report`
|
||||
// below is what will be unreachable the moment this function drops the
|
||||
// project record — see [`ProjectRemovalReport`] and
|
||||
// `storage::pending_cleanup`, which is what makes it reachable anyway.
|
||||
let mut report = ProjectRemovalReport::default();
|
||||
let existing_project = state.projects_store.get(&project_id);
|
||||
|
||||
if let Some(ref project) = existing_project {
|
||||
// Resolved via `find_existing_container` unconditionally rather than
|
||||
// trusting `project.container_id` — that field can be *stale*, not
|
||||
// just absent: `start_project_container_locked`'s recreate path
|
||||
// removes the old container, creates a new one, and does not persist
|
||||
// the new id until after `start_container` succeeds, so a start
|
||||
// failure in between (a missing `/dev/net/tun`, an image that exits
|
||||
// immediately) leaves the stored id pointing at a container that no
|
||||
// longer exists while a live one sits under the same deterministic
|
||||
// name. Removing by a stale id then 404s — success as far as Docker
|
||||
// is concerned — while the real container survives to block every
|
||||
// subsequent volume removal with a 409, with nothing in the report
|
||||
// ever naming it. `find_existing_container` is what every other
|
||||
// destroyer of a project's container already resolves through
|
||||
// (`start_project_container`, migration's recreate paths) for this
|
||||
// exact reason.
|
||||
//
|
||||
// A `Docker unreachable` error here is treated as "assume a
|
||||
// container is still there" rather than "assume none is", matching
|
||||
// `remove_volumes_by_name`'s fail-closed handling of the same
|
||||
// situation — the alternative silently drops the one resource most
|
||||
// likely to block everything else if it does exist.
|
||||
//
|
||||
// Exec sessions are closed for `project.container_id` unconditionally,
|
||||
// before the lookup above and regardless of whether it succeeds —
|
||||
// that is host-side state with no Docker dependency, so it must not
|
||||
// wait on a daemon that might not answer. Resolving through
|
||||
// `find_existing_container` instead of using it directly would leave
|
||||
// these open in exactly the two cases this whole change exists to
|
||||
// handle: Docker unreachable (no id resolved, no way to ever close
|
||||
// them again once the project record is gone) and the stale-id race
|
||||
// (sessions were opened against the container that actually exists,
|
||||
// which is what gets resolved below, not the stored id).
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = match docker::find_existing_container(project).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not check for an existing container for project {}: {}",
|
||||
project_id, e
|
||||
);
|
||||
report.container = Some(project.container_name());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
let _ = docker::remove_container(container_id).await;
|
||||
if let Err(e) = docker::remove_container(container_id).await {
|
||||
log::warn!(
|
||||
"Failed to remove container {} for project {}: {}",
|
||||
container_id, project_id, e
|
||||
);
|
||||
// Recorded by name, not id: the name is the stable handle a
|
||||
// later retry can still resolve (Docker's remove-container
|
||||
// call accepts either), and it is what `container_ref` above
|
||||
// falls back to finding in the first place.
|
||||
report.container = Some(project.container_name());
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
@@ -738,10 +802,9 @@ pub async fn remove_project(
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
report.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
}
|
||||
report.volumes = docker::remove_project_volumes(project).await;
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -749,7 +812,216 @@ pub async fn remove_project(
|
||||
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
state.projects_store.remove(&project_id)
|
||||
if !report.is_clean() {
|
||||
let record = crate::storage::pending_cleanup::PendingCleanup {
|
||||
project_id: project_id.clone(),
|
||||
project_name: existing_project.map(|p| p.name).unwrap_or_default(),
|
||||
container_id: report.container.clone(),
|
||||
image: report.image.clone(),
|
||||
volumes: report.volumes.clone(),
|
||||
recorded_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
match crate::storage::pending_cleanup::save(&record) {
|
||||
Ok(()) => {
|
||||
report.retry_scheduled = true;
|
||||
log::warn!(
|
||||
"Project {} removed; could not confirm these Docker resources were removed: \
|
||||
{:?} — recorded for automatic retry on next launch",
|
||||
project_id, report
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
report.retry_scheduled = false;
|
||||
log::error!(
|
||||
"Project {} removed; could not confirm these Docker resources were removed \
|
||||
({:?}), and the pending-cleanup record could not be written ({}) — nothing \
|
||||
will retry removing them",
|
||||
project_id, report, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pending-cleanup record above must not outlive the project record it
|
||||
// describes: if the store's own write fails (full disk, permissions) the
|
||||
// project is still on disk and will reload on the next launch, but the
|
||||
// record would tell startup housekeeping to delete its container and
|
||||
// volumes out from under it. Roll the record back rather than leaving
|
||||
// that mismatch for the retry to discover the hard way.
|
||||
//
|
||||
// This is a second, narrower line of defence, not the only one — a crash
|
||||
// between the `save` above and the `remove` below leaves exactly the same
|
||||
// mismatch with no error for either side to catch, which is why
|
||||
// `retry_pending_cleanup_logged` also refuses to act on a record whose
|
||||
// project is still listed in `projects.json`. Belt and suspenders: a
|
||||
// caught failure here is handled immediately rather than waiting for the
|
||||
// next launch to notice.
|
||||
if let Err(e) = state.projects_store.remove(&project_id) {
|
||||
if !report.is_clean() {
|
||||
if let Err(clear_err) = crate::storage::pending_cleanup::clear(&project_id) {
|
||||
log::error!(
|
||||
"Project {} was not removed ({}), and its pending-cleanup record could not \
|
||||
be rolled back either ({}) — it will name this still-live project until \
|
||||
startup housekeeping's own guard clears it",
|
||||
project_id, e, clear_err
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Retry every pending-cleanup record left behind by a [`remove_project`]
|
||||
/// that could not finish. Run once at startup alongside the other reapers
|
||||
/// (see `lib.rs`'s "Startup disk housekeeping" block) — never on a timer and
|
||||
/// never blocking anything, since a locked volume or an in-use image can sit
|
||||
/// unresolved for an arbitrary amount of time and the daemon may not even be
|
||||
/// up yet.
|
||||
///
|
||||
/// Not a `#[tauri::command]`: nothing in the UI surfaces this list yet
|
||||
/// (deliberately — see `SnapshotSweepReport`'s doc comment for the same
|
||||
/// reasoning), so there is no IPC contract to keep. A record that still has
|
||||
/// leftovers after this is written back so the next run does not lose track
|
||||
/// of what changed; one that is now empty is deleted.
|
||||
///
|
||||
/// Takes the `ProjectsStore` so it can refuse to touch a project that is
|
||||
/// still live: `remove_project` writes a pending-cleanup record durably
|
||||
/// (fsync'd) *before* it asks the store to drop the project, and that
|
||||
/// store write is a plain `fs::write` with no fsync of its own. A crash or
|
||||
/// power loss in the gap between the two — or the store write failing
|
||||
/// outright, on top of the round-2 fix that only rolls the record back when
|
||||
/// that failure is caught in-process — can leave a record on disk pointing
|
||||
/// at a project `projects.json` still lists. Without this check, the very
|
||||
/// first retry after such a crash deletes that project's container,
|
||||
/// snapshot image and *both volumes, including the one holding the OAuth
|
||||
/// credential and every session transcript*, out from under a project the
|
||||
/// user still sees in the sidebar. A record whose project still exists is
|
||||
/// therefore always stale — cleared without touching Docker, not retried.
|
||||
pub async fn retry_pending_cleanup_logged(projects_store: &crate::storage::projects_store::ProjectsStore) {
|
||||
let records = crate::storage::pending_cleanup::list();
|
||||
if records.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cleaned = 0usize;
|
||||
let mut still_pending = 0usize;
|
||||
|
||||
for mut record in records {
|
||||
if projects_store.get(&record.project_id).is_some() {
|
||||
log::warn!(
|
||||
"Pending cleanup record for project {} ({}) names a project that still exists — \
|
||||
clearing the record without touching Docker rather than risk deleting a live \
|
||||
project's resources",
|
||||
record.project_id, record.project_name
|
||||
);
|
||||
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
|
||||
log::error!(
|
||||
"Could not clear the stale pending-cleanup record for still-live project {} \
|
||||
({}): {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(container_id) = record.container_id.take() {
|
||||
match docker::remove_container(&container_id).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Pending cleanup: still could not remove container {} for project {} \
|
||||
({}): {}",
|
||||
container_id, record.project_id, record.project_name, e
|
||||
);
|
||||
record.container_id = Some(container_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(image) = record.image.take() {
|
||||
match docker::remove_image_by_name(&image).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Pending cleanup: still could not remove image {} for project {} ({}): {}",
|
||||
image, record.project_id, record.project_name, e
|
||||
);
|
||||
record.image = Some(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !record.volumes.is_empty() {
|
||||
record.volumes = docker::remove_volumes_by_name(&record.volumes).await;
|
||||
}
|
||||
|
||||
if record.is_empty() {
|
||||
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
|
||||
log::warn!(
|
||||
"Pending cleanup for project {} ({}) finished but the record could not be \
|
||||
deleted: {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
cleaned += 1;
|
||||
} else {
|
||||
still_pending += 1;
|
||||
// `recorded_at` is otherwise write-only — nothing read it back,
|
||||
// which is exactly the shape `storage::migration_store` calls out
|
||||
// as a bug in its own history ("nothing ever removed them"). A
|
||||
// record that has failed every retry for a week is no longer
|
||||
// routine: escalate the log level so it is not indistinguishable
|
||||
// from one seen for the first time.
|
||||
match pending_cleanup_is_stale(&record.recorded_at, chrono::Utc::now()) {
|
||||
Some(true) => {
|
||||
log::error!(
|
||||
"Pending cleanup for project {} ({}) has not succeeded in over {} \
|
||||
days: {:?} — this may need a manual `docker volume rm` / \
|
||||
`docker rmi` / `docker rm`",
|
||||
record.project_id, record.project_name, PENDING_CLEANUP_STALE_AFTER_DAYS, record
|
||||
);
|
||||
}
|
||||
Some(false) => {}
|
||||
// Silent otherwise would mean a record with a corrupted
|
||||
// timestamp never escalates and nothing says why.
|
||||
None => log::debug!(
|
||||
"Pending cleanup record for project {} ({}) has an unreadable recorded_at \
|
||||
({:?}) — its age cannot be tracked",
|
||||
record.project_id, record.project_name, record.recorded_at
|
||||
),
|
||||
}
|
||||
if let Err(e) = crate::storage::pending_cleanup::save(&record) {
|
||||
log::warn!(
|
||||
"Could not update pending cleanup record for project {} ({}): {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Pending cleanup retry: {} project(s) fully cleaned up, {} still have leftovers",
|
||||
cleaned, still_pending
|
||||
);
|
||||
}
|
||||
|
||||
/// After this many days of a pending-cleanup record failing every retry,
|
||||
/// `retry_pending_cleanup_logged` escalates its log line from `warn` to
|
||||
/// `error` — see the comment at its call site.
|
||||
const PENDING_CLEANUP_STALE_AFTER_DAYS: i64 = 7;
|
||||
|
||||
/// Whether a pending-cleanup record's `recorded_at` is older than
|
||||
/// [`PENDING_CLEANUP_STALE_AFTER_DAYS`], measured against `now`. `None` means
|
||||
/// the timestamp could not be parsed at all — a corrupted or (hypothetically)
|
||||
/// hand-edited record — which callers must not silently treat as "not stale"
|
||||
/// without saying why. `now` is a parameter rather than read internally so
|
||||
/// this is testable without a live clock.
|
||||
fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc>) -> Option<bool> {
|
||||
let recorded = chrono::DateTime::parse_from_rfc3339(recorded_at)
|
||||
.ok()?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Some(now.signed_duration_since(recorded) > chrono::Duration::days(PENDING_CLEANUP_STALE_AFTER_DAYS))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1226,7 +1498,7 @@ pub async fn rebuild_project_container(
|
||||
project_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
) -> Result<ProjectResetOutcome, String> {
|
||||
// Reset deletes both volumes and the snapshot image. Doing that while a
|
||||
// migration is mid-flight pulls the ground out from under it and leaves an
|
||||
// orphan migration record pointing at images that no longer exist — and
|
||||
@@ -1253,25 +1525,58 @@ pub async fn rebuild_project_container(
|
||||
// `start_project_container` below re-arms it against the new one.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
// Remove existing container
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Remove existing container. Resolved via `find_existing_container`
|
||||
// unconditionally, not `project.container_id` — see the long comment in
|
||||
// `remove_project` for why that field can be stale, not just absent. A
|
||||
// container this misses blocks the volume removal immediately below with
|
||||
// a 409, and Reset silently keeping the old volumes is exactly the bug
|
||||
// this whole change is closing. Unlike `remove_project`'s best-effort
|
||||
// handling of the same lookup failing, `?` here aborts Reset outright:
|
||||
// every step after this one needs Docker too, so there is no useful
|
||||
// partial progress to make without it.
|
||||
// Closed for the stored id unconditionally, then again for the resolved
|
||||
// one if it differs — see the matching comment in `remove_project` for
|
||||
// why the stale-id race can leave sessions open under either identity.
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = docker::find_existing_container(&project).await?;
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
// Remove snapshot image + volumes so Reset creates from the clean base image
|
||||
// Remove snapshot image + volumes so Reset creates from the clean base
|
||||
// image. Both leftovers are surfaced, not just logged — an image that
|
||||
// survives is the more serious of the two, since
|
||||
// `start_project_container_locked` below builds from
|
||||
// `triple-c-snapshot-{id}:latest` whenever it exists, so a leftover image
|
||||
// means Reset silently rebuilds the exact system layer it promised to
|
||||
// discard. No pending-cleanup record for either: unlike `remove_project`,
|
||||
// Reset keeps the project record, so a later Reset attempt can retry
|
||||
// these itself rather than needing startup housekeeping to do it.
|
||||
let mut leftover_image = None;
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
leftover_image = Some(docker::get_snapshot_image_name(&project));
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(&project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||
if leftover_image.is_some() || !leftover_volumes.is_empty() {
|
||||
log::warn!(
|
||||
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
|
||||
new container may be built from, or reuse, old contents instead of starting clean",
|
||||
project_id, leftover_image, leftover_volumes
|
||||
);
|
||||
}
|
||||
|
||||
// Start fresh. The locked variant, because `_guard` above is this project's
|
||||
// claim and the public command would be refused by it.
|
||||
start_project_container_locked(project_id, app_handle, state).await
|
||||
let project = start_project_container_locked(project_id, app_handle, state).await?;
|
||||
Ok(ProjectResetOutcome { project, leftover_image, leftover_volumes })
|
||||
}
|
||||
|
||||
/// Reconcile project statuses against actual Docker container state.
|
||||
@@ -1379,6 +1684,54 @@ fn default_docker_socket() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Pending-cleanup aging ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_record_younger_than_the_threshold_is_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-19T00:00:00Z"; // 6 days before `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_exactly_at_the_threshold_is_not_yet_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-18T00:00:00Z"; // exactly 7 days before `now`
|
||||
assert_eq!(
|
||||
pending_cleanup_is_stale(recorded_at, now),
|
||||
Some(false),
|
||||
"the boundary itself must not already read as stale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_older_than_the_threshold_is_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-17T00:00:00Z"; // 8 days before `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(true));
|
||||
}
|
||||
|
||||
/// A clock that ran fast when the record was written leaves a timestamp
|
||||
/// in the future. This must read as "not stale" rather than underflow or
|
||||
/// panic — `signed_duration_since` returns a negative `Duration` here,
|
||||
/// which compares less than any positive threshold correctly.
|
||||
#[test]
|
||||
fn a_timestamp_in_the_future_is_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-26T00:00:00Z"; // one day after `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
|
||||
}
|
||||
|
||||
/// A corrupted or hand-edited `recorded_at` must not silently read as
|
||||
/// "not stale" through some default — callers need to be able to tell
|
||||
/// "definitely not stale" apart from "cannot tell".
|
||||
#[test]
|
||||
fn an_unparseable_recorded_at_reports_unknown_rather_than_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
assert_eq!(pending_cleanup_is_stale("not a timestamp", now), None);
|
||||
assert_eq!(pending_cleanup_is_stale("", now), None);
|
||||
}
|
||||
|
||||
fn path(host: &str, mount: &str) -> ProjectPath {
|
||||
ProjectPath {
|
||||
host_path: host.to_string(),
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
@@ -1935,7 +1935,7 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||
container_id
|
||||
);
|
||||
docker
|
||||
match docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
@@ -1945,7 +1945,17 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
// Already gone is the outcome this call wants, not a failure — a
|
||||
// caller retrying a leftover from a previous, partially-failed removal
|
||||
// (see `remove_project`) must not be told it failed forever just
|
||||
// because a *different* attempt already succeeded.
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove container: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the snapshot image name for a project.
|
||||
@@ -3496,13 +3506,27 @@ async fn rewrite_image_without_secrets(
|
||||
}
|
||||
|
||||
/// Remove the snapshot image for a project (used on Reset / project removal).
|
||||
///
|
||||
/// A project that never started never built a snapshot, so "no such image" is
|
||||
/// the ordinary case, not a failure — it is treated the same as success and
|
||||
/// logged at most at `info`. A real failure (the image is in use, a
|
||||
/// permission error, the daemon dropped the connection) is the one thing this
|
||||
/// returns `Err` for, and callers must not throw that away: see the
|
||||
/// `remove_project` doc comment on `ProjectRemovalReport` for why an
|
||||
/// unreported failure here used to make the resource unreachable forever.
|
||||
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_snapshot_image_name(project);
|
||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||
}
|
||||
|
||||
docker
|
||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
||||
/// only has the image name (the project record is already gone by then).
|
||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
match docker
|
||||
.remove_image(
|
||||
&image_name,
|
||||
image_name,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
@@ -3510,25 +3534,91 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
||||
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove snapshot image {}: {}", image_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove both named volumes for a project (used on Reset / project removal).
|
||||
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
for vol in [
|
||||
///
|
||||
/// Returns the names of volumes that still exist afterwards — empty means
|
||||
/// both are gone (removed here, or never created). This used to always
|
||||
/// return `Ok(())` regardless of what actually happened, which made the
|
||||
/// `if let Err(e)` at every call site unreachable by construction; see
|
||||
/// triple-c#31. A volume Docker reports as simply not existing is not a
|
||||
/// leftover and is not included.
|
||||
pub async fn remove_project_volumes(project: &Project) -> Vec<String> {
|
||||
remove_volumes_by_name(&[
|
||||
home_volume_name(&project.id),
|
||||
config_volume_name(&project.id),
|
||||
] {
|
||||
match docker.remove_volume(&vol, None).await {
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remove a set of named volumes, treating "does not exist" as success.
|
||||
/// Returns the names that still exist afterwards — empty means every one is
|
||||
/// gone (removed here, or never created).
|
||||
///
|
||||
/// Shared by [`remove_project_volumes`] and the pending-cleanup retry, the
|
||||
/// latter calling this with whatever the former could not remove the first
|
||||
/// time. Used to always report success regardless of what actually happened,
|
||||
/// which made every `if let Err(e)` at its call sites unreachable by
|
||||
/// construction; see triple-c#31.
|
||||
pub async fn remove_volumes_by_name(names: &[String]) -> Vec<String> {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
// Can't reach the daemon to even try, so nothing here can be
|
||||
// confirmed removed. Reporting all as leftover is the safe
|
||||
// direction: worst case a later retry finds them already gone.
|
||||
log::warn!("Could not remove volumes {:?}: {}", names, e);
|
||||
return names.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
let mut leftover = Vec::new();
|
||||
for vol in names {
|
||||
match remove_one_volume_with_retry(&docker, vol).await {
|
||||
Ok(_) => log::info!("Removed volume {}", vol),
|
||||
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to remove volume {}: {}", vol, e);
|
||||
leftover.push(vol.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
leftover
|
||||
}
|
||||
|
||||
/// Remove one volume, retrying once after a short delay on a 409 ("volume is
|
||||
/// in use"). Docker releasing a volume's mount reference after the container
|
||||
/// using it is removed is not always instantaneous, so the very first call
|
||||
/// site of this — `remove_project`, whose container removal lands
|
||||
/// immediately before its volume removal — could otherwise turn an ordinary
|
||||
/// race into a permanent pending-cleanup record and an alarming toast for
|
||||
/// something that would have cleared itself half a second later.
|
||||
async fn remove_one_volume_with_retry(
|
||||
docker: &bollard::Docker,
|
||||
name: &str,
|
||||
) -> Result<(), bollard::errors::Error> {
|
||||
match docker.remove_volume(name, None).await {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 409, ..
|
||||
}) => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
docker.remove_volume(name, None).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the existing container's configuration still matches the
|
||||
|
||||
@@ -250,6 +250,7 @@ pub fn run() {
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
@@ -257,6 +258,15 @@ pub fn run() {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
// A container/image/volume `remove_project` could not delete
|
||||
// is recorded rather than lost — see triple-c#31 — and this is
|
||||
// the only place anything ever retries it. Takes the store so
|
||||
// it can refuse to touch a project that turns out to still be
|
||||
// live — see the long comment on the function itself.
|
||||
crate::commands::project_commands::retry_pending_cleanup_logged(
|
||||
&projects_store_for_cleanup,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone. That includes
|
||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||
/// safe direction to be wrong in.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
/// call. Narrow in practice (an already-running browser just receives the
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -422,6 +422,61 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||
/// of reporting a clean removal that was not one.
|
||||
///
|
||||
/// The project record is dropped from `projects.json` regardless — see the
|
||||
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||
/// anything named here is also written to a pending-cleanup record that
|
||||
/// startup housekeeping retries, so it stays reachable after the project it
|
||||
/// belonged to no longer exists.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectRemovalReport {
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||
/// not the container id, since the id can be stale or absent and the
|
||||
/// name is what a later retry can still resolve.
|
||||
pub container: Option<String>,
|
||||
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||
pub image: Option<String>,
|
||||
/// Named volumes (home, claude config) that could not be removed.
|
||||
pub volumes: Vec<String>,
|
||||
/// True once the leftovers above were durably recorded for automatic
|
||||
/// retry on the next launch. False means the pending-cleanup record
|
||||
/// itself could not be written — nothing will retry these, and the UI
|
||||
/// must say so rather than promising a retry that will not happen.
|
||||
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||
pub retry_scheduled: bool,
|
||||
}
|
||||
|
||||
impl ProjectRemovalReport {
|
||||
/// True when nothing was left behind.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||
/// stands after restarting, and anything Reset could not clear.
|
||||
///
|
||||
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||
/// there is no pending-cleanup record for either: the project id survives
|
||||
/// Reset, so a later Reset attempt can retry them itself.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectResetOutcome {
|
||||
pub project: Project,
|
||||
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||
/// more serious of the two leftovers here: the new container is created
|
||||
/// from this image whenever it exists, so a surviving image means Reset
|
||||
/// silently rebuilt the exact system layer it was asked to discard.
|
||||
pub leftover_image: Option<String>,
|
||||
/// Volumes that survived Reset and were mounted into the new container
|
||||
/// unchanged.
|
||||
pub leftover_volumes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
@@ -650,6 +705,25 @@ impl Project {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||
assert!(ProjectRemovalReport::default().is_clean());
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.container = Some("abc123".to_string());
|
||||
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.volumes.push("triple-c-home-x".to_string());
|
||||
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||
}
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod migration_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||
//!
|
||||
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||
//! in the app can name the leftover container, image or volume again by any
|
||||
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||
//! written *before* the project record is dropped. Startup housekeeping
|
||||
//! retries every record on the next launch (see
|
||||
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||
//! the ones that fully succeed.
|
||||
//!
|
||||
//! **This record is written in the same instant its record in `projects.json`
|
||||
//! is destroyed, and it is the only remaining handle on the leftover
|
||||
//! resource** — which is a stronger claim on durability than an ordinary
|
||||
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||
//! to other readers, not to power loss. A crash in that window leaves the
|
||||
//! rename applied and the data half-written, which [`list`] then treats as
|
||||
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||
//! directory-sync shape `migration_store` does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCleanup {
|
||||
pub project_id: String,
|
||||
/// Kept only so a log line or a future UI can name the project without a
|
||||
/// second lookup — the project record itself is already gone by the time
|
||||
/// this is read back.
|
||||
pub project_name: String,
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||
/// container id Docker handed out — Docker's remove-container API
|
||||
/// accepts either, and the name is the one identifier guaranteed to still
|
||||
/// resolve to the same container by the time a retry runs.
|
||||
pub container_id: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
impl PendingCleanup {
|
||||
/// True once nothing named here still needs to be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||
fn dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("pending-cleanup");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||
/// `storage::migration_store::sanitize`.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) a project's pending-cleanup record.
|
||||
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||
save_in(&dir()?, record)
|
||||
}
|
||||
|
||||
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(&dir()?, project_id)
|
||||
}
|
||||
|
||||
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||
/// skipped rather than blocking every other project's retry — the same
|
||||
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||
pub fn list() -> Vec<PendingCleanup> {
|
||||
let Ok(dir) = dir() else { return Vec::new() };
|
||||
list_in(&dir)
|
||||
}
|
||||
|
||||
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||
/// after it — see the module doc comment for why a plain
|
||||
/// write-temp-then-rename is not enough here. Mirrors
|
||||
/// `storage::migration_store::save`/`sync_dir`.
|
||||
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||
let path = path_in(dir, &record.project_id);
|
||||
let data = serde_json::to_string_pretty(record)
|
||||
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let path = path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let data = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) => {
|
||||
// Moved aside rather than left in place: a record nothing
|
||||
// ever repairs would otherwise warn on every single
|
||||
// startup forever, same as an ordinary `.json` file it
|
||||
// would keep looking like one to `list_in` on the next
|
||||
// call too. One aside-copy is enough here — this only
|
||||
// ever holds names to retry removing, not the class of
|
||||
// once-in-a-lifetime crash evidence `migration_store`
|
||||
// keeps multiple timestamped backups of.
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||
log::warn!(
|
||||
"Could not parse pending cleanup record {}: {}{}",
|
||||
path.display(),
|
||||
err,
|
||||
if moved {
|
||||
format!(" — moved aside to {}", corrupt.display())
|
||||
} else {
|
||||
" — leaving it in place".to_string()
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power
|
||||
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||
/// private to that module, so this is a small deliberate duplicate rather
|
||||
/// than a shared dependency between two otherwise-independent stores.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-pending-cleanup-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn record(project_id: &str) -> PendingCleanup {
|
||||
PendingCleanup {
|
||||
project_id: project_id.to_string(),
|
||||
project_name: "Some Project".to_string(),
|
||||
container_id: Some("triple-c-abc".to_string()),
|
||||
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||
volumes: vec!["triple-c-home-abc".to_string()],
|
||||
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||
let mut r = record("p1");
|
||||
assert!(!r.is_empty());
|
||||
|
||||
r.container_id = None;
|
||||
r.image = None;
|
||||
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||
|
||||
r.volumes.clear();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||
/// re-implementation of their bodies — against a temp directory standing
|
||||
/// in for `dir()`.
|
||||
#[test]
|
||||
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let rec = record("proj-1");
|
||||
|
||||
save_in(&dir, &rec).expect("save");
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-1");
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
clear_in(&dir, "proj-1").expect("clear");
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A second `save` for the same project overwrites rather than appending
|
||||
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||
/// record behind it.
|
||||
#[test]
|
||||
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||
let dir = temp_dir("overwrite");
|
||||
let mut rec = record("proj-1");
|
||||
save_in(&dir, &rec).expect("save");
|
||||
|
||||
rec.container_id = None;
|
||||
rec.image = None;
|
||||
save_in(&dir, &rec).expect("save again");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||
assert!(found[0].container_id.is_none());
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse must not poison the rest of the listing.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||
let dir = temp_dir("corrupt");
|
||||
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||
save_in(&dir, &record("proj-2")).expect("save");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-2");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse is moved aside once, rather than left in
|
||||
/// place to be re-warned about — and re-warned about — on every future
|
||||
/// launch forever.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||
let dir = temp_dir("corrupt-aside");
|
||||
let bad = dir.join("bad.json");
|
||||
fs::write(&bad, "{ not json").unwrap();
|
||||
|
||||
list_in(&dir);
|
||||
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||
let corrupt = dir.join("bad.json.corrupt");
|
||||
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||
|
||||
// A second pass must not warn about `bad.json` again — it is gone —
|
||||
// and must not choke on `.json.corrupt` already being there.
|
||||
assert!(list_in(&dir).is_empty());
|
||||
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||
/// leaves behind if a crash lands between the write and the rename — the
|
||||
/// whole point of the temp-then-rename dance is that only the renamed
|
||||
/// file is ever a complete record.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_not_listed() {
|
||||
let dir = temp_dir("tmp-leftover");
|
||||
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing by project id must remove exactly the file that id maps to
|
||||
/// under `sanitize`, and nothing else.
|
||||
#[test]
|
||||
fn clearing_one_project_does_not_touch_another() {
|
||||
let dir = temp_dir("clear-scoped");
|
||||
save_in(&dir, &record("proj-a")).unwrap();
|
||||
save_in(&dir, &record("proj-b")).unwrap();
|
||||
|
||||
clear_in(&dir, "proj-a").unwrap();
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-b");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -282,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
const report = await remove(project.id);
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
const verb = leftoverVerb(report);
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeLeftovers, leftoverVerb } from "./removalReport";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
function report(overrides: Partial<ProjectRemovalReport> = {}): ProjectRemovalReport {
|
||||
return {
|
||||
container: null,
|
||||
image: null,
|
||||
volumes: [],
|
||||
retry_scheduled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectRemovalIsClean", () => {
|
||||
it("is true only when nothing survived", () => {
|
||||
expect(projectRemovalIsClean(report())).toBe(true);
|
||||
expect(projectRemovalIsClean(report({ container: "triple-c-abc" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ image: "triple-c-snapshot-abc:latest" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ volumes: ["triple-c-home-abc"] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeLeftovers", () => {
|
||||
it("names each kind of leftover", () => {
|
||||
expect(describeLeftovers(report({ container: "triple-c-abc" }))).toBe("its container");
|
||||
expect(describeLeftovers(report({ image: "x" }))).toBe("its saved image");
|
||||
expect(describeLeftovers(report({ volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeLeftovers(report({ volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
});
|
||||
|
||||
it("joins multiple kinds together", () => {
|
||||
expect(
|
||||
describeLeftovers(report({ container: "triple-c-abc", image: "x", volumes: ["v1", "v2"] })),
|
||||
).toBe("its container, its saved image, 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leftoverVerb", () => {
|
||||
it("is singular for exactly one leftover of any kind", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ image: "x" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ volumes: ["v1"] }))).toBe("was");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived, including multiple volumes alone", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc", image: "x" }))).toBe("were");
|
||||
expect(leftoverVerb(report({ volumes: ["v1", "v2"] }))).toBe("were");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
/**
|
||||
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
|
||||
*
|
||||
* Worded as "could not confirm" rather than "is still on disk": the same
|
||||
* report shape covers a genuine leftover (a locked volume) and a daemon that
|
||||
* was simply unreachable at the time, in which case nothing was ever created
|
||||
* and there is nothing to find — asserting certainty either way would be
|
||||
* wrong in one of those cases.
|
||||
*/
|
||||
export function describeLeftovers(report: ProjectRemovalReport): string {
|
||||
const parts: string[] = [];
|
||||
if (report.container) parts.push("its container");
|
||||
if (report.image) parts.push("its saved image");
|
||||
if (report.volumes.length === 1) parts.push("a volume");
|
||||
else if (report.volumes.length > 1) parts.push(`${report.volumes.length} volumes`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** How many distinct things `describeLeftovers` is describing — a container
|
||||
* and an image each count as one, however many volumes are named. Shared by
|
||||
* `leftoverVerb` and `leftoverPronoun` so the two can never disagree about
|
||||
* singular vs. plural. */
|
||||
function leftoverCount(report: ProjectRemovalReport): number {
|
||||
return (report.container ? 1 : 0) + (report.image ? 1 : 0) + report.volumes.length;
|
||||
}
|
||||
|
||||
/** Verb agreement for `describeLeftovers`'s output — "its container" needs
|
||||
* "was", "its container, a volume" needs "were". */
|
||||
export function leftoverVerb(report: ProjectRemovalReport): "was" | "were" {
|
||||
return leftoverCount(report) === 1 ? "was" : "were";
|
||||
}
|
||||
|
||||
/** Pronoun agreement for referring back to `describeLeftovers`'s output —
|
||||
* "remove it manually" for one thing, "remove them manually" for more. */
|
||||
export function leftoverPronoun(report: ProjectRemovalReport): "it" | "them" {
|
||||
return leftoverCount(report) === 1 ? "it" : "them";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { save } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
@@ -28,13 +29,14 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (label: string, fn: () => Promise<unknown>) => {
|
||||
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
setBusy(true);
|
||||
setContainerProgress(project.id, null);
|
||||
try {
|
||||
await fn();
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
fail(`${label} failed for “${project.name}”`, e);
|
||||
return undefined;
|
||||
} finally {
|
||||
setContainerProgress(project.id, null);
|
||||
setBusy(false);
|
||||
@@ -54,8 +56,25 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => run("Reset", () => rebuild(project.id)),
|
||||
[run, rebuild, project.id],
|
||||
() =>
|
||||
run("Reset", async () => {
|
||||
const outcome = await rebuild(project.id);
|
||||
if (outcome.leftover_image || outcome.leftover_volumes.length > 0) {
|
||||
// Not "run `docker volume rm`" — by the time this renders, the new
|
||||
// container this same call just started already has the leftover
|
||||
// volume mounted, so that command would just hit the same 409
|
||||
// Reset did. Stopping the project first is what actually frees it.
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Reset for “${project.name}” did not fully clean up`,
|
||||
detail: `Triple-C could not remove ${describeResetLeftovers(outcome)} from before the reset, so \
|
||||
the new container may still be built from, or contain, old data. Stop the project, then try \
|
||||
Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}),
|
||||
[run, rebuild, project.id, project.name, pushToast],
|
||||
);
|
||||
|
||||
const openClaudeTerminal = useCallback(async () => {
|
||||
|
||||
@@ -140,3 +140,27 @@ describe("useProjects puts the status back when a refused command never ran", ()
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useProjects.rebuild on success", () => {
|
||||
it("puts the outcome's project, not the whole outcome, into the list", async () => {
|
||||
const rebuilt = project("running");
|
||||
rebuildProjectContainer.mockResolvedValue({
|
||||
project: rebuilt,
|
||||
leftover_image: null,
|
||||
leftover_volumes: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.rebuild>>;
|
||||
await act(async () => {
|
||||
outcome = await result.current.rebuild("p1");
|
||||
});
|
||||
|
||||
// A regression here would put the `{ project, leftover_image,
|
||||
// leftover_volumes }` wrapper into the projects list instead of the
|
||||
// `Project` it wraps — a shape mismatch `tsc` would not catch inside a
|
||||
// callback typed to take `unknown` per Tauri's `invoke`.
|
||||
expect(useAppState.getState().projects.find((p) => p.id === "p1")).toEqual(rebuilt);
|
||||
expect(outcome.leftover_volumes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,9 @@ export function useProjects() {
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.removeProject(id);
|
||||
const report = await commands.removeProject(id);
|
||||
removeProjectFromList(id);
|
||||
return report;
|
||||
},
|
||||
[removeProjectFromList],
|
||||
);
|
||||
@@ -135,9 +136,9 @@ export function useProjects() {
|
||||
const rebuild = useCallback(
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
const outcome = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(outcome.project);
|
||||
return outcome;
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeResetLeftovers, resetLeftoverPronoun } from "./resetOutcome";
|
||||
import type { ProjectResetOutcome } from "./types";
|
||||
|
||||
function outcome(overrides: Partial<ProjectResetOutcome> = {}): ProjectResetOutcome {
|
||||
return {
|
||||
project: {} as ProjectResetOutcome["project"],
|
||||
leftover_image: null,
|
||||
leftover_volumes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeResetLeftovers", () => {
|
||||
it("names the image first, then the volumes", () => {
|
||||
expect(describeResetLeftovers(outcome({ leftover_image: "x" }))).toBe(
|
||||
"its previous container image",
|
||||
);
|
||||
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
expect(
|
||||
describeResetLeftovers(outcome({ leftover_image: "x", leftover_volumes: ["v1", "v2"] })),
|
||||
).toBe("its previous container image and 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetLeftoverPronoun", () => {
|
||||
it("is singular for exactly one leftover", () => {
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_image: "x" }))).toBe("it");
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1"] }))).toBe("it");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived", () => {
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_image: "x", leftover_volumes: ["v1"] }))).toBe(
|
||||
"them",
|
||||
);
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("them");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ProjectResetOutcome } from "./types";
|
||||
|
||||
/**
|
||||
* Names what a `ProjectResetOutcome` says Reset could not clear, for
|
||||
* `useProjectActions`'s Reset toast.
|
||||
*
|
||||
* The image is named first and phrased as "its previous container image"
|
||||
* rather than folded in with the volumes — it is the more serious of the
|
||||
* two: the new container is built from it whenever it exists, so a
|
||||
* surviving image means Reset silently rebuilt the exact system layer it
|
||||
* was asked to discard, while a surviving volume only means old data rides
|
||||
* along.
|
||||
*/
|
||||
export function describeResetLeftovers(outcome: ProjectResetOutcome): string {
|
||||
const parts: string[] = [];
|
||||
if (outcome.leftover_image) parts.push("its previous container image");
|
||||
if (outcome.leftover_volumes.length === 1) parts.push("a volume");
|
||||
else if (outcome.leftover_volumes.length > 1) parts.push(`${outcome.leftover_volumes.length} volumes`);
|
||||
return parts.join(" and ");
|
||||
}
|
||||
|
||||
/** How many distinct things `describeResetLeftovers` is describing — the
|
||||
* image counts as one, however many volumes are named alongside it. */
|
||||
function resetLeftoverCount(outcome: ProjectResetOutcome): number {
|
||||
return (outcome.leftover_image ? 1 : 0) + outcome.leftover_volumes.length;
|
||||
}
|
||||
|
||||
/** Pronoun agreement for referring back to `describeResetLeftovers`'s
|
||||
* output — "remove it manually" for one thing, "remove them" for more. */
|
||||
export function resetLeftoverPronoun(outcome: ProjectResetOutcome): "it" | "them" {
|
||||
return resetLeftoverCount(outcome) === 1 ? "it" : "them";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -13,7 +13,7 @@ export const listProjects = () => invoke<Project[]>("list_projects");
|
||||
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||
invoke<Project>("add_project", { name, paths });
|
||||
export const removeProject = (projectId: string) =>
|
||||
invoke<void>("remove_project", { projectId });
|
||||
invoke<ProjectRemovalReport>("remove_project", { projectId });
|
||||
export const updateProject = (project: Project) =>
|
||||
invoke<Project>("update_project", { project });
|
||||
export const startProjectContainer = (projectId: string) =>
|
||||
@@ -21,7 +21,7 @@ export const startProjectContainer = (projectId: string) =>
|
||||
export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
invoke<ProjectResetOutcome>("rebuild_project_container", { projectId });
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
|
||||
@@ -77,6 +77,37 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
/** What `removeProject` could not delete. The project is removed from the
|
||||
* sidebar either way. When `retry_scheduled` is true, anything named here
|
||||
* was recorded on the host and will be retried automatically the next time
|
||||
* the app starts; when false, the record itself could not be saved and
|
||||
* nothing will retry it. `retry_scheduled` is meaningless when nothing was
|
||||
* left behind. */
|
||||
export interface ProjectRemovalReport {
|
||||
container: string | null;
|
||||
image: string | null;
|
||||
volumes: string[];
|
||||
retry_scheduled: boolean;
|
||||
}
|
||||
|
||||
/** True when a `ProjectRemovalReport` left nothing behind. Mirrors the
|
||||
* Rust-side `ProjectRemovalReport::is_clean`. */
|
||||
export function projectRemovalIsClean(report: ProjectRemovalReport): boolean {
|
||||
return !report.container && !report.image && report.volumes.length === 0;
|
||||
}
|
||||
|
||||
/** What Reset (`rebuildProjectContainer`) produced: the project as it stands
|
||||
* after restarting, and any volume Reset could not clear — which is reused
|
||||
* as-is by the new container instead of starting clean. */
|
||||
export interface ProjectResetOutcome {
|
||||
project: Project;
|
||||
/** The saved container image, if Reset could not remove it — the new
|
||||
* container is built from it whenever it exists, so this means Reset
|
||||
* silently rebuilt the system layer it was asked to discard. */
|
||||
leftover_image: string | null;
|
||||
leftover_volumes: string[];
|
||||
}
|
||||
|
||||
export type Backend =
|
||||
| "anthropic"
|
||||
| "bedrock"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Maintainer: Triple-C Contributors
|
||||
#
|
||||
# This file is regenerated by .gitea/workflows/publish-aur-package.yml on every
|
||||
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
|
||||
# already-uploaded release asset, never guessed. Editing pkgver/source/
|
||||
# sha256sums by hand here only matters until the next automated run overwrites
|
||||
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
|
||||
# maintained normally.
|
||||
#
|
||||
# "-bin" rather than building from source: this repackages the same .deb
|
||||
# build-app.yml already produces and publishes, so a user gets exactly the
|
||||
# binary the project ships and tests, and `makepkg` never needs a Rust
|
||||
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
|
||||
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
|
||||
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
|
||||
# with the installing system's — true for essentially every currently
|
||||
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
|
||||
# ships, and forward compatibility is the direction that holds.
|
||||
pkgname=triple-c-bin
|
||||
pkgver=0.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Sandbox Claude Code inside Docker containers"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/shadowdao/triple-c"
|
||||
license=('MIT')
|
||||
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
|
||||
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
|
||||
# Arch container, and re-ran `ldd` on the actual binary until nothing came
|
||||
# back "not found". `pango` and `libayatana-appindicator` were both in an
|
||||
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
|
||||
# transitively, and namcap correctly flags declaring it as redundant), and
|
||||
# libayatana-appindicator is in Tauri's own linux dependency list but this
|
||||
# binary never links it at all: there is no tray icon or menu in this app
|
||||
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
|
||||
# same reason), so it was never a real dependency to begin with.
|
||||
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
|
||||
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
|
||||
optdepends=('docker: to actually run the sandboxed containers'
|
||||
'xdg-utils: opening links from the app in your default browser')
|
||||
provides=('triple-c')
|
||||
conflicts=('triple-c')
|
||||
# !strip: the upstream .deb's binary is already the release build Tauri
|
||||
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
|
||||
# no benefit. It's also what actually suppresses makepkg's debug-package
|
||||
# machinery here (debug-package extraction requires strip; verified in a
|
||||
# real build — with !strip alone, no debug package is produced at all).
|
||||
# !debug is kept anyway, explicit about intent rather than relying on that
|
||||
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
|
||||
# tree containing a dangling .build-id symlink, which is a real namcap
|
||||
# error (not just the empty-directory warning it looks like) — there is no
|
||||
# debug info in this release binary for the machinery to have extracted in
|
||||
# the first place.
|
||||
options=('!strip' '!debug')
|
||||
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name — verified against the real release, not
|
||||
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
|
||||
# because the .deb itself carries no license file — namcap flags an MIT
|
||||
# package with nothing under /usr/share/licenses/ as an error, correctly.
|
||||
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
|
||||
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
package() {
|
||||
cd "$srcdir"
|
||||
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
|
||||
# (part of base-devel's binutils) pulls just the payload out. Extracting
|
||||
# that tar directly into $pkgdir works here with no path rewriting at all:
|
||||
# verified against the real archive, whose entire payload is
|
||||
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
|
||||
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
|
||||
# this app carries no separate resource directory under usr/lib/, so there
|
||||
# is nothing that could disagree between Debian's and Arch's package trees
|
||||
# for it to land in the wrong place.
|
||||
#
|
||||
# Globbed rather than named literally: the publish workflow discovers the
|
||||
# real asset name from the release itself specifically so a Tauri bundler
|
||||
# naming change can't silently break this — naming the file again here
|
||||
# would throw that away and fail this one line with an opaque "No such
|
||||
# file or directory" instead. `source=()` above guarantees exactly one
|
||||
# `*_amd64.deb` entry, so the glob can only ever match that one file.
|
||||
ar x ./*_amd64.deb
|
||||
tar xf data.tar.* -C "$pkgdir"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Arch / CachyOS package
|
||||
|
||||
`PKGBUILD` here is the AUR `triple-c-bin` package's template — see triple-c#34
|
||||
(the "I would like to also have an Arch/CachyOS native version" part of it).
|
||||
|
||||
## Why "-bin"
|
||||
|
||||
It repackages the same `.deb` `build-app.yml` already produces, rather than
|
||||
building from source. That means `makepkg` never needs a Rust toolchain,
|
||||
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
|
||||
itself — and a user gets exactly the binary the project ships and tests,
|
||||
built on Ubuntu 24.04 in CI. Verified end to end against a real release
|
||||
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
|
||||
against a real `ldd` of the actual binary (two packages that looked right
|
||||
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
|
||||
to be real dependencies of *this* binary and were dropped), and ran a real
|
||||
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
|
||||
|
||||
## Publishing
|
||||
|
||||
`.gitea/workflows/publish-aur-package.yml` does the actual work: given a
|
||||
version (or "latest" if none is given), it finds that release's real Linux
|
||||
asset on GitHub, downloads it, computes real checksums, renders this
|
||||
template into a version-specific PKGBUILD, validates it with `makepkg` and
|
||||
`namcap` inside a real Arch container, and pushes the result to AUR.
|
||||
|
||||
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
|
||||
header comment for why an automatic trigger isn't safe here (the same reason
|
||||
`sync-release.yml` didn't work and was removed in triple-c#32).
|
||||
|
||||
**Before it can push anything**, an AUR account has to exist and the
|
||||
`triple-c-bin` package has to have been created (or you added as a
|
||||
co-maintainer) under it — both are one-time, manual steps on
|
||||
https://aur.archlinux.org, since there's no API to automate creating an
|
||||
account or a new package. Once that's done, add the account's SSH private
|
||||
key as the `AUR_SSH_PRIVATE_KEY` secret on this repo. Until that secret
|
||||
exists, the workflow fails at the "Push to AUR" step with a message saying
|
||||
so, rather than silently doing nothing.
|
||||
|
||||
## What's hand-maintained vs. generated
|
||||
|
||||
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
|
||||
the workflow rewrites them for every real publish and never commits the
|
||||
result back here, so don't read this file's `pkgver` as "the last published
|
||||
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
|
||||
edited by hand normally, the same as any other PKGBUILD.
|
||||
|
||||
**A hand-edit made directly in the AUR repo is silently overwritten the
|
||||
next time this workflow runs.** Every run renders fresh from *this*
|
||||
repo's template rather than starting from whatever AUR's copy currently
|
||||
looks like, so a quick fix pushed straight to AUR (bumping `pkgrel` for a
|
||||
packaging-only issue, say) survives only until the next dispatch. Make
|
||||
the fix here instead.
|
||||
Reference in New Issue
Block a user