Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 |
@@ -1,298 +0,0 @@
|
||||
name: Publish Arch Package
|
||||
|
||||
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
||||
# given release, or the latest one if none is given, and attaches the built
|
||||
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
||||
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
||||
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
||||
# as an automatic trigger: this repo's releases are assembled by
|
||||
# build-app.yml across three separate platform jobs, and there is no single
|
||||
# automatic event that fires only once everything (including the Linux .deb
|
||||
# this workflow needs) is actually uploaded. A human deciding "this release
|
||||
# is ready, go package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
||||
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
|
||||
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
||||
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
||||
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
||||
# a placeholder version, and the workflow never starts from or writes to it.
|
||||
#
|
||||
# ## Not published to the AUR (yet)
|
||||
#
|
||||
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
||||
# needs a maintainer AUR account and its SSH key registered as a secret here
|
||||
# — both manual, one-time steps neither this workflow nor anyone but a
|
||||
# maintainer can do. Until that setup happens, a downloadable release asset
|
||||
# gets the same package to users without it. The AUR push step is still in
|
||||
# this file's git history (see the commit that added this comment) if that
|
||||
# setup is ever done and it's worth reinstating.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||
Leave empty to use the latest published GitHub release.
|
||||
required: false
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and find the Linux asset
|
||||
id: resolve
|
||||
env:
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Authenticated when the secret is available (it is, everywhere
|
||||
# else in this repo's workflows) to avoid the unauthenticated
|
||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||
# lower limit, since this hits nothing but a public repo's public
|
||||
# releases.
|
||||
AUTH=()
|
||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||
|
||||
if [ -z "${VERSION_INPUT}" ]; then
|
||||
echo "No version given — resolving the latest GitHub release"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||
else
|
||||
echo "Using requested version ${VERSION_INPUT}"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||
fi
|
||||
|
||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
VERSION="${TAG#v}"
|
||||
echo "Resolved to ${TAG}"
|
||||
|
||||
# Discovered from the real release, not assumed: Tauri names the
|
||||
# asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||
# of thing that silently drifts if a future Tauri upgrade changes
|
||||
# bundler defaults — a hardcoded pattern here would then 404
|
||||
# forever until someone noticed. `head -1` guards against a release
|
||||
# somehow carrying more than one matching asset, which would
|
||||
# otherwise pass the emptiness check below and then break the
|
||||
# download step with two URLs on one line.
|
||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found asset: ${DEB_NAME}"
|
||||
|
||||
# For attaching the built package to this same release later —
|
||||
# every release object carries its own `upload_url` regardless of
|
||||
# whether it was just created or (as here) already existed, and
|
||||
# the `{?name,label}` URI-template suffix has to come off before
|
||||
# this is usable as a plain URL to POST to.
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download the release asset and compute real checksums
|
||||
id: checksums
|
||||
env:
|
||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||
|
||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD
|
||||
id: render
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p rendered
|
||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||
cd rendered
|
||||
|
||||
# Plain string replacement throughout, not sed — the source URL
|
||||
# contains slashes and the repo name does too, and getting a sed
|
||||
# delimiter choice AND its escaping right for that is exactly the
|
||||
# kind of thing that looks correct, passes review, and breaks the
|
||||
# next time someone touches it. `re.sub` with `count=1` and an
|
||||
# exact `.format`-free literal match is boring and that's the
|
||||
# point: every substitution below fails loudly (an assertion /
|
||||
# the checks after) rather than silently no-op'ing if the
|
||||
# template's shape ever drifts from what this expects.
|
||||
#
|
||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||
# for, and this workflow always republishes the current PKGBUILD
|
||||
# verbatim rather than incrementing anything, so 1 is always
|
||||
# correct for what this workflow does. It is NOT correct for a
|
||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||
# would be forced back to 1, and no existing installation sees an
|
||||
# upgrade. That case needs a manual pkgrel bump in the template
|
||||
# before dispatching, which this workflow has no input for.
|
||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||
import re, sys
|
||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||
|
||||
with open("PKGBUILD") as f:
|
||||
text = f.read()
|
||||
|
||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||
assert n == 1, "pkgver=... line not found"
|
||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||
assert n == 1, "pkgrel=... line not found"
|
||||
|
||||
# Built with a "$" variable and plain "+" concatenation rather than
|
||||
# an f-string's double-brace escape for a literal brace: writing
|
||||
# this as an f-string put a dollar sign directly against two open
|
||||
# braces, right here in this workflow's own YAML text — and this
|
||||
# runner's own expression templating scans a run: block for that
|
||||
# exact two-character opening sequence and tries to evaluate
|
||||
# whatever sits inside as one of ITS OWN expressions (a step
|
||||
# output, a secret, ...) before the shell ever sees this script.
|
||||
# "pkgver" isn't one of those, so that lookup failed and silently
|
||||
# emptied this whole step rather than raising anything here.
|
||||
# Spelling the dollar sign out of a variable instead means this
|
||||
# file's own text never contains that trigger sequence.
|
||||
DOLLAR = "$"
|
||||
old_source = (
|
||||
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
||||
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
||||
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
||||
)
|
||||
new_source = (
|
||||
f'source=("{deb_name}::'
|
||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||
)
|
||||
assert old_source in text, "source=() line does not match the expected template shape"
|
||||
text = text.replace(old_source, new_source, 1)
|
||||
|
||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||
|
||||
with open("PKGBUILD", "w") as f:
|
||||
f.write(text)
|
||||
PY
|
||||
|
||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||
# obvious way to write this, and was the first draft — but on a
|
||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||
# job's own container, which the daemon's host cannot resolve; the
|
||||
# mount would silently attach an empty directory instead of failing
|
||||
# loudly. `docker cp` moves real bytes across that boundary
|
||||
# regardless of where the daemon actually lives, which is what
|
||||
# makes this work under both a bind-mount-capable runner and a
|
||||
# containerized one.
|
||||
docker pull archlinux:latest
|
||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||
useradd -m builder
|
||||
chown -R builder:builder /work
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||
# Named once here, inside the container, rather than guessed
|
||||
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
||||
# host after the fact — makepkg is the one place that actually
|
||||
# knows its own output name, and `!debug` already guarantees
|
||||
# this glob can only ever match the one real package (no
|
||||
# -debug split package gets produced).
|
||||
basename /work/*.pkg.tar.* > /work/.pkgfile
|
||||
echo "--- namcap ---"
|
||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||
echo "$NAMCAP_OUT"
|
||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||
# uses more than one line shape for its two rule families, and
|
||||
# namcap itself exits 0 regardless of what it reports, so this
|
||||
# grep is the only thing standing between an E: and a green job.
|
||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||
echo "namcap reported an error — see above" >&2
|
||||
exit 1
|
||||
fi
|
||||
')
|
||||
mkdir -p rendered
|
||||
docker cp rendered/. "${CID}:/work"
|
||||
# `docker start -a` streams output and its exit code is the
|
||||
# container's own — the same failure this would have hit with a
|
||||
# bind mount still fails the job the same way.
|
||||
docker start -a "${CID}"
|
||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
||||
PKG_FILE=$(cat rendered/.pkgfile)
|
||||
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
||||
docker rm -f "${CID}" >/dev/null
|
||||
|
||||
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach the package to the GitHub release
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
||||
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
||||
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_PAT}" ]; then
|
||||
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A manual re-dispatch for a version that's already been packaged
|
||||
# would otherwise hit GitHub's 422 "already_exists" here instead
|
||||
# of just replacing the stale build with this one.
|
||||
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
||||
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@rendered/${PKG_FILE}" \
|
||||
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
||||
> /dev/null
|
||||
|
||||
echo "Attached ${PKG_FILE} to ${TAG}"
|
||||
@@ -677,6 +677,26 @@ deliberately out of scope — this is not a project backup.
|
||||
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||
as a second layer against the same failure mode.
|
||||
|
||||
## Packaging
|
||||
|
||||
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
|
||||
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||
the AppImage is the Arch story.
|
||||
|
||||
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,63 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
|
||||
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
|
||||
| **Arch / CachyOS / other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
|
||||
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||
because nothing installed it — so it will not show up in your app launcher, and
|
||||
running it from a file manager gives you a generic icon in the taskbar.
|
||||
|
||||
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Apps
|
||||
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
```
|
||||
|
||||
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||
copied or moved. To remove the entry again:
|
||||
|
||||
```bash
|
||||
./scripts/install-appimage.sh --uninstall
|
||||
```
|
||||
|
||||
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||
bundle would appear in the menu and then fail to start anything.
|
||||
|
||||
Two follow-ups worth knowing:
|
||||
|
||||
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||
with a newer version under a different filename, re-run the script against the
|
||||
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||
icon cache, not a failed install — see
|
||||
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -1536,3 +1594,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -418,12 +418,6 @@ triple-c/
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
|
||||
│
|
||||
├── packaging/
|
||||
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
|
||||
│ ├── PKGBUILD
|
||||
│ └── README.md
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod help_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod notes_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod settings_export_commands;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::models::Note;
|
||||
use crate::storage::notes_store;
|
||||
|
||||
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||
/// recently edited.
|
||||
///
|
||||
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||
/// of the same list — cannot drift into two different orders.
|
||||
#[tauri::command]
|
||||
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||
let mut notes = notes_store::load(&project_id)?;
|
||||
notes.sort_by(|a, b| {
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||
});
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
/// Insert or replace one note.
|
||||
///
|
||||
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||
/// here is editing one note.
|
||||
#[tauri::command]
|
||||
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||
notes_store::upsert(&project_id, note)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||
notes_store::delete(&project_id, ¬e_id)
|
||||
}
|
||||
@@ -722,6 +722,15 @@ 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;
|
||||
|
||||
// A project's notes are the one piece of its state that is purely the
|
||||
// user's prose, so removal takes them with it rather than leaving an
|
||||
// orphan file keyed by an id nothing will ever look up again. Logged and
|
||||
// not propagated: an orphaned notes file is harmless, and a project that
|
||||
// cannot be removed is not.
|
||||
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
|
||||
log::warn!("Could not remove notes for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -470,6 +470,10 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Notes
|
||||
commands::notes_commands::list_notes,
|
||||
commands::notes_commands::save_note,
|
||||
commands::notes_commands::delete_note,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
|
||||
@@ -26,12 +26,27 @@
|
||||
/// 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.
|
||||
/// A user who has already set this themselves is left alone — with one
|
||||
/// correction. The earlier version of this function left *any* pre-set value
|
||||
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||
/// sees any difference.
|
||||
///
|
||||
/// That matters more than it looks, because the trade described above is not
|
||||
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||
/// this comment assumed it would degrade to, not faster. See
|
||||
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||
/// whether the addon is loaded at all.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
@@ -39,10 +54,77 @@
|
||||
/// 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")]
|
||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||
/// already set to. Split from the mutation so it can be tested without
|
||||
/// touching process-wide environment state from a parallel test runner.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DmabufAction {
|
||||
/// Not set by the user — apply the workaround.
|
||||
Disable,
|
||||
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||
/// way to express "enabled" is for the variable not to exist.
|
||||
Remove,
|
||||
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||
LeaveAlone,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||
match current {
|
||||
None => DmabufAction::Disable,
|
||||
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||
_ => DmabufAction::LeaveAlone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
let current = std::env::var(DMABUF_VAR).ok();
|
||||
match dmabuf_action(current.as_deref()) {
|
||||
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||
DmabufAction::LeaveAlone => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::{dmabuf_action, DmabufAction};
|
||||
|
||||
#[test]
|
||||
fn unset_gets_the_workaround() {
|
||||
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||
// The bug this replaces: these all previously read as "user set it,
|
||||
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||
// it only checks presence. There was no way to ask for the GPU path.
|
||||
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::Remove,
|
||||
"{value:?} should opt out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_values_are_left_alone() {
|
||||
for value in ["1", "true", "yes", "anything"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::LeaveAlone,
|
||||
"{value:?} should be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||
///
|
||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||
/// The addon therefore loads successfully and then renders every frame on
|
||||
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||
/// disabled on Linux.
|
||||
///
|
||||
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||
/// the zero value has to mean "we choose", not "off", or every existing
|
||||
/// settings file would silently pin the answer at whatever the default was
|
||||
/// the day it was written.
|
||||
#[serde(default)]
|
||||
pub terminal_gpu_rendering: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
pub mod project;
|
||||
pub mod settings_export;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
pub use settings_export::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||
/// session.
|
||||
///
|
||||
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||
/// classification decision at writing time is one the user is least willing to
|
||||
/// make, and it would turn one pane into two features.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Pinned notes sort first, then by `updated_at` descending.
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(title: String, body: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title,
|
||||
body,
|
||||
pinned: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod migration_store;
|
||||
pub mod notes_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Host-side persistence for per-project notes.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||
//! free-function shape as `migration_store` — no struct, nothing in
|
||||
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||
//! caches the project list; a store that reads and writes the file per call
|
||||
//! has nothing to cache and nothing to guard.
|
||||
//!
|
||||
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||
//! there would mean the whole project list is rewritten per edit, and a note
|
||||
//! save racing a Config save would silently drop one of them.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::models::Note;
|
||||
|
||||
/// Serialises the read-modify-write half of an upsert or delete.
|
||||
///
|
||||
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||
/// those interleaving would lose whichever note was written first. The read
|
||||
/// path does not take it.
|
||||
fn write_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||
pub fn notes_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("notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the notes directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||
|
||||
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||
load_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||
upsert_in(¬es_dir()?, project_id, note)
|
||||
}
|
||||
|
||||
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
delete_in(¬es_dir()?, project_id, note_id)
|
||||
}
|
||||
|
||||
/// Remove a project's notes file entirely. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(¬es_dir()?, project_id)
|
||||
}
|
||||
|
||||
// ── Implementation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a project's notes. A missing file is an empty list.
|
||||
///
|
||||
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||
/// that project with no way out through the UI; deleting instead would destroy
|
||||
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||
/// corruption cannot overwrite the first — which is the one taken before
|
||||
/// anything rewrote the file, and therefore the one worth having.
|
||||
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||
match serde_json::from_str::<Vec<Note>>(&data) {
|
||||
Ok(notes) => Ok(notes),
|
||||
Err(e) => {
|
||||
keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||
log::error!(
|
||||
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||
left in place and a copy was kept beside it",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")));
|
||||
if backup.exists() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::copy(path, &backup) {
|
||||
log::error!("Could not keep a copy of the unreadable notes file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace one note, leaving the rest untouched.
|
||||
///
|
||||
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||
/// `updated_at` is stamped here for the same reason.
|
||||
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||
Some(existing) => {
|
||||
note.created_at = existing.created_at.clone();
|
||||
*existing = note.clone();
|
||||
}
|
||||
None => notes.push(note.clone()),
|
||||
}
|
||||
save_all(dir, project_id, ¬es)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||
/// retry a delete whose result it never saw.
|
||||
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut notes = load_in(dir, project_id)?;
|
||||
let before = notes.len();
|
||||
notes.retain(|n| n.id != note_id);
|
||||
if notes.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
save_all(dir, project_id, ¬es)
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = notes_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 notes: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write the whole list.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||
/// applied and the data not written — a truncated file, produced by the code
|
||||
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||
/// the directory after it, since the rename is directory metadata. Notes are
|
||||
/// prose the user typed and nothing else holds a copy.
|
||||
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||
let path = notes_path_in(dir, project_id);
|
||||
let data = serde_json::to_string_pretty(notes)
|
||||
.map_err(|e| format!("Failed to serialize notes: {}", 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 notes: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||
///
|
||||
/// Best effort only where it is meaningless: Windows has no directory handle
|
||||
/// to sync and returns an error for the attempt, so a failure is logged rather
|
||||
/// than propagated. The file's own `sync_all` carries the data and is not best
|
||||
/// effort.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
log::debug!(
|
||||
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-notes-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_notes_directory() {
|
||||
// The id arrives over IPC. It must not be able to steer the write.
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize("a\\b"), "a_b");
|
||||
// A real UUID must survive untouched, or every note file would move
|
||||
// the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||
let dir = temp_dir("missing");
|
||||
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upserted_note_round_trips() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
assert_eq!(saved.id, note.id);
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "one\ntwo");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||
let dir = temp_dir("replace");
|
||||
let mut note = Note::new("Title".into(), "first".into());
|
||||
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
note.body = "second".into();
|
||||
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||
assert_eq!(loaded[0].body, "second");
|
||||
assert_ne!(
|
||||
saved.created_at, "1999-01-01T00:00:00Z",
|
||||
"created_at is owned by the store, not by whatever the webview sent"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||
let dir = temp_dir("delete");
|
||||
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
let loaded = load_in(&dir, "p1").unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
|
||||
// Idempotent: removing what is already gone is not an error, because
|
||||
// the UI can retry a delete it never saw the result of.
|
||||
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||
// Same reasoning as migration_store: a corrupt file must not make the
|
||||
// tab permanently unusable, and the bytes must not be destroyed.
|
||||
let dir = temp_dir("corrupt");
|
||||
let path = notes_path_in(&dir, "p1");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||
assert!(path.exists(), "the unreadable file is left in place");
|
||||
|
||||
let copies: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(copies.len(), 1, "the bytes must be kept exactly once");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_leaves_no_temp_file_behind() {
|
||||
let dir = temp_dir("tmp");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||
let dir = temp_dir("clear");
|
||||
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||
assert!(notes_path_in(&dir, "p1").exists());
|
||||
|
||||
clear_in(&dir, "p1").unwrap();
|
||||
assert!(!notes_path_in(&dir, "p1").exists());
|
||||
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||
// `remove_project` must not be able to fail because a project simply
|
||||
// never had any notes — an orphaned notes file is harmless, a project
|
||||
// that cannot be removed is not.
|
||||
let dir = temp_dir("removal");
|
||||
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import AccordionSection from "../ui/AccordionSection";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
@@ -67,6 +69,14 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
terminal_gpu_rendering: value === "auto" ? null : value === "on",
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoCheckToggle = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||
@@ -242,6 +252,45 @@ export default function SettingsPanel() {
|
||||
<SttSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
|
||||
<SegmentedControl
|
||||
label="Terminal GPU rendering"
|
||||
value={
|
||||
appSettings?.terminal_gpu_rendering == null
|
||||
? "auto"
|
||||
: appSettings.terminal_gpu_rendering
|
||||
? "on"
|
||||
: "off"
|
||||
}
|
||||
onChange={handleGpuRenderingChange}
|
||||
segments={[
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
|
||||
? "On for this platform."
|
||||
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
|
||||
},
|
||||
{
|
||||
value: "on",
|
||||
label: "On",
|
||||
hint: "Always load the WebGL renderer.",
|
||||
},
|
||||
{
|
||||
value: "off",
|
||||
label: "Off",
|
||||
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Takes effect when a terminal tab is next switched to.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{appVersion && (
|
||||
|
||||
@@ -28,6 +28,7 @@ import UrlToast, {
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
|
||||
interface Props {
|
||||
@@ -95,6 +96,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||
@@ -491,7 +493,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
||||
// means the session is gone, which the exit listener already reports.
|
||||
sendInput(sessionId, data).catch((e) =>
|
||||
console.error("Failed to send terminal input:", e)
|
||||
);
|
||||
});
|
||||
|
||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||
@@ -684,7 +690,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (active) {
|
||||
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
||||
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
||||
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
||||
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
||||
|
||||
// The renderer and the activation work are independent: a terminal with
|
||||
// GPU rendering switched off still has to fit and take focus when its tab
|
||||
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
||||
// "never re-fit, never focus".
|
||||
if (active && useGpu) {
|
||||
// Attach WebGL renderer
|
||||
if (!webglRef.current) {
|
||||
try {
|
||||
@@ -699,19 +714,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
}
|
||||
} else if (webglRef.current) {
|
||||
// Release the context — for inactive terminals, and when the setting
|
||||
// turns GPU rendering off while this terminal is on screen.
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
fitRef.current?.fit();
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
if (webglRef.current) {
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [active]);
|
||||
}, [active, gpuRenderingSetting]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useNotes } from "./useNotes";
|
||||
import type { Note } from "../lib/types";
|
||||
|
||||
const listNotes = vi.fn();
|
||||
const saveNote = vi.fn();
|
||||
const deleteNote = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listNotes: (p: string) => listNotes(p),
|
||||
saveNote: (p: string, n: Note) => saveNote(p, n),
|
||||
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
||||
}));
|
||||
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||
{ getState: () => ({ pushToast }) },
|
||||
),
|
||||
}));
|
||||
|
||||
const note = (over: Partial<Note> = {}): Note => ({
|
||||
id: "n1",
|
||||
title: "Deploy",
|
||||
body: "one\ntwo",
|
||||
pinned: false,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listNotes.mockResolvedValue([note()]);
|
||||
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||
deleteNote.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("useNotes", () => {
|
||||
it("loads a project's notes on mount", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listNotes).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a failed save instead of swallowing it", async () => {
|
||||
// Silent save failure is data loss: the user sees their text on screen and
|
||||
// believes it is stored. Same reason `useSaveState` exists.
|
||||
saveNote.mockRejectedValueOnce(new Error("disk full"));
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.saveState.status).toBe("failed");
|
||||
expect(pushToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces the saved note in place rather than appending", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Mock the re-read to return the edited note
|
||||
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
expect(result.current.notes[0].body).toBe("edited");
|
||||
});
|
||||
|
||||
it("drops a deleted note from the list", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNote("n1");
|
||||
});
|
||||
|
||||
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not load anything for an empty project id", async () => {
|
||||
// The dock renders with no project selected; it must not fire a command
|
||||
// for the empty string.
|
||||
renderHook(() => useNotes(""));
|
||||
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Change to a different project before the new fetch resolves
|
||||
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
// The old notes should be cleared immediately
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves no stale notes on screen when a load fails", async () => {
|
||||
listNotes.mockResolvedValueOnce([note()]);
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Switch to a project whose load fails
|
||||
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
||||
rerender({ projectId: "p2" });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.notes).toHaveLength(0);
|
||||
expect(pushToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ends with the list the backend returned when saving a new note", async () => {
|
||||
// Initially one note
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.notes).toHaveLength(1);
|
||||
|
||||
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
||||
const newNote = note({ id: "n2", title: "New" });
|
||||
listNotes.mockResolvedValueOnce([newNote, note()]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(newNote);
|
||||
});
|
||||
|
||||
expect(result.current.notes).toHaveLength(2);
|
||||
expect(result.current.notes[0].id).toBe("n2");
|
||||
});
|
||||
|
||||
it("re-reads the list after a successful save rather than patching in place", async () => {
|
||||
const { result } = renderHook(() => useNotes("p1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const callCountBefore = listNotes.mock.calls.length;
|
||||
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveNote(note({ body: "edited" }));
|
||||
});
|
||||
|
||||
// listNotes should be called again after the save
|
||||
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type { Note } from "../lib/types";
|
||||
import type { SaveState } from "./useSaveState";
|
||||
import { useAppState } from "../store/appState";
|
||||
|
||||
/** A blank note, ordered to the top so the user can start typing immediately. */
|
||||
function draft(): Note {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
// The backend owns the real id; this one only has to be unique enough to
|
||||
// key the list until the first save returns.
|
||||
id: crypto.randomUUID(),
|
||||
title: "",
|
||||
body: "",
|
||||
pinned: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A project's notes, cached from the backend.
|
||||
*
|
||||
* The backend is the source of truth and this is a cache — every mutation goes
|
||||
* through a command and the returned record replaces the local one, so the
|
||||
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
|
||||
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
|
||||
* user staring at text they believe is stored.
|
||||
*/
|
||||
export function useNotes(projectId: string) {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setNotes([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setNotes([]);
|
||||
commands
|
||||
.listNotes(projectId)
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setNotes(loaded);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setNotes([]);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not load notes for this project",
|
||||
detail: String(e),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, pushToast]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const succeeded = useCallback(() => {
|
||||
setSaveState({ status: "saved", error: null });
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(
|
||||
() => setSaveState({ status: "idle", error: null }),
|
||||
2500,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const saveNote = useCallback(
|
||||
async (note: Note) => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
setSaveState({ status: "saving", error: null });
|
||||
try {
|
||||
await commands.saveNote(projectId, note);
|
||||
// Re-read the canonical list from the backend. A successful save stamps a new
|
||||
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
|
||||
// so the record's position has changed and positional patching would disagree with
|
||||
// what a reload would show.
|
||||
try {
|
||||
const reloaded = await commands.listNotes(projectId);
|
||||
setNotes(reloaded);
|
||||
} catch {
|
||||
// Keep the save reported as successful (it was) and leave the existing list alone
|
||||
// rather than clearing it if the re-read fails.
|
||||
}
|
||||
succeeded();
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = String(e);
|
||||
setSaveState({ status: "failed", error: message });
|
||||
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, pushToast, succeeded],
|
||||
);
|
||||
|
||||
const createNote = useCallback(async () => {
|
||||
const note = draft();
|
||||
// Held locally first so the editor can focus it immediately; the save
|
||||
// happens on blur like every other edit. The note does not exist backend-side,
|
||||
// so no re-read can place it and no backend ordering applies to it yet. Prepending
|
||||
// puts it at the top where the user can see it immediately, and on the first save
|
||||
// its canonical position is established.
|
||||
setNotes((current) => [note, ...current]);
|
||||
return note;
|
||||
}, []);
|
||||
|
||||
const deleteNote = useCallback(
|
||||
async (noteId: string) => {
|
||||
try {
|
||||
await commands.deleteNote(projectId, noteId);
|
||||
setNotes((current) => current.filter((n) => n.id !== noteId));
|
||||
return true;
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
return { notes, loading, saveState, createNote, saveNote, deleteNote };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The queue lives at module scope in useTerminal, so the command layer is
|
||||
// mocked and the hook's `sendInput` is exercised through `renderHook`.
|
||||
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
|
||||
openTerminalSession: vi.fn(),
|
||||
closeTerminalSession: vi.fn(),
|
||||
terminalResize: vi.fn(),
|
||||
pasteImageToTerminal: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
|
||||
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
|
||||
|
||||
describe("useTerminal input ordering", () => {
|
||||
beforeEach(() => {
|
||||
terminalInput.mockReset();
|
||||
});
|
||||
|
||||
it("preserves order even when the underlying invokes resolve out of order", async () => {
|
||||
// Make the *first* call the slowest, which is exactly the race that put a
|
||||
// backspace behind the characters typed after it.
|
||||
const resolvers: Array<() => void> = [];
|
||||
terminalInput.mockImplementation(
|
||||
() => new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
const first = result.current.sendInput("s1", "\x7f"); // backspace
|
||||
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
|
||||
|
||||
// Only one write may be in flight at a time.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(1);
|
||||
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
|
||||
|
||||
resolvers.shift()!();
|
||||
await first;
|
||||
|
||||
// The three queued keystrokes coalesce into one ordered write.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(2);
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
|
||||
|
||||
resolvers.shift()!();
|
||||
await Promise.all(rest);
|
||||
|
||||
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
|
||||
expect(sent).toBe("\x7fabc");
|
||||
});
|
||||
|
||||
it("settles each caller's promise and does not drop later writes on failure", async () => {
|
||||
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
|
||||
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
|
||||
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
|
||||
});
|
||||
|
||||
it("keeps separate sessions independent", async () => {
|
||||
terminalInput.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await Promise.all([
|
||||
result.current.sendInput("a", "1"),
|
||||
result.current.sendInput("b", "2"),
|
||||
]);
|
||||
|
||||
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
|
||||
expect(bySession).toContainEqual(["a", "1"]);
|
||||
expect(bySession).toContainEqual(["b", "2"]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
/**
|
||||
* Per-session ordered write queue.
|
||||
*
|
||||
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
||||
* that command is `async` on the Rust side Tauri spawns each one as an
|
||||
* independent task. Those tasks then race for the session mutex in
|
||||
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
||||
* were typed in — the visible symptom was a backspace landing *after* the
|
||||
* characters typed behind it. The serial writer task downstream cannot help,
|
||||
* because the order is already lost by the time anything reaches the channel.
|
||||
*
|
||||
* The queue restores ordering the same way the web terminal gets it for free:
|
||||
* one write in flight at a time, the next only after the previous resolves.
|
||||
* Anything typed while a write is in flight coalesces into the next chunk,
|
||||
* which also collapses a burst of typing into a couple of IPC round trips
|
||||
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
||||
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
||||
* caller's promise still settles only when its own bytes have gone, so
|
||||
* `await sendInput(...)` keeps the meaning it had.
|
||||
*
|
||||
* Module scope, not hook scope, because `useTerminal()` is called from several
|
||||
* components (App for speech-to-text, TerminalView for typing and image paste,
|
||||
* useProjectActions for tile commands). A per-hook queue would give each caller
|
||||
* its own ordering and leave them racing against each other.
|
||||
*/
|
||||
type PendingWrite = {
|
||||
bytes: number[];
|
||||
resolve: () => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
||||
|
||||
async function drainInputQueue(sessionId: string): Promise<void> {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q || q.draining) return;
|
||||
|
||||
q.draining = true;
|
||||
try {
|
||||
while (q.pending.length > 0) {
|
||||
// Take everything queued so far as one batch, preserving order.
|
||||
const batch = q.pending.splice(0, q.pending.length);
|
||||
const bytes = batch.flatMap((w) => w.bytes);
|
||||
try {
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
batch.forEach((w) => w.resolve());
|
||||
} catch (err) {
|
||||
// Reject only the writes in this batch. Anything queued while it was
|
||||
// in flight is still pending and gets its own attempt on the next lap.
|
||||
batch.forEach((w) => w.reject(err));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
q.draining = false;
|
||||
// Drop the entry once idle so closed sessions do not accumulate.
|
||||
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let q = inputQueues.get(sessionId);
|
||||
if (!q) {
|
||||
q = { pending: [], draining: false };
|
||||
inputQueues.set(sessionId, q);
|
||||
}
|
||||
q.pending.push({ bytes, resolve, reject });
|
||||
void drainInputQueue(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop any queued input for a session that is going away. */
|
||||
function discardInputQueue(sessionId: string): void {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q) return;
|
||||
const dropped = q.pending.splice(0, q.pending.length);
|
||||
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
||||
if (!q.draining) inputQueues.delete(sessionId);
|
||||
}
|
||||
|
||||
export function useTerminal() {
|
||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||
useAppState(
|
||||
@@ -33,6 +113,7 @@ export function useTerminal() {
|
||||
const session = currentSessions.find((s) => s.id === sessionId);
|
||||
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
||||
|
||||
discardInputQueue(sessionId);
|
||||
await commands.closeTerminalSession(sessionId);
|
||||
removeSession(sessionId);
|
||||
|
||||
@@ -54,7 +135,7 @@ export function useTerminal() {
|
||||
const sendInput = useCallback(
|
||||
async (sessionId: string, data: string) => {
|
||||
const bytes = Array.from(new TextEncoder().encode(data));
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
await enqueueInput(sessionId, bytes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, 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, SettingsImportPreview, SettingsImportOutcome, 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, Note } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
// Notes — per-project, host-side, readable with the container stopped.
|
||||
export const listNotes = (projectId: string) =>
|
||||
invoke<Note[]>("list_notes", { projectId });
|
||||
/** Insert or replace one note. `created_at` and `id` are owned by the backend. */
|
||||
export const saveNote = (projectId: string, note: Note) =>
|
||||
invoke<Note>("save_note", { projectId, note });
|
||||
export const deleteNote = (projectId: string, noteId: string) =>
|
||||
invoke<void>("delete_note", { projectId, noteId });
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||
export const updateSettings = (settings: AppSettings) =>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
|
||||
|
||||
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
|
||||
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
|
||||
|
||||
describe("isLinuxWebview", () => {
|
||||
it("recognises desktop Linux", () => {
|
||||
expect(isLinuxWebview(LINUX)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count Android as desktop Linux", () => {
|
||||
expect(isLinuxWebview(ANDROID)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the other desktop platforms", () => {
|
||||
expect(isLinuxWebview(MAC)).toBe(false);
|
||||
expect(isLinuxWebview(WINDOWS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTerminalGpuRendering", () => {
|
||||
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
|
||||
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
|
||||
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
|
||||
});
|
||||
|
||||
it("auto is on elsewhere", () => {
|
||||
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
|
||||
});
|
||||
|
||||
it("an explicit setting wins on every platform", () => {
|
||||
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Decides whether the terminal loads `@xterm/addon-webgl`.
|
||||
*
|
||||
* Split out of `TerminalView` so it can be unit-tested without standing up a
|
||||
* terminal, and so the platform rule lives in exactly one place.
|
||||
*/
|
||||
|
||||
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
|
||||
export function isLinuxWebview(userAgent: string): boolean {
|
||||
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective WebGL setting.
|
||||
*
|
||||
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
|
||||
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux —
|
||||
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
|
||||
* which leaves WebGL present but software-rasterised, so loading the addon is
|
||||
* slower than the canvas renderer it would otherwise have fallen back to.
|
||||
*/
|
||||
export function resolveTerminalGpuRendering(
|
||||
setting: boolean | null | undefined,
|
||||
userAgent: string,
|
||||
): boolean {
|
||||
if (typeof setting === "boolean") return setting;
|
||||
return !isLinuxWebview(userAgent);
|
||||
}
|
||||
@@ -290,6 +290,12 @@ export interface AppSettings {
|
||||
stt: SttSettings;
|
||||
gateway: GatewaySettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
|
||||
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
|
||||
* backed by software rasterisation and the addon ends up slower than the
|
||||
* canvas renderer it would otherwise fall back to. See
|
||||
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||
terminal_gpu_rendering: boolean | null;
|
||||
}
|
||||
|
||||
/** What `preview_settings_import` returns before anything is applied —
|
||||
@@ -559,6 +565,16 @@ export interface SchedulerNotification {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One project note. Mirrors `models::Note` — field names are the Rust ones. */
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Which loopback family the container-side listener was found on.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
# Project Notes — design
|
||||
|
||||
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
|
||||
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
|
||||
|
||||
A per-project notes surface, with a per-note **Send to agent** action that puts the note
|
||||
into a running Claude session's prompt.
|
||||
|
||||
---
|
||||
|
||||
## Why this earns a slot
|
||||
|
||||
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
|
||||
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
|
||||
answers it **while the container is stopped**, which is the gap the stop/start container
|
||||
model creates and the same reasoning that made Sessions/Resume the flagship.
|
||||
|
||||
Two things already do part of this job, and the design is shaped to avoid both:
|
||||
|
||||
- `Project.claude_instructions` (`models/project.rs:405`, editor at
|
||||
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
|
||||
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
|
||||
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
|
||||
instructions box, it is redundant with a feature that already ships.
|
||||
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
|
||||
user when the container is stopped, and invisible to the fleet view.
|
||||
|
||||
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
|
||||
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
|
||||
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
|
||||
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
|
||||
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
|
||||
| Tab position | Last, after Browser | A companion to the work, not a step in it |
|
||||
|
||||
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
|
||||
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
|
||||
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
|
||||
classification decision at the moment of writing — which is the moment the user is least
|
||||
willing to make one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage
|
||||
|
||||
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
|
||||
on `projects_store.rs`:
|
||||
|
||||
```
|
||||
<data_dir>/triple-c/notes/{project_id}.json
|
||||
```
|
||||
|
||||
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
|
||||
over IPC; it must not be able to steer the write.
|
||||
- **Atomic *and durable* write** — `.tmp`, `sync_all()`, `rename()`, then fsync the
|
||||
directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That
|
||||
file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write`
|
||||
returns once the bytes are in the page cache, so losing power in the window leaves the
|
||||
rename applied and the data not written — a truncated file produced by the very code meant
|
||||
to prevent one. Notes are user prose; that is the data least worth losing to a half-write.
|
||||
- **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125` —
|
||||
timestamped, capped, and never overwriting an earlier copy, because the first copy is the
|
||||
one taken before anything rewrote the file.
|
||||
- **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public
|
||||
wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes
|
||||
`dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its
|
||||
own tests only exercise free functions. The notes store should not inherit that limit.
|
||||
|
||||
```rust
|
||||
struct Note {
|
||||
id: String, // uuid v4
|
||||
title: String,
|
||||
body: String,
|
||||
pinned: bool,
|
||||
created_at: String, // RFC 3339
|
||||
updated_at: String,
|
||||
}
|
||||
struct ProjectNotes { version: u32, notes: Vec<Note> }
|
||||
```
|
||||
|
||||
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
||||
|
||||
### Why not a field on `Project`
|
||||
|
||||
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
|
||||
Config). Long user prose on that record means (a) the whole project list is rewritten every
|
||||
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
|
||||
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
|
||||
for why *it* is not in `projects.json`. Notes inherit it.
|
||||
|
||||
A per-project file also means a corrupt notes file loses notes for one project, not the
|
||||
project list.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
|
||||
an orphaned notes file is harmless, a project that cannot be removed is not.
|
||||
|
||||
## 2. Commands and frontend state
|
||||
|
||||
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
|
||||
**no** entry in `capabilities/default.json`.
|
||||
|
||||
- `list_notes(projectId) -> Vec<Note>`
|
||||
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
|
||||
- `delete_note(projectId, noteId)`
|
||||
|
||||
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
|
||||
storage choice above exists to avoid.
|
||||
|
||||
`notes_store` is a **free-function module** keyed by project id, exactly like
|
||||
`migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes.
|
||||
`ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store
|
||||
that reads and writes the file per call has nothing to cache and nothing to guard. What it
|
||||
does need is that each upsert's read-modify-write is not interleaved with another's, so the
|
||||
module holds one process-wide write lock (`OnceLock<Mutex<()>>`, the idiom already in
|
||||
`browser_view/popout.rs`) taken for the read-modify-write, not for the read path.
|
||||
|
||||
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
|
||||
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
|
||||
|
||||
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
|
||||
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
|
||||
`notes-changed` event, both windows listen.
|
||||
|
||||
## 3. Editor — plain text, deliberately
|
||||
|
||||
A note is a title and a `<textarea>`, saved on blur, following
|
||||
`ClaudeInstructionsEditor.tsx` (which saves in `onBlur` and holds no timer) and reporting
|
||||
the outcome through `ui/SaveIndicator`. There is no debounce anywhere in the existing save
|
||||
path — `useSaveState.ts`'s only timer is a 2500 ms reset of the "Saved ✓" label — and notes
|
||||
add none. **No rich editor, no markdown library, and no markdown
|
||||
rendering** — it is a scratchpad for reminders, and it stays one.
|
||||
|
||||
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
|
||||
there is no state to get wrong and no moment where the text the user is looking at is not
|
||||
the text that would be sent.
|
||||
|
||||
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
|
||||
It was written for Help content, it entity-escapes before converting to make its
|
||||
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
|
||||
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
|
||||
converter a second caller with different content — cost and risk, for formatting a
|
||||
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
|
||||
this change.
|
||||
|
||||
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
|
||||
box. Nothing is transformed on the way out except the newline substitution in §5.
|
||||
|
||||
## 4. The Notes tab
|
||||
|
||||
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
|
||||
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
|
||||
shape `{ project: Project }`.
|
||||
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
|
||||
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
|
||||
existing order runs roughly from "what is this" to "what is in it".
|
||||
- Layout is master/detail: title list left, editor right.
|
||||
|
||||
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
|
||||
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
|
||||
not worth changing here.
|
||||
|
||||
## 5. Send to agent
|
||||
|
||||
### The newline problem, and why it is already solved
|
||||
|
||||
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
|
||||
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
|
||||
messages.
|
||||
|
||||
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
|
||||
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
|
||||
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
|
||||
would *run* the line. So:
|
||||
|
||||
```
|
||||
payload = note.body.replace(/\r?\n/g, "\x1b\r")
|
||||
```
|
||||
|
||||
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
|
||||
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
|
||||
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
|
||||
|
||||
Two consequences follow from that same comment:
|
||||
|
||||
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
|
||||
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
|
||||
at all.
|
||||
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
|
||||
knowledge in that comment is hard-won and must not be duplicated away from it.
|
||||
|
||||
### Target resolution
|
||||
|
||||
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
|
||||
`sessionType` and `sessionName`, so no new plumbing is needed.
|
||||
|
||||
| Claude sessions for this project | Behavior |
|
||||
|---|---|
|
||||
| 0 | Button disabled, "no running session for this project" |
|
||||
| 1 | Send |
|
||||
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
|
||||
|
||||
The display-name rule is currently written **twice**, both copies non-exported and local to
|
||||
`MainTabs.tsx` — `tabLabel` (:192-203) and inline in `renderTab` (:362-367). The picker would
|
||||
be a third copy of a rule that already disagrees with itself the moment one copy is edited,
|
||||
so it is extracted once to a shared helper and both existing sites call it. That is a
|
||||
targeted improvement to code this feature depends on, not unrelated refactoring.
|
||||
|
||||
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
|
||||
(it pins at record-start so text does not land in whatever tab is active at stop time).
|
||||
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
|
||||
exposed as `sendInput` at `:135-141`) → `terminal_input` → `exec_manager.send_input`. That
|
||||
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
|
||||
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
|
||||
- After sending, switch the active tab to that terminal so the user watches it land. This is
|
||||
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
|
||||
succeeded.
|
||||
- **Body only, not the title.** The title is an index label for the list, not content.
|
||||
|
||||
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
|
||||
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
|
||||
clicking a note is too large an implicit action, and that timing hack should not spread.
|
||||
|
||||
## 6. Surface — a dock that takes space inward
|
||||
|
||||
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
|
||||
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
|
||||
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
|
||||
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
|
||||
|
||||
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
|
||||
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
|
||||
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
|
||||
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
|
||||
correctly, with no new code.
|
||||
|
||||
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
|
||||
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
|
||||
- **The dock follows the active tab's project** — terminal tab → that session's project,
|
||||
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
|
||||
`TerminalSession.projectId` already provide this.
|
||||
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
|
||||
arithmetic, no platform checks. §6.1 is why.
|
||||
|
||||
### 6.1 Why the dock does not widen the window — Phase 0 spike
|
||||
|
||||
The original design had the dock "expand outward" by widening the OS window, so the terminal
|
||||
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
|
||||
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
|
||||
today and the behavior could not be predicted. It was run twice, once per GDK backend,
|
||||
which turned out to matter more than the platform.
|
||||
|
||||
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
|
||||
`GDK_BACKEND=x11` in `AppRun`, citing
|
||||
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
|
||||
|
||||
| Test | Result |
|
||||
|---|---|
|
||||
| Grow while floating | asked +420, got +420 — exact |
|
||||
| Shrink back | asked -420, got -420 — exact |
|
||||
| `outer_position()` | readable, correct |
|
||||
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
|
||||
| Grow while maximized / fullscreen | ignored, as designed |
|
||||
|
||||
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
|
||||
hook) the same binary failed — and failed *silently*, which is the part that decided this:
|
||||
|
||||
| Test | Result |
|
||||
|---|---|
|
||||
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
|
||||
| Shrink back | asked -420, got **-240**; height **+276** again |
|
||||
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
|
||||
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
|
||||
| Grow while maximized / fullscreen | ignored, as designed |
|
||||
| `set_position` | ignored, as expected |
|
||||
|
||||
Two independent failures, either one sufficient:
|
||||
|
||||
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
|
||||
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
|
||||
every write built on a read-back compounds it. There is no size that can be read and
|
||||
safely written back. Three calls in, the window is larger than the display.
|
||||
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
|
||||
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
|
||||
"do not grow" never triggers, because the value looks perfectly valid. The room check
|
||||
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
|
||||
|
||||
The second point is what rules out a runtime fallback. A clean failure could have been
|
||||
handled; a plausible wrong answer cannot be detected from the value itself.
|
||||
|
||||
Growing therefore works on one packaging channel and corrupts on another — the split is by
|
||||
**packaging, not platform**, which is worse than a platform split because two users on
|
||||
identical hardware and OS would see different behavior. A dock that takes space inward
|
||||
behaves identically on every backend, OS and package, needs no detection, and reuses a
|
||||
resize path that is already exercised by every terminal in the app.
|
||||
|
||||
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
|
||||
does not move the window and must not start.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
|
||||
CLAUDE.md's Testing section.
|
||||
|
||||
**Rust (`notes_store.rs`)**
|
||||
- `sanitize()` rejects traversal and separator characters in a project id
|
||||
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
|
||||
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
|
||||
- removing a project deletes its notes file; a delete failure does not fail removal
|
||||
|
||||
**Frontend**
|
||||
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
|
||||
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
|
||||
- the dock's project resolution: terminal tab, home tab, and nothing active
|
||||
- save-on-blur persists, and dock width round-trips through localStorage
|
||||
|
||||
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
|
||||
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
|
||||
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
|
||||
that — but anything touching real key handling needs a manual check in Chromium.
|
||||
|
||||
## 8. Out of scope for v1
|
||||
|
||||
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
|
||||
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
|
||||
narrowly scoped capability granting the notes window `core:event:allow-listen` /
|
||||
`allow-unlisten` — `capabilities/default.json` scopes those to `"windows": ["main"]` today,
|
||||
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
|
||||
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
|
||||
handler would need review at that point.
|
||||
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
|
||||
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
||||
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||
sync direction is a v2 conversation.
|
||||
- Tags, full-text search, manual reordering, note history.
|
||||
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||
versus fired-on-demand items.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
|
||||
table above.
|
||||
@@ -1,86 +0,0 @@
|
||||
# Maintainer: Triple-C Contributors
|
||||
#
|
||||
# This file is regenerated by .gitea/workflows/publish-arch-package.yml on every
|
||||
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
|
||||
# already-uploaded release asset, never guessed. Editing pkgver/source/
|
||||
# sha256sums by hand here only matters until the next automated run overwrites
|
||||
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
|
||||
# maintained normally.
|
||||
#
|
||||
# "-bin" rather than building from source: this repackages the same .deb
|
||||
# build-app.yml already produces and publishes, so a user gets exactly the
|
||||
# binary the project ships and tests, and `makepkg` never needs a Rust
|
||||
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
|
||||
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
|
||||
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
|
||||
# with the installing system's — true for essentially every currently
|
||||
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
|
||||
# ships, and forward compatibility is the direction that holds.
|
||||
pkgname=triple-c-bin
|
||||
pkgver=0.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Sandbox Claude Code inside Docker containers"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/shadowdao/triple-c"
|
||||
license=('MIT')
|
||||
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
|
||||
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
|
||||
# Arch container, and re-ran `ldd` on the actual binary until nothing came
|
||||
# back "not found". `pango` and `libayatana-appindicator` were both in an
|
||||
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
|
||||
# transitively, and namcap correctly flags declaring it as redundant), and
|
||||
# libayatana-appindicator is in Tauri's own linux dependency list but this
|
||||
# binary never links it at all: there is no tray icon or menu in this app
|
||||
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
|
||||
# same reason), so it was never a real dependency to begin with.
|
||||
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
|
||||
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
|
||||
optdepends=('docker: to actually run the sandboxed containers'
|
||||
'xdg-utils: opening links from the app in your default browser')
|
||||
provides=('triple-c')
|
||||
conflicts=('triple-c')
|
||||
# !strip: the upstream .deb's binary is already the release build Tauri
|
||||
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
|
||||
# no benefit. It's also what actually suppresses makepkg's debug-package
|
||||
# machinery here (debug-package extraction requires strip; verified in a
|
||||
# real build — with !strip alone, no debug package is produced at all).
|
||||
# !debug is kept anyway, explicit about intent rather than relying on that
|
||||
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
|
||||
# tree containing a dangling .build-id symlink, which is a real namcap
|
||||
# error (not just the empty-directory warning it looks like) — there is no
|
||||
# debug info in this release binary for the machinery to have extracted in
|
||||
# the first place.
|
||||
options=('!strip' '!debug')
|
||||
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name — verified against the real release, not
|
||||
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
|
||||
# because the .deb itself carries no license file — namcap flags an MIT
|
||||
# package with nothing under /usr/share/licenses/ as an error, correctly.
|
||||
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
|
||||
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
package() {
|
||||
cd "$srcdir"
|
||||
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
|
||||
# (part of base-devel's binutils) pulls just the payload out. Extracting
|
||||
# that tar directly into $pkgdir works here with no path rewriting at all:
|
||||
# verified against the real archive, whose entire payload is
|
||||
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
|
||||
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
|
||||
# this app carries no separate resource directory under usr/lib/, so there
|
||||
# is nothing that could disagree between Debian's and Arch's package trees
|
||||
# for it to land in the wrong place.
|
||||
#
|
||||
# Globbed rather than named literally: the publish workflow discovers the
|
||||
# real asset name from the release itself specifically so a Tauri bundler
|
||||
# naming change can't silently break this — naming the file again here
|
||||
# would throw that away and fail this one line with an opaque "No such
|
||||
# file or directory" instead. `source=()` above guarantees exactly one
|
||||
# `*_amd64.deb` entry, so the glob can only ever match that one file.
|
||||
ar x ./*_amd64.deb
|
||||
tar xf data.tar.* -C "$pkgdir"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Arch / CachyOS package
|
||||
|
||||
`PKGBUILD` here is the `triple-c-bin` package's template — see triple-c#34
|
||||
(the "I would like to also have an Arch/CachyOS native version" part of it).
|
||||
It's written to AUR conventions (and may go there eventually — see
|
||||
"Publishing" below) but isn't published to the AUR yet.
|
||||
|
||||
## Why "-bin"
|
||||
|
||||
It repackages the same `.deb` `build-app.yml` already produces, rather than
|
||||
building from source. That means `makepkg` never needs a Rust toolchain,
|
||||
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
|
||||
itself — and a user gets exactly the binary the project ships and tests,
|
||||
built on Ubuntu 24.04 in CI. Verified end to end against a real release
|
||||
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
|
||||
against a real `ldd` of the actual binary (two packages that looked right
|
||||
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
|
||||
to be real dependencies of *this* binary and were dropped), and ran a real
|
||||
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
|
||||
|
||||
## Publishing
|
||||
|
||||
`.gitea/workflows/publish-arch-package.yml` does the actual work: given a
|
||||
version (or "latest" if none is given), it finds that release's real Linux
|
||||
asset on GitHub, downloads it, computes real checksums, renders this
|
||||
template into a version-specific PKGBUILD, validates it with `makepkg` and
|
||||
`namcap` inside a real Arch container, and attaches the resulting
|
||||
`.pkg.tar.zst` to that same GitHub release as a downloadable asset —
|
||||
installable by hand with `sudo pacman -U`.
|
||||
|
||||
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
|
||||
header comment for why an automatic trigger isn't safe here (the same reason
|
||||
`sync-release.yml` didn't work and was removed in triple-c#32).
|
||||
|
||||
**Not on the AUR yet.** Publishing there would need a maintainer AUR account
|
||||
and its SSH key added as a secret on this repo — both manual, one-time steps
|
||||
on https://aur.archlinux.org that only a maintainer can do. The workflow's
|
||||
git history still has the AUR-push step from before this was descoped, if
|
||||
that setup happens later and it's worth reinstating.
|
||||
|
||||
## What's hand-maintained vs. generated
|
||||
|
||||
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
|
||||
the workflow rewrites them for every real publish and never commits the
|
||||
result back here, so don't read this file's `pkgver` as "the last published
|
||||
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
|
||||
edited by hand normally, the same as any other PKGBUILD.
|
||||
|
||||
**A hand-edit made to the rendered PKGBUILD attached to a GitHub release is
|
||||
not this file.** Every run renders fresh from *this* repo's template, so a
|
||||
packaging fix belongs here, not in a downloaded copy — the next dispatch for
|
||||
that version would just overwrite it anyway.
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/sh
|
||||
# Register a Triple-C AppImage with the desktop, so it appears in the app
|
||||
# launcher with its icon instead of only being runnable from a file manager.
|
||||
#
|
||||
# An AppImage is a single executable file and nothing more: it ships a
|
||||
# `.desktop` entry and icons *inside* itself, but nothing on the host ever
|
||||
# reads them, because nothing installed it. This script does what a package
|
||||
# manager's install hooks would — copies the icons into the user's icon theme
|
||||
# and writes a `.desktop` entry pointing at wherever the AppImage actually
|
||||
# lives.
|
||||
#
|
||||
# ./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
# ./scripts/install-appimage.sh --uninstall
|
||||
#
|
||||
# Everything goes under ~/.local/share, so there is no sudo and no root-owned
|
||||
# file to clean up later. The AppImage itself is never copied or moved — the
|
||||
# launcher entry points at the path you give here, so keep the file somewhere
|
||||
# stable (`~/Apps` or `~/.local/bin`, not `~/Downloads`) or re-run this after
|
||||
# moving it.
|
||||
#
|
||||
# Extraction uses `--appimage-extract`, which unpacks the payload directly and
|
||||
# needs no FUSE. So this script works even on a system where *running* the
|
||||
# AppImage would need `fuse2` installed first.
|
||||
|
||||
set -eu
|
||||
|
||||
APP_ID="triple-c"
|
||||
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
|
||||
DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop"
|
||||
|
||||
refresh_caches() {
|
||||
# Both are best-effort: a minimal desktop may ship neither, and neither
|
||||
# failing means the install did not work.
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -f -t "${ICON_DIR}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
rm -f "${DESKTOP_FILE}"
|
||||
find "${ICON_DIR}" -name "${APP_ID}.png" -delete 2>/dev/null || true
|
||||
refresh_caches
|
||||
echo "Removed the Triple-C launcher entry and icons."
|
||||
echo "The AppImage itself was not touched."
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APPIMAGE="${1:-}"
|
||||
if [ -z "${APPIMAGE}" ]; then
|
||||
echo "usage: $0 [--uninstall] /path/to/Triple-C_<version>_amd64.AppImage" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "${APPIMAGE}" ]; then
|
||||
echo "No such file: ${APPIMAGE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An absolute path, because the .desktop Exec line is read from anywhere.
|
||||
APPIMAGE=$(cd "$(dirname "${APPIMAGE}")" && printf '%s/%s' "$(pwd)" "$(basename "${APPIMAGE}")")
|
||||
|
||||
if [ ! -x "${APPIMAGE}" ]; then
|
||||
echo "Making ${APPIMAGE} executable"
|
||||
chmod +x "${APPIMAGE}"
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # WORK is expanded now on purpose.
|
||||
trap "rm -rf '${WORK}'" EXIT INT TERM
|
||||
|
||||
echo "Extracting bundled icons from $(basename "${APPIMAGE}")..."
|
||||
( cd "${WORK}" && "${APPIMAGE}" --appimage-extract >/dev/null )
|
||||
|
||||
SRC="${WORK}/squashfs-root"
|
||||
if [ ! -d "${SRC}/usr/share/icons/hicolor" ]; then
|
||||
echo "That AppImage has no bundled icons — is it really Triple-C?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy every size the bundle ships, keeping the theme's directory layout.
|
||||
COUNT=0
|
||||
while IFS= read -r icon; do
|
||||
[ -n "${icon}" ] || continue
|
||||
rel=${icon#"${SRC}/usr/share/icons/hicolor/"}
|
||||
install -Dm644 "${icon}" "${ICON_DIR}/${rel}"
|
||||
COUNT=$((COUNT + 1))
|
||||
done <<EOF
|
||||
$(find "${SRC}/usr/share/icons/hicolor" -name "${APP_ID}.png")
|
||||
EOF
|
||||
echo "Installed ${COUNT} icon size(s) into ${ICON_DIR}"
|
||||
|
||||
# Written rather than copied from the bundle. The bundled entry has
|
||||
# `Exec=triple-c`, which resolves only inside the running AppImage's own mount
|
||||
# — from the host it names a binary that is not on PATH, so the launcher entry
|
||||
# would appear and then fail to start anything. `Categories` is empty in the
|
||||
# bundle too, which leaves the entry to fall into "Other" in most menus.
|
||||
# `StartupWMClass` is kept exactly as the bundle sets it: it is what lets the
|
||||
# shell match the running window to this entry, so the taskbar shows the real
|
||||
# icon instead of a generic placeholder.
|
||||
mkdir -p "${DESKTOP_DIR}"
|
||||
cat > "${DESKTOP_FILE}" <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Triple-C
|
||||
Comment=Run Claude Code sandboxed in Docker containers
|
||||
Exec=${APPIMAGE} %U
|
||||
Icon=${APP_ID}
|
||||
Terminal=false
|
||||
Categories=Development;
|
||||
StartupWMClass=${APP_ID}
|
||||
DESKTOP
|
||||
chmod 644 "${DESKTOP_FILE}"
|
||||
echo "Wrote ${DESKTOP_FILE}"
|
||||
|
||||
refresh_caches
|
||||
|
||||
echo
|
||||
echo "Done. Triple-C should now be in your app launcher."
|
||||
echo "If the icon is generic or the entry is missing, log out and back in —"
|
||||
echo "see \"App Icon Missing After Installing (Linux)\" in HOW-TO-USE.md."
|
||||
Reference in New Issue
Block a user