Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37bbf181c9 | ||
|
|
5d16b5713d | ||
|
|
c02c02cbfc | ||
|
|
c0e4c87cec | ||
|
|
3aec2998d8 | ||
|
|
019fb403d5 | ||
|
|
b21a568bf5 | ||
|
|
f41b1d9054 | ||
|
|
d38736007f | ||
|
|
63f282bef6 | ||
|
|
d561ce03d5 |
@@ -299,8 +299,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -335,7 +361,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
|
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
@@ -427,8 +452,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -172,8 +172,34 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules package-lock.json
|
# `npm ci` — from the lockfile, never resolving afresh.
|
||||||
npm install
|
#
|
||||||
|
# This used to be `rm -rf node_modules package-lock.json && npm
|
||||||
|
# install`, which deleted the lockfile "to ensure correct
|
||||||
|
# platform-specific bindings" (2d4fce9). That made every build
|
||||||
|
# re-resolve the whole tree against the registry, so a dependency
|
||||||
|
# publishing a new version could break CI with no change to this
|
||||||
|
# repo — and one did. Deleting the lockfile then hit a null
|
||||||
|
# dereference in npm 10.9.8's arborist peer-set resolver:
|
||||||
|
#
|
||||||
|
# npm error Cannot read properties of null (reading 'edgesOut')
|
||||||
|
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
|
||||||
|
#
|
||||||
|
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
|
||||||
|
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
|
||||||
|
# Reproduced exactly by removing the lockfile locally on the same
|
||||||
|
# Node 22.23.2 the runner installs.
|
||||||
|
#
|
||||||
|
# The binding worry is obsolete: the committed lockfile records 25
|
||||||
|
# rollup platform variants, and `npm ci` on Linux installs precisely
|
||||||
|
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
|
||||||
|
# with a clean tsc, a successful build and 752 passing tests from the
|
||||||
|
# resulting tree.
|
||||||
|
#
|
||||||
|
# Do not "fix" a future dependency error by deleting the lockfile
|
||||||
|
# again. If `npm ci` refuses, package.json and the lockfile have
|
||||||
|
# genuinely diverged, and the fix is to commit an updated lockfile.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -200,10 +226,23 @@ jobs:
|
|||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
# The versioned AppImage only. The update channel's copy lives in
|
||||||
|
# bundle/appimage/update-channel/ precisely so this glob cannot pick
|
||||||
|
# it up and publish an 80 MB duplicate under a second name.
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
|
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
|
# A green job that published nothing is the worst outcome available:
|
||||||
|
# the release exists, carries no AppImage, and nobody is told. The
|
||||||
|
# `|| true` above is there so a missing bundle does not mask the real
|
||||||
|
# error, which makes this check the thing that catches it.
|
||||||
|
shopt -s nullglob
|
||||||
|
collected=(artifacts/*)
|
||||||
|
if [ ${#collected[@]} -eq 0 ]; then
|
||||||
|
echo "No artifacts collected — the bundler produced nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
@@ -286,7 +325,11 @@ jobs:
|
|||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
run: bash scripts/publish-update-channel.sh artifacts
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
bash scripts/publish-update-channel.sh \
|
||||||
|
app/src-tauri/target/release/bundle/appimage/update-channel
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -343,8 +386,10 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: |
|
run: |
|
||||||
rm -rf node_modules
|
# `npm ci` here too, so all three platforms install identically and
|
||||||
npm install
|
# none of them can re-resolve the tree mid-release. Windows already
|
||||||
|
# did. See the Linux job for what a fresh resolution cost us.
|
||||||
|
npm ci
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# Put BuildKit in the host's network namespace so it can reach
|
||||||
|
# act_runner's cache service.
|
||||||
|
#
|
||||||
|
# The `docker-container` driver — which the multi-arch build below
|
||||||
|
# requires, since the plain `docker` driver cannot do
|
||||||
|
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
|
||||||
|
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
|
||||||
|
# an address the *job* container can reach, and nothing teaches the
|
||||||
|
# BuildKit container about it: the job could reach
|
||||||
|
# 192.168.1.126:40649 while the container actually making the request
|
||||||
|
# could not, and the build died with `no route to host`.
|
||||||
|
#
|
||||||
|
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
|
||||||
|
# missing route (a wrong address times out instead) — which is what a
|
||||||
|
# default firewalld zone does to traffic arriving from the docker
|
||||||
|
# bridge. Sharing the host's namespace sidesteps the question
|
||||||
|
# entirely: the cache address becomes local to BuildKit.
|
||||||
|
#
|
||||||
|
# No effect on runners where this already worked.
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -55,5 +76,21 @@ jobs:
|
|||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||||
|
# `ignore-error` is what stops a cache failure failing a build that
|
||||||
|
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||||
|
# service on the runner host's LAN address, and the `docker-container`
|
||||||
|
# builder `setup-buildx-action` creates could not route to it —
|
||||||
|
# every layer of both arches built, then the job died on
|
||||||
|
# `GetCacheEntryDownloadURL: no route to host` while exporting.
|
||||||
|
#
|
||||||
|
# On a pull_request `push:` above is false, so this job pushes
|
||||||
|
# nothing and the cache is its only output: failing it discarded a
|
||||||
|
# complete, successful validation of the Dockerfile for both
|
||||||
|
# architectures. A cache is an optimisation and must degrade to
|
||||||
|
# "slow", never to "red".
|
||||||
|
#
|
||||||
|
# The import is already non-fatal — the build ran all 37 layers after
|
||||||
|
# warning that it could not read the cache — so only the exporter
|
||||||
|
# needs the flag.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,ignore-error=true
|
||||||
|
|||||||
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
|
|||||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||||
so a project already at `true` gets no recreation at all on upgrade.
|
so a project already at `true` gets no recreation at all on upgrade.
|
||||||
|
|
||||||
|
### Keeping Claude Code current
|
||||||
|
|
||||||
|
`claude update` runs in **two** places, and both are needed:
|
||||||
|
|
||||||
|
- `container/entrypoint.sh` runs it once per container start, before any session exists.
|
||||||
|
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
|
||||||
|
command every Claude session launches with, because containers use a stop/start model and a
|
||||||
|
long-lived one would otherwise never re-check.
|
||||||
|
|
||||||
|
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
|
||||||
|
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
|
||||||
|
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
|
||||||
|
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
|
||||||
|
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
|
||||||
|
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
|
||||||
|
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
|
||||||
|
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
|
||||||
|
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
|
||||||
|
deliberately untouched.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
|
|||||||
+27
-5
@@ -243,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ │ └──────────────────────────────────────────────────┘ │
|
│ │ └──────────────────────────────────────────────────┘ │
|
||||||
├─────────────┴────────────────────────────────────────────────────────┤
|
├─────────────┴────────────────────────────────────────────────────────┤
|
||||||
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │
|
│ 2 project(s) · 1 running · 2 terminal(s) Notes │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -268,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
|
||||||
open you get a welcome screen with Docker/image/project readiness checks.
|
open you get a welcome screen with Docker/image/project readiness checks.
|
||||||
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
|
||||||
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
|
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
|
||||||
speech-to-text is enabled.
|
**Notes** toggle; and the microphone button when speech-to-text is enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1224,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
|
|||||||
|
|
||||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||||
|
|
||||||
### Jump to Current
|
### Scrolling
|
||||||
|
|
||||||
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
|
||||||
|
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
|
||||||
|
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
|
||||||
|
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
|
||||||
|
for them to act on.
|
||||||
|
|
||||||
|
### When the mouse stops working
|
||||||
|
|
||||||
|
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
|
||||||
|
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
|
||||||
|
you cannot select text, and stray characters can appear as you move the pointer.
|
||||||
|
|
||||||
|
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
|
||||||
|
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
|
||||||
|
container — only the terminal's own state is reset.
|
||||||
|
|
||||||
|
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
|
||||||
|
the button is showing most of the time you are in one. It is there for when a program exits
|
||||||
|
without handing the mouse back and the terminal is left stuck; releasing while a program is still
|
||||||
|
running just takes the mouse away from that program.
|
||||||
|
|
||||||
|
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
|
||||||
|
macOS.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
|
||||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ choice it never asked about.
|
|||||||
|
|
||||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||||
file drag-drop, STT), the web terminal, and workspace backup.
|
file drag-drop, STT), the web terminal, and workspace backup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+6
-3
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||||
|
|
||||||
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||||
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||||
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||||
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
|
||||||
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
|
||||||
|
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
|
||||||
|
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
|
||||||
|
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
|
||||||
|
|
||||||
### bollard (Docker API)
|
### bollard (Docker API)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,58 @@ use crate::AppState;
|
|||||||
|
|
||||||
/// Build the command to run in the container terminal.
|
/// Build the command to run in the container terminal.
|
||||||
///
|
///
|
||||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
||||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
||||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||||
|
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||||
|
/// WebLinksAddon).
|
||||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
build_claude_terminal_cmd(
|
||||||
|
project,
|
||||||
|
settings.global_aws.aws_profile.as_deref(),
|
||||||
|
session_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell line run immediately before `exec claude` in every Claude terminal
|
||||||
|
/// session.
|
||||||
|
///
|
||||||
|
/// `container/entrypoint.sh` already runs `claude update` when the container
|
||||||
|
/// starts, but containers here use a stop/start (and often just keep running)
|
||||||
|
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
||||||
|
/// it per session is what keeps a week-old container current.
|
||||||
|
///
|
||||||
|
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
||||||
|
/// network, npm registry down) so a session always opens, and `timeout 60`
|
||||||
|
/// bounds how long a user waits for a terminal.
|
||||||
|
///
|
||||||
|
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
||||||
|
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
||||||
|
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
||||||
|
/// races two updaters against the same `~/.claude/bin` install, as does
|
||||||
|
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
||||||
|
/// behind a friendly message and the very next line (`exec claude`) would run
|
||||||
|
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
||||||
|
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
||||||
|
/// other holder just updated, so there is nothing left to do.
|
||||||
|
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
||||||
|
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
||||||
|
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Single-quote one argument for interpolation into a shell script string.
|
||||||
|
fn shell_quote_arg(arg: &str) -> String {
|
||||||
|
format!(" '{}'", arg.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||||
|
/// profile rather than the whole [`AppState`].
|
||||||
|
fn build_claude_terminal_cmd(
|
||||||
|
project: &Project,
|
||||||
|
global_aws_profile: Option<&str>,
|
||||||
|
session_name: Option<&str>,
|
||||||
|
) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.bedrock_config
|
||||||
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
|||||||
|
|
||||||
let permission_args = project.effective_permission_mode().cli_args();
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
|
// The args are interpolated into a shell script string, so single-quote
|
||||||
|
// each one.
|
||||||
|
let name_flag = session_name
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||||
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
return vec![
|
||||||
cmd.extend(permission_args);
|
"bash".to_string(),
|
||||||
if let Some(name) = session_name {
|
"-c".to_string(),
|
||||||
if !name.is_empty() {
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
cmd.push("-n".to_string());
|
];
|
||||||
cmd.push(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cmd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = aws_commands::resolve_profile_for_project(
|
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
||||||
project,
|
|
||||||
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
// then exec's into claude.
|
// then exec's into claude.
|
||||||
let name_flag = session_name
|
|
||||||
.filter(|n| !n.is_empty())
|
|
||||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
// The args are interpolated into a shell script string, so single-quote
|
|
||||||
// each one (same escaping style as name_flag above).
|
|
||||||
let permission_flags: String = permission_args
|
|
||||||
.iter()
|
|
||||||
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
|
||||||
.collect();
|
|
||||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -75,9 +114,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
||||||
|
use crate::models::Project;
|
||||||
|
|
||||||
/// A dropped file must be named the way the *user* named it.
|
/// A dropped file must be named the way the *user* named it.
|
||||||
///
|
///
|
||||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||||
@@ -338,6 +382,122 @@ mod tests {
|
|||||||
/// answer comes from the spelling, and a path that does not name a file is
|
/// answer comes from the spelling, and a path that does not name a file is
|
||||||
/// refused rather than silently substituted (it used to fall back to
|
/// refused rather than silently substituted (it used to fall back to
|
||||||
/// `"dropped-file"`).
|
/// `"dropped-file"`).
|
||||||
|
/// A `Project` with only the fields these tests care about set; the rest
|
||||||
|
/// come through serde so the test does not have to track every field.
|
||||||
|
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "p1",
|
||||||
|
"name": "Test",
|
||||||
|
"paths": [],
|
||||||
|
"container_id": null,
|
||||||
|
"status": "running",
|
||||||
|
"backend": backend,
|
||||||
|
"bedrock_config": bedrock_config,
|
||||||
|
"ollama_config": null,
|
||||||
|
"openai_compatible_config": null,
|
||||||
|
"allow_docker_access": false,
|
||||||
|
"full_permissions": false,
|
||||||
|
"ssh_key_path": null,
|
||||||
|
"git_user_name": null,
|
||||||
|
"git_user_email": null,
|
||||||
|
"created_at": "now",
|
||||||
|
"updated_at": "now"
|
||||||
|
}))
|
||||||
|
.expect("test project deserializes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Claude session updates the CLI before launching it.
|
||||||
|
///
|
||||||
|
/// `container/entrypoint.sh` only updates at container *start*, and these
|
||||||
|
/// containers are long-lived, so a stale CLI is the normal case without
|
||||||
|
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
||||||
|
/// wrapper rather than a bare `claude` argv.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||||
|
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||||
|
|
||||||
|
assert_eq!(cmd[0], "bash");
|
||||||
|
assert_eq!(cmd[1], "-c");
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(UPDATE_PRELUDE),
|
||||||
|
"plain path must run the update prelude: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
||||||
|
// The update has to happen *before* the exec, which never returns.
|
||||||
|
assert!(
|
||||||
|
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
||||||
|
"prelude must precede the exec: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
||||||
|
"the update must stay time-bounded and non-fatal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session name is interpolated into a shell script, so a quote in it
|
||||||
|
/// must not break out of its single-quoted argument.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
||||||
|
let cmd = build_claude_terminal_cmd(
|
||||||
|
&project("anthropic", serde_json::Value::Null),
|
||||||
|
None,
|
||||||
|
Some("Bob's tab; rm -rf /"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
||||||
|
"session name must be single-quote escaped: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission flags travel the same escaped path, and an empty name adds
|
||||||
|
/// no `-n` at all.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||||
|
let mut p = project("anthropic", serde_json::Value::Null);
|
||||||
|
p.full_permissions = true;
|
||||||
|
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||||
|
"got: {}",
|
||||||
|
cmd[2]
|
||||||
|
);
|
||||||
|
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
||||||
|
/// prelude, immediately before the exec.
|
||||||
|
#[test]
|
||||||
|
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
||||||
|
let cmd = build_claude_terminal_cmd(
|
||||||
|
&project("bedrock", serde_json::json!({
|
||||||
|
"auth_method": "profile",
|
||||||
|
"aws_region": "us-east-1",
|
||||||
|
"aws_profile": "acme",
|
||||||
|
"model_id": null,
|
||||||
|
"disable_prompt_caching": false
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
Some("it's fine"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(cmd[0], "bash");
|
||||||
|
let script = &cmd[2];
|
||||||
|
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
||||||
|
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
||||||
|
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
||||||
|
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
||||||
|
assert!(
|
||||||
|
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
||||||
|
"prelude must precede the exec: {}",
|
||||||
|
script
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||||
use crate::commands::file_commands::host_upload_name;
|
use crate::commands::file_commands::host_upload_name;
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
|
|||||||
writer_handle.abort();
|
writer_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
|
||||||
|
/// copied so the web terminal cannot drift from it — a duplicated `const` with
|
||||||
|
/// a "keep these identical" comment is only as good as the next reader.
|
||||||
|
use crate::commands::terminal_commands::UPDATE_PRELUDE;
|
||||||
|
|
||||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
|
|
||||||
let permission_args = project.effective_permission_mode().cli_args();
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
|
||||||
let mut cmd = vec!["claude".to_string()];
|
|
||||||
cmd.extend(permission_args);
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile = aws_commands::resolve_profile_for_project(
|
|
||||||
project,
|
|
||||||
settings_store.get().global_aws.aws_profile.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The args are interpolated into a shell script string below, so
|
// The args are interpolated into a shell script string below, so
|
||||||
// single-quote each one.
|
// single-quote each one.
|
||||||
let permission_flags: String = permission_args
|
let permission_flags: String = permission_args
|
||||||
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
.collect();
|
.collect();
|
||||||
let claude_cmd = format!("exec claude{}", permission_flags);
|
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
return vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = aws_commands::resolve_profile_for_project(
|
||||||
|
project,
|
||||||
|
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
echo "Validating AWS session for profile '{profile}'..."
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
@@ -260,9 +267,11 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
{update_prelude}
|
||||||
{claude_cmd}
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
|
update_prelude = UPDATE_PRELUDE,
|
||||||
claude_cmd = claude_cmd
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
export default function StatusBar({ stt }: Props) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
terminalHasSelection: s.terminalHasSelection,
|
terminalHasSelection: s.terminalHasSelection,
|
||||||
activeSessionId: s.activeSessionId,
|
activeSessionId: s.activeSessionId,
|
||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
|
||||||
notesDockOpen: s.notesDockOpen,
|
notesDockOpen: s.notesDockOpen,
|
||||||
toggleNotesDock: s.toggleNotesDock,
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
|
terminalMouseCaptured: s.terminalMouseCaptured,
|
||||||
|
releaseActiveMouse: s.releaseActiveMouse,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
{/* Right-aligned controls: mouse release + Notes + STT mic */}
|
||||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
{activeSessionId && !terminalAtBottom && (
|
{activeSessionId && terminalMouseCaptured && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollActiveToBottom()}
|
data-mouse-release="true"
|
||||||
|
onClick={() => releaseActiveMouse()}
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||||
title="Scroll the terminal to the latest output"
|
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
|
||||||
>
|
>
|
||||||
Jump to Current ↓
|
🖱 Mouse captured — release
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
|||||||
import TerminalView, { supersedes } from "./TerminalView";
|
import TerminalView, { supersedes } from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||||
|
import { URL_TOAST_SELECTOR } from "./UrlToast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The window-wide native drag-drop listener, captured at registration.
|
* The window-wide native drag-drop listener, captured at registration.
|
||||||
@@ -370,15 +371,34 @@ describe("TerminalView — where a dropped file lands", () => {
|
|||||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
|
||||||
// The regression this file could not see. The toggle is `absolute top-2
|
// The regression this file could not see. Chrome like the URL toast is a
|
||||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
// *sibling* of the xterm host painted over the pane, so
|
||||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
// `elementFromPoint` returns it rather than the host — and a gate asking
|
||||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||||
// with no message and no log line. jsdom never ran that branch.
|
// with no message and no log line. jsdom never ran that branch.
|
||||||
const view = await mountWithLayout();
|
//
|
||||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
// The original fixture was the always-rendered "▼ Following" toggle. That
|
||||||
stubElementFromPoint(toggle);
|
// control is retired and the mouse-release button that could have replaced
|
||||||
|
// it lives in the status bar now, so the toast is what stands in — it is
|
||||||
|
// real chrome over the pane, which is the only property under test.
|
||||||
|
await mountWithLayout();
|
||||||
|
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||||
|
if (!emit) throw new Error("no terminal-output listener registered");
|
||||||
|
await act(async () => {
|
||||||
|
emit({
|
||||||
|
payload: Array.from(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
const toast = document.querySelector(URL_TOAST_SELECTOR);
|
||||||
|
if (!toast) throw new Error("URL toast not shown");
|
||||||
|
stubElementFromPoint(toast);
|
||||||
|
|
||||||
await drop(780, 10);
|
await drop(780, 10);
|
||||||
|
|
||||||
@@ -593,4 +613,89 @@ describe("TerminalView — focus on request", () => {
|
|||||||
});
|
});
|
||||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("TerminalView — releasing a captured mouse", () => {
|
||||||
|
/** Feed raw bytes to the terminal as if the container had printed them, and
|
||||||
|
* let xterm drain its write queue (it parses asynchronously). */
|
||||||
|
async function emitBytes(text: string) {
|
||||||
|
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||||
|
if (!emit) throw new Error("no terminal-output listener registered");
|
||||||
|
await act(async () => {
|
||||||
|
emit({ payload: Array.from(new TextEncoder().encode(text)) });
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the status bar would render from: the active terminal publishes the
|
||||||
|
* capture state, and the release action, into the store. The control itself
|
||||||
|
* lives in `StatusBar` — deliberately, so it never sits on top of the TUI
|
||||||
|
* that is asking for the mouse. */
|
||||||
|
function captured(): boolean {
|
||||||
|
return useAppState.getState().terminalMouseCaptured;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows nothing while the container has not grabbed the mouse", async () => {
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a release control once the container turns mouse tracking on", async () => {
|
||||||
|
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
|
||||||
|
// reported to the app. When the TUI that asked for it dies without
|
||||||
|
// resetting the mode, xterm keeps routing moves to the PTY and drops text
|
||||||
|
// selection — the freeze this control exists to break out of.
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
await emitBytes("\x1b[?1003h\x1b[?1006h");
|
||||||
|
|
||||||
|
expect(captured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the mode locally, without sending a byte to the container", async () => {
|
||||||
|
// The reset is written into xterm's own parser, not onto the wire. The
|
||||||
|
// program inside is usually gone; if it is not, it must not be told the
|
||||||
|
// user pulled the mouse back, or a live TUI would just re-grab it.
|
||||||
|
mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
await emitBytes("\x1b[?1003h");
|
||||||
|
terminalInput.mockClear();
|
||||||
|
|
||||||
|
// Exactly what the status-bar button's onClick does.
|
||||||
|
const release = useAppState.getState().releaseActiveMouse;
|
||||||
|
await act(async () => {
|
||||||
|
release();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The published flag is bound to the live mode, so it going false *is* the
|
||||||
|
// assertion that xterm's mouse tracking is back to "none".
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
expect(terminalInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
|
||||||
|
const { container } = mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
await emitBytes("\x1b[?1002h");
|
||||||
|
terminalInput.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(helperTextarea(container), {
|
||||||
|
key: "X",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captured()).toBe(false);
|
||||||
|
// The chord must not also reach the container as input.
|
||||||
|
expect(terminalInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
||||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -219,14 +219,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
}, []);
|
}, []);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
||||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
// True while the program in the container holds mouse reporting open (any of
|
||||||
// Tracks user intent to follow output — only set to false by explicit user
|
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
||||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
const [mouseCaptured, setMouseCaptured] = useState(false);
|
||||||
const autoFollowRef = useRef(true);
|
const mouseCapturedRef = useRef(false);
|
||||||
const lastUserScrollTimeRef = useRef(0);
|
|
||||||
|
|
||||||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||||
// and the unmount-cleanup effect further down).
|
// and the unmount-cleanup effect further down).
|
||||||
@@ -251,10 +248,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
//
|
//
|
||||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
||||||
// of the host rather than a child. Nothing painted over the pane refuses a
|
// sibling of the host rather than a child. Nothing painted over the pane
|
||||||
// drop on its own account — asking "is this element mine?" once turned every
|
// refuses a drop on its own account — asking "is this element mine?" once
|
||||||
// pixel under that chrome into a permanent dead zone.
|
// turned every pixel under that chrome into a permanent dead zone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -315,12 +312,60 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
};
|
};
|
||||||
}, [sessionId, sendInput]);
|
}, [sessionId, sendInput]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the badge with xterm's live mouse-tracking mode.
|
||||||
|
*
|
||||||
|
* There is no event for this, but there does not need to be a poll either:
|
||||||
|
* the mode only ever changes because the container printed a DECSET/DECRST
|
||||||
|
* sequence, so checking once per write covers every transition, exactly when
|
||||||
|
* it happens. The ref gate keeps the common case (mode unchanged, thousands
|
||||||
|
* of writes a second) down to one string comparison and no re-render.
|
||||||
|
*/
|
||||||
|
const syncMouseCapture = useCallback(() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
const captured = term.modes.mouseTrackingMode !== "none";
|
||||||
|
if (captured === mouseCapturedRef.current) return;
|
||||||
|
mouseCapturedRef.current = captured;
|
||||||
|
setMouseCaptured(captured);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the mouse back from a program that grabbed it and never let go.
|
||||||
|
*
|
||||||
|
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
|
||||||
|
* tracking modes set. xterm goes on routing clicks, drags and — under
|
||||||
|
* `?1003` — every pointer *move* to the PTY, which kills text selection and
|
||||||
|
* floods the prompt with escape bytes. The result reads as a frozen
|
||||||
|
* terminal, and until now the only exit was closing the tab.
|
||||||
|
*
|
||||||
|
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
|
||||||
|
* xterm's own parser and never onto the wire. The program that asked for
|
||||||
|
* tracking is usually already gone; if it is not, telling it the user pulled
|
||||||
|
* the mouse back would only invite it to grab again on its next repaint.
|
||||||
|
*/
|
||||||
|
const releaseMouse = useCallback(() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
// The three tracking modes, then the two encodings they report in. All
|
||||||
|
// five, because a program is free to have set any combination and a
|
||||||
|
// leftover encoding mode outlives the tracking mode that motivated it.
|
||||||
|
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
|
||||||
|
}, [syncMouseCapture]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
// Let the user select text even while a program holds the mouse.
|
||||||
|
// xterm's force-selection modifier is Shift everywhere *except* macOS,
|
||||||
|
// where it is Option and is gated behind this option, which defaults to
|
||||||
|
// false — so without this line Mac users have no force-select at all and
|
||||||
|
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
||||||
|
// first. `SelectionService.shouldForceSelection`.
|
||||||
|
macOptionClickForcesSelection: true,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0d1117",
|
background: "#0d1117",
|
||||||
@@ -391,6 +436,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
useAppState.getState().sttToggle();
|
useAppState.getState().sttToggle();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
|
||||||
|
// a key because the failure this recovers from is *the pointer not
|
||||||
|
// working* — a control you have to click can be unreachable in exactly
|
||||||
|
// the situation that calls for it.
|
||||||
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
|
||||||
|
releaseMouse();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||||
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
||||||
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
||||||
@@ -501,42 +554,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
|
||||||
// Captured during capture phase so it fires before xterm's own handler.
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
|
||||||
lastUserScrollTimeRef.current = Date.now();
|
|
||||||
if (e.deltaY < 0) {
|
|
||||||
autoFollowRef.current = false;
|
|
||||||
setIsAutoFollow(false);
|
|
||||||
isAtBottomRef.current = false;
|
|
||||||
setIsAtBottom(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
|
||||||
|
|
||||||
// Track scroll position to show "Jump to Current" button.
|
|
||||||
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
|
||||||
let scrollStateRafId: number | null = null;
|
|
||||||
const scrollDisposable = term.onScroll(() => {
|
|
||||||
const buf = term.buffer.active;
|
|
||||||
const atBottom = buf.viewportY >= buf.baseY;
|
|
||||||
isAtBottomRef.current = atBottom;
|
|
||||||
|
|
||||||
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
|
||||||
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
|
||||||
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
|
||||||
autoFollowRef.current = true;
|
|
||||||
setIsAutoFollow(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollStateRafId === null) {
|
|
||||||
scrollStateRafId = requestAnimationFrame(() => {
|
|
||||||
scrollStateRafId = null;
|
|
||||||
setIsAtBottom(isAtBottomRef.current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track text selection to show copy hint in status bar
|
// Track text selection to show copy hint in status bar
|
||||||
const selectionDisposable = term.onSelectionChange(() => {
|
const selectionDisposable = term.onSelectionChange(() => {
|
||||||
setTerminalHasSelection(term.hasSelection());
|
setTerminalHasSelection(term.hasSelection());
|
||||||
@@ -599,15 +616,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data, () => {
|
// Scrolling on new output is xterm's own job, and it already gets it
|
||||||
if (autoFollowRef.current) {
|
// right: it follows the tail while the viewport is at the bottom and
|
||||||
term.scrollToBottom();
|
// holds position while you are reading further up. The manual
|
||||||
if (!isAtBottomRef.current) {
|
// `scrollToBottom()` that used to live here fought that second half.
|
||||||
isAtBottomRef.current = true;
|
term.write(data, syncMouseCapture);
|
||||||
setIsAtBottom(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
detector.feed(data);
|
detector.feed(data);
|
||||||
|
|
||||||
// Scan for SSO refresh marker in terminal output
|
// Scan for SSO refresh marker in terminal output
|
||||||
@@ -649,11 +662,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeRafId = requestAnimationFrame(() => {
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
resizeRafId = null;
|
resizeRafId = null;
|
||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
|
// Whether the viewport was following the tail has to be sampled
|
||||||
|
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
|
||||||
|
// afterwards cannot tell "was at the bottom" from "was pushed off it".
|
||||||
|
const wasAtBottom =
|
||||||
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
if (autoFollowRef.current) {
|
// Only re-anchor a viewport that was already on the tail. This
|
||||||
term.scrollToBottom();
|
// observer fires for any pane size change — opening the Notes dock,
|
||||||
}
|
// dragging the sidebar, resizing the window — and none of those are a
|
||||||
|
// reason to yank someone away from the scrollback they are reading.
|
||||||
|
if (wasAtBottom) term.scrollToBottom();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -667,14 +687,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
osc52Disposable.dispose();
|
osc52Disposable.dispose();
|
||||||
relayDisposable.dispose();
|
relayDisposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
setTerminalHasSelection(false);
|
setTerminalHasSelection(false);
|
||||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
@@ -723,10 +740,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
|
// Same rule as the resize observer: re-anchor only what was already
|
||||||
|
// anchored, so a tab left scrolled up comes back where it was left.
|
||||||
|
const wasAtBottom =
|
||||||
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
if (autoFollowRef.current) {
|
if (wasAtBottom) term.scrollToBottom();
|
||||||
term.scrollToBottom();
|
|
||||||
}
|
|
||||||
term.focus();
|
term.focus();
|
||||||
}
|
}
|
||||||
}, [active, gpuRenderingSetting]);
|
}, [active, gpuRenderingSetting]);
|
||||||
@@ -826,39 +845,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
);
|
);
|
||||||
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
|
||||||
const term = termRef.current;
|
|
||||||
if (term) {
|
|
||||||
autoFollowRef.current = true;
|
|
||||||
setIsAutoFollow(true);
|
|
||||||
fitRef.current?.fit();
|
|
||||||
term.scrollToBottom();
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
setIsAtBottom(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
|
||||||
// control, but only while it's the active (visible) terminal.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
setTerminalAtBottom(isAtBottom);
|
|
||||||
setScrollActiveToBottom(handleScrollToBottom);
|
|
||||||
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
|
||||||
|
|
||||||
// On unmount, if this was the active terminal, clear the status-bar scroll
|
|
||||||
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
|
||||||
// unmount — the deactivating terminal stays mounted but hidden — so this
|
|
||||||
// only fires when the active session is actually closed.)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (activeRef.current) {
|
|
||||||
setTerminalAtBottom(true);
|
|
||||||
setScrollActiveToBottom(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
|
||||||
|
|
||||||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -876,20 +862,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleAutoFollow = useCallback(() => {
|
// Surface the capture state and its escape hatch to the status bar, but only
|
||||||
const next = !autoFollowRef.current;
|
// while this is the visible terminal.
|
||||||
autoFollowRef.current = next;
|
useEffect(() => {
|
||||||
setIsAutoFollow(next);
|
if (!active) return;
|
||||||
if (next) {
|
setTerminalMouseCaptured(mouseCaptured);
|
||||||
const term = termRef.current;
|
setReleaseActiveMouse(releaseMouse);
|
||||||
if (term) {
|
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||||
fitRef.current?.fit();
|
|
||||||
term.scrollToBottom();
|
// On unmount, if this was the active terminal, clear the status-bar state so
|
||||||
isAtBottomRef.current = true;
|
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
||||||
setIsAtBottom(true);
|
// the deactivating terminal stays mounted but hidden — so this only fires
|
||||||
|
// when the active session is actually closed.)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setTerminalMouseCaptured(false);
|
||||||
|
setReleaseActiveMouse(() => {});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, []);
|
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -915,18 +907,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Auto-follow toggle - top right */}
|
|
||||||
<button
|
|
||||||
onClick={handleToggleAutoFollow}
|
|
||||||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
|
||||||
isAutoFollow
|
|
||||||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
|
||||||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
|
||||||
}`}
|
|
||||||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
|
||||||
>
|
|
||||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
|
||||||
</button>
|
|
||||||
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||||
FitAddon measures the host element it's mounted into; padding there
|
FitAddon measures the host element it's mounted into; padding there
|
||||||
causes the grid to overhang and clip the rightmost column / bottom
|
causes the grid to overhang and clip the rightmost column / bottom
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
|
|||||||
describe("chrome over a pane, with no dialog open", () => {
|
describe("chrome over a pane, with no dialog open", () => {
|
||||||
/** Everything that is painted over a pane and is not a blocker. */
|
/** Everything that is painted over a pane and is not a blocker. */
|
||||||
const CHROME: Array<[string, () => HTMLElement]> = [
|
const CHROME: Array<[string, () => HTMLElement]> = [
|
||||||
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
|
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
|
||||||
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
|
// and a *sibling* of the xterm host — so "does the pane contain what is
|
||||||
// host — so "does the pane contain what is painted here?" made the
|
// painted here?" made the terminal's top-right corner a dead zone no
|
||||||
// terminal's top-right corner a dead zone no user action could clear.
|
// user action could clear. (The retired Following toggle held the same
|
||||||
["the Following/Paused toggle", () => document.createElement("button")],
|
// corner and produced the original bug.)
|
||||||
|
["the mouse-release badge", () => document.createElement("button")],
|
||||||
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
|
||||||
// pane, and its error cards stay until dismissed.
|
// pane, and its error cards stay until dismissed.
|
||||||
["a toast card", () => document.createElement("div")],
|
["a toast card", () => document.createElement("div")],
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
*
|
*
|
||||||
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
||||||
* painted here mine?" — refused drops onto anything painted *over* a pane
|
* painted here mine?" — refused drops onto anything painted *over* a pane
|
||||||
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
|
||||||
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
* of the xterm host), the URL toast, `ToastHost`'s stack.
|
||||||
* Permanent dead zones no user action could clear.
|
* Permanent dead zones no user action could clear.
|
||||||
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
||||||
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
||||||
|
|||||||
+14
-10
@@ -205,16 +205,20 @@ interface AppState {
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
|
// Whether a program in the active terminal is holding mouse reporting open,
|
||||||
|
// and how to take it back. Surfaced so the release control can live in the
|
||||||
|
// status bar: painted over the terminal it would sit on top of whatever TUI
|
||||||
|
// is asking for the mouse, and swallow clicks aimed at that program's own
|
||||||
|
// top-right corner for as long as it ran. Only the active TerminalView
|
||||||
|
// writes these.
|
||||||
|
terminalMouseCaptured: boolean;
|
||||||
|
setTerminalMouseCaptured: (captured: boolean) => void;
|
||||||
|
releaseActiveMouse: () => void;
|
||||||
|
setReleaseActiveMouse: (fn: () => void) => void;
|
||||||
// STT toggle for the active session, registered by App so the terminal's
|
// STT toggle for the active session, registered by App so the terminal's
|
||||||
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
||||||
sttToggle: () => void;
|
sttToggle: () => void;
|
||||||
setSttToggle: (fn: () => void) => void;
|
setSttToggle: (fn: () => void) => void;
|
||||||
// Active terminal scroll state, surfaced so the status bar can host the
|
|
||||||
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
||||||
terminalAtBottom: boolean;
|
|
||||||
setTerminalAtBottom: (v: boolean) => void;
|
|
||||||
scrollActiveToBottom: () => void;
|
|
||||||
setScrollActiveToBottom: (fn: () => void) => void;
|
|
||||||
sidebarView: "projects" | "settings";
|
sidebarView: "projects" | "settings";
|
||||||
setSidebarView: (view: "projects" | "settings") => void;
|
setSidebarView: (view: "projects" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
|
terminalMouseCaptured: false,
|
||||||
|
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
|
||||||
|
releaseActiveMouse: () => {},
|
||||||
|
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
|
||||||
sttToggle: () => {},
|
sttToggle: () => {},
|
||||||
setSttToggle: (fn) => set({ sttToggle: fn }),
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
||||||
terminalAtBottom: true,
|
|
||||||
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
||||||
scrollActiveToBottom: () => {},
|
|
||||||
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
sidebarCollapsed: loadSidebarCollapsed(),
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
|||||||
@@ -639,8 +639,14 @@ fi
|
|||||||
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
# any terminal session launches `claude`. Runs as the claude user (the CLI is
|
||||||
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
|
||||||
# slow or offline network never blocks container readiness.
|
# slow or offline network never blocks container readiness.
|
||||||
|
# The lock is shared with the per-session update that every Claude terminal
|
||||||
|
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
|
||||||
|
# "Container ready" is printed *after* this finishes, so a user who starts a
|
||||||
|
# project and immediately opens a tab would otherwise have two updaters
|
||||||
|
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
|
||||||
|
# damage right before it ran the result.
|
||||||
echo "entrypoint: checking for Claude Code updates..."
|
echo "entrypoint: checking for Claude Code updates..."
|
||||||
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
|
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
|
||||||
&& echo "entrypoint: Claude Code is up to date" \
|
&& echo "entrypoint: Claude Code is up to date" \
|
||||||
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
|
|||||||
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||||
|
|
||||||
APP_ID="com.triple-c.desktop"
|
APP_ID="com.triple-c.desktop"
|
||||||
|
# The channel pair lives in its own directory. Left beside the versioned image
|
||||||
|
# they are picked up by the release job's `*.AppImage` glob, and every release
|
||||||
|
# then carries an eighty-megabyte byte-identical duplicate under a second name
|
||||||
|
# — which is exactly as confusing on a downloads page as it sounds.
|
||||||
|
CHANNEL_DIR="update-channel"
|
||||||
STABLE_NAME="Triple-C_x86_64.AppImage"
|
STABLE_NAME="Triple-C_x86_64.AppImage"
|
||||||
UPDATE_TAG="linux-latest"
|
UPDATE_TAG="linux-latest"
|
||||||
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
|
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
|
||||||
@@ -98,8 +103,13 @@ CATEGORIES="Development;Utility;"
|
|||||||
|
|
||||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
|
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
|
||||||
|
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
|
||||||
|
# metadata is missing under any other name — while the script cheerfully
|
||||||
|
# reported it present. The AppStream id inside the file is unchanged and is
|
||||||
|
# what actually identifies the component; only the filename follows the tool.
|
||||||
|
appdata_installed_as="Triple-C.appdata.xml"
|
||||||
|
|
||||||
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
|
dir="${1:?usage: finalize-appimage.sh <bundle/appimage directory>}"
|
||||||
cd "$dir"
|
cd "$dir"
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -109,6 +119,14 @@ if [ ${#images[@]} -eq 0 ]; then
|
|||||||
echo "No .AppImage in $dir — nothing to do." >&2
|
echo "No .AppImage in $dir — nothing to do." >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Refused here rather than after the repack: with two present the old position
|
||||||
|
# let the script download appimagetool, repack, overwrite the versioned
|
||||||
|
# artifact and write the channel pair, *then* fail — and it silently picked
|
||||||
|
# images[0], which is glob order, i.e. the older version.
|
||||||
|
if [ ${#images[@]} -ne 1 ]; then
|
||||||
|
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
appimage="${images[0]}"
|
appimage="${images[0]}"
|
||||||
here="$PWD"
|
here="$PWD"
|
||||||
|
|
||||||
@@ -120,15 +138,15 @@ echo "Inspecting $appimage"
|
|||||||
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
|
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
|
||||||
root="$work/squashfs-root"
|
root="$work/squashfs-root"
|
||||||
|
|
||||||
if [ ! -e "$root/usr/lib/$LIB" ]; then
|
# The demotion and the metadata are independent jobs, and an absent library
|
||||||
# Not a failure: linuxdeploy may have stopped bundling it, which is the
|
# must not skip the second. An early exit here also left `update-channel/`
|
||||||
# outcome this script exists to produce.
|
# uncreated, which killed the publish step on a missing directory and took the
|
||||||
echo "$LIB is not bundled — leaving $appimage alone."
|
# tag and mirror jobs down with it — a half-published release.
|
||||||
exit 0
|
demoted=false
|
||||||
fi
|
if [ -e "$root/usr/lib/$LIB" ]; then
|
||||||
|
|
||||||
mkdir -p "$root/$FALLBACK_DIR"
|
mkdir -p "$root/$FALLBACK_DIR"
|
||||||
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
|
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
|
||||||
|
|
||||||
cat > "$root/$HOOK" <<'HOOK_EOF'
|
cat > "$root/$HOOK" <<'HOOK_EOF'
|
||||||
#! /usr/bin/env bash
|
#! /usr/bin/env bash
|
||||||
@@ -177,6 +195,11 @@ src = src.replace(
|
|||||||
)
|
)
|
||||||
open(path, "w").write(src)
|
open(path, "w").write(src)
|
||||||
PATCH_EOF
|
PATCH_EOF
|
||||||
|
fi
|
||||||
|
demoted=true
|
||||||
|
echo "Demoted $LIB to $FALLBACK_DIR."
|
||||||
|
else
|
||||||
|
echo "$LIB is not bundled — nothing to demote."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- metadata -------------------------------------------------------------
|
# --- metadata -------------------------------------------------------------
|
||||||
@@ -188,22 +211,29 @@ version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
|
|||||||
if [ -f "$appdata_src" ]; then
|
if [ -f "$appdata_src" ]; then
|
||||||
mkdir -p "$root/usr/share/metainfo"
|
mkdir -p "$root/usr/share/metainfo"
|
||||||
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
|
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
|
||||||
"$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml"
|
"$appdata_src" > "$root/usr/share/metainfo/$appdata_installed_as"
|
||||||
echo "Added AppStream metadata for $version."
|
echo "Added AppStream metadata for $version."
|
||||||
else
|
else
|
||||||
echo "No AppStream source at $appdata_src — skipping." >&2
|
echo "No AppStream source at $appdata_src — skipping." >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
|
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
|
||||||
for desktop in "$root"/*.desktop; do
|
#
|
||||||
|
# The AppDir root entry is a **symlink** into usr/share/applications, so a
|
||||||
|
# plain `sed -i` replaces the link with a regular file and leaves the real entry
|
||||||
|
# untouched — two divergent copies, of which the empty one is the one that
|
||||||
|
# actually ships and the filled one is the only one a root-only guard can see.
|
||||||
|
# `--follow-symlinks` writes through. Both locations are globbed because the
|
||||||
|
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
|
||||||
|
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
|
||||||
[ -e "$desktop" ] || continue
|
[ -e "$desktop" ] || continue
|
||||||
if grep -q "^Categories=$" "$desktop"; then
|
if grep -q "^Categories=$" "$desktop"; then
|
||||||
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
|
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
|
||||||
echo "Filled in Categories for $(basename "$desktop")."
|
echo "Filled in Categories for ${desktop#"$root"/}."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
|
echo "Repacking."
|
||||||
|
|
||||||
tool="$work/appimagetool"
|
tool="$work/appimagetool"
|
||||||
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
||||||
@@ -211,13 +241,19 @@ chmod +x "$tool"
|
|||||||
|
|
||||||
# --appimage-extract-and-run: CI runners generally have no FUSE.
|
# --appimage-extract-and-run: CI runners generally have no FUSE.
|
||||||
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
|
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
|
||||||
|
rm -rf "$CHANNEL_DIR"
|
||||||
|
mkdir -p "$CHANNEL_DIR"
|
||||||
ARCH=x86_64 "$tool" --appimage-extract-and-run \
|
ARCH=x86_64 "$tool" --appimage-extract-and-run \
|
||||||
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null
|
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
|
||||||
chmod +x "$STABLE_NAME"
|
chmod +x "$CHANNEL_DIR/$STABLE_NAME"
|
||||||
|
|
||||||
# The versioned name is what the per-version release publishes; the stable one
|
# The versioned name is what the per-version release publishes; the stable one
|
||||||
# and its .zsync go to the rolling tag. Same bytes, two names.
|
# and its .zsync go to the rolling tag. Same bytes, two names, two places.
|
||||||
cp "$STABLE_NAME" "$appimage"
|
# zsyncmake writes the .zsync into the working directory, not beside the image
|
||||||
|
# it describes, so it has to be collected rather than assumed in place.
|
||||||
|
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
|
||||||
|
|
||||||
|
cp "$CHANNEL_DIR/$STABLE_NAME" "$appimage"
|
||||||
chmod +x "$appimage"
|
chmod +x "$appimage"
|
||||||
|
|
||||||
# The guards are the test. Each one is a way the repack could look like it
|
# The guards are the test. Each one is a way the repack could look like it
|
||||||
@@ -227,16 +263,28 @@ out="$check/squashfs-root"
|
|||||||
|
|
||||||
fail() { echo "FAILED: $1" >&2; exit 1; }
|
fail() { echo "FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
|
if [ "$demoted" = true ]; then
|
||||||
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
|
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
|
||||||
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
|
||||||
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
||||||
|
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
||||||
|
fi
|
||||||
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
||||||
|
|
||||||
# An empty Categories or missing metadata ships an image a manager cannot file
|
# An empty Categories or missing metadata ships an image a manager cannot file
|
||||||
# or describe, and both fail silently at runtime rather than at build time.
|
# or describe, and both fail silently at runtime rather than at build time.
|
||||||
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
|
# Asserted positively, over every entry: the earlier form checked only that no
|
||||||
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \
|
# *root* file held an empty value, which passed while the real entry under
|
||||||
|
# usr/share/applications shipped empty, and also passed on a missing key.
|
||||||
|
desktops=0
|
||||||
|
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
|
||||||
|
[ -e "$desktop" ] || continue
|
||||||
|
desktops=$((desktops + 1))
|
||||||
|
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|
||||||
|
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
|
||||||
|
done
|
||||||
|
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
|
||||||
|
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
|
||||||
|| fail "AppStream metadata did not make it into the image."; }
|
|| fail "AppStream metadata did not make it into the image."; }
|
||||||
|
|
||||||
# The update string is the difference between adoptable and updatable. It
|
# The update string is the difference between adoptable and updatable. It
|
||||||
@@ -245,13 +293,25 @@ grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
|
|||||||
# the URL it fetched the .zsync from. That is exactly why the output is named
|
# the URL it fetched the .zsync from. That is exactly why the output is named
|
||||||
# for the fixed tag: a versioned name here resolves to the build the client
|
# for the fixed tag: a versioned name here resolves to the build the client
|
||||||
# already has.
|
# already has.
|
||||||
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing."
|
[ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
|
||||||
[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync."
|
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
|
||||||
|
|
||||||
readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \
|
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|
||||||
|| fail "the image carries no update information for the $UPDATE_TAG tag."
|
|| fail "the image does not carry exactly the expected update information."
|
||||||
grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \
|
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
|
||||||
|| fail "the .zsync names something other than $STABLE_NAME."
|
|| fail "the .zsync names something other than $STABLE_NAME."
|
||||||
|
|
||||||
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream"
|
# The versioned release must carry one AppImage, not two. This is the guard
|
||||||
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync."
|
# for the duplicate that shipped in 0.4.20 and 0.4.21.
|
||||||
|
shopt -s nullglob
|
||||||
|
beside=(*.AppImage)
|
||||||
|
shopt -u nullglob
|
||||||
|
[ "${#beside[@]}" -eq 1 ] \
|
||||||
|
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
|
||||||
|
|
||||||
|
if [ "$demoted" = true ]; then
|
||||||
|
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
|
||||||
|
else
|
||||||
|
echo "OK: $appimage had no bundled $LIB to demote, and carries"
|
||||||
|
fi
|
||||||
|
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."
|
||||||
|
|||||||
@@ -17,7 +17,22 @@
|
|||||||
# It writes to GitHub rather than Gitea because that mirror is where updates
|
# It writes to GitHub rather than Gitea because that mirror is where updates
|
||||||
# are pulled from. Needs GH_PAT with contents write on the mirror.
|
# are pulled from. Needs GH_PAT with contents write on the mirror.
|
||||||
#
|
#
|
||||||
# Usage: GH_PAT=... publish-update-channel.sh <directory holding the artifacts>
|
# **The tag has to exist in Gitea, not just on GitHub, and that is the whole
|
||||||
|
# reason this script touches Gitea at all.** Gitea push-mirrors this repo to
|
||||||
|
# GitHub, and a mirror push deletes remote refs that have no local counterpart.
|
||||||
|
# A tag created only by GitHub's release API therefore survives until the next
|
||||||
|
# mirror run and then vanishes — which is exactly what happened to 0.4.20 and
|
||||||
|
# 0.4.21: the release was created and both URLs verified 200 at 00:38, and the
|
||||||
|
# 13:04 mirror deleted the tag, leaving every installed copy checking a 404.
|
||||||
|
# Versioned tags never had this problem because `create-tag` creates them in
|
||||||
|
# Gitea first. So does this one, now, and before the GitHub release rather than
|
||||||
|
# after, so there is no window where the two disagree.
|
||||||
|
#
|
||||||
|
# Note what this means for verification: publishing correctly is not evidence
|
||||||
|
# the channel still works hours later. The Gitea tag is what makes it durable,
|
||||||
|
# so its absence is treated as a failure rather than a warning.
|
||||||
|
#
|
||||||
|
# Usage: GH_PAT=... GITEA_TOKEN=... GITEA_SHA=... publish-update-channel.sh <dir>
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,7 +41,12 @@ TAG="linux-latest"
|
|||||||
API="https://api.github.com/repos/$REPO"
|
API="https://api.github.com/repos/$REPO"
|
||||||
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
|
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
|
||||||
|
|
||||||
|
GITEA_API="${GITEA_API:-https://repo.anhonesthost.net/api/v1}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-CyberCoveLLC/Triple-C}"
|
||||||
|
|
||||||
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
|
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
|
||||||
|
: "${GITEA_TOKEN:?GITEA_TOKEN is required to anchor the $TAG tag against the mirror}"
|
||||||
|
: "${GITEA_SHA:?GITEA_SHA is required to point the $TAG tag at this build}"
|
||||||
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
|
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
|
||||||
cd "$dir"
|
cd "$dir"
|
||||||
|
|
||||||
@@ -35,46 +55,179 @@ for asset in "${ASSETS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
|
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
|
||||||
|
tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||||
|
# Status, not a boolean. `curl -sf` fails identically for "404, the tag is
|
||||||
|
# genuinely absent" and "503, Gitea is briefly unreachable", and treating the
|
||||||
|
# second as the first means POSTing over a tag that already exists, taking a
|
||||||
|
# 409, and aborting the last step of build-linux — which `create-tag` and
|
||||||
|
# `sync-to-github` both depend on. A transient blip would cost the release, not
|
||||||
|
# just the channel update. Same `case`-on-code idiom as `Upload to Gitea
|
||||||
|
# release` two steps above in the workflow. A refused connection reports 000
|
||||||
|
# and lands in the catch-all.
|
||||||
|
tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; }
|
||||||
|
|
||||||
echo "==> Looking for the $TAG release"
|
# Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
|
||||||
release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)"
|
#
|
||||||
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)"
|
# An earlier version deleted and recreated it so the tag would name the current
|
||||||
|
# build. That was worse than useless: nothing about the channel depends on
|
||||||
|
# which commit the tag points at — the update string resolves the tag by *name*
|
||||||
|
# and the assets hang off the release object — while a DELETE followed by a
|
||||||
|
# failed POST destroys a working anchor and leaves a window in which a mirror
|
||||||
|
# run prunes GitHub's copy. A transient Gitea error would have converted a
|
||||||
|
# healthy channel into a dead one, which is strictly worse than this step not
|
||||||
|
# existing. Gitea's POST /tags has no force semantics, so the DELETE was only
|
||||||
|
# ever there to get around a 409; asking first removes the need.
|
||||||
|
echo "==> Anchoring the $TAG tag in Gitea"
|
||||||
|
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
case "$anchor_probe" in
|
||||||
|
200)
|
||||||
|
echo " already anchored — left alone"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo " creating it at ${GITEA_SHA:0:9}"
|
||||||
|
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
|
||||||
|
>/dev/null
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2
|
||||||
|
echo " Refusing to guess — creating it blindly would 409 over an" >&2
|
||||||
|
echo " existing tag and abort the release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Not best-effort. Without this tag the mirror removes GitHub's and the
|
||||||
|
# channel dies silently somewhere between now and four hours from now. Reported
|
||||||
|
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
|
||||||
|
anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
|
||||||
|
[ "$anchor_code" = "200" ] || {
|
||||||
|
echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2
|
||||||
|
echo " without it the mirror would delete GitHub's copy." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Look through the authenticated list rather than /releases/tags/, which never
|
||||||
|
# returns drafts. That matters here specifically: GitHub demotes a published
|
||||||
|
# release to a draft when its tag is deleted, which is the state every mirror
|
||||||
|
# run left behind, so the by-tag lookup reports "absent" while orphaned drafts
|
||||||
|
# sit there holding 86 MB each. Reuse the newest and delete the rest, or they
|
||||||
|
# accumulate one per release forever.
|
||||||
|
echo "==> Looking for the $TAG release (drafts included)"
|
||||||
|
all_releases="$(gh "$API/releases?per_page=100")"
|
||||||
|
mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
tag = sys.argv[1]
|
||||||
|
rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag]
|
||||||
|
rs.sort(key=lambda r: r.get("created_at",""), reverse=True)
|
||||||
|
for r in rs:
|
||||||
|
print(r["id"])
|
||||||
|
' "$TAG")
|
||||||
|
|
||||||
|
release_id="${existing[0]:-}"
|
||||||
|
|
||||||
|
for stale in "${existing[@]:1}"; do
|
||||||
|
echo " deleting orphaned duplicate release $stale"
|
||||||
|
gh -X DELETE "$API/releases/$stale" >/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$release_id" ]; then
|
||||||
|
# A draft has no tag and serves no download URL, so it has to be republished.
|
||||||
|
echo " reusing release $release_id"
|
||||||
|
# `make_latest` is not optional here even though this release already exists.
|
||||||
|
# Publishing a draft is a publish transition, where the API's documented
|
||||||
|
# default is `true` — so omitting it would quietly promote this channel to
|
||||||
|
# the repository's "Latest release" and bury the versioned release a person
|
||||||
|
# actually wants from the releases page.
|
||||||
|
#
|
||||||
|
# `tag_name` is re-sent deliberately, and must be: the API removes the tag
|
||||||
|
# when a PATCH omits it. Given this whole change exists because a tag
|
||||||
|
# disappeared, that is an expensive line to tidy away.
|
||||||
|
gh -X PATCH "$API/releases/$release_id" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null
|
||||||
|
release="$(gh "$API/releases/$release_id")"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$release_id" ]; then
|
if [ -z "$release_id" ]; then
|
||||||
echo "==> Creating it"
|
echo "==> Creating it"
|
||||||
# Not a prerelease, but deliberately not the "latest" release either: this
|
# Not a prerelease, but deliberately not the "latest" release either: this
|
||||||
# tag is a channel, and it must never displace the versioned release a
|
# tag is a channel, and it must never displace the versioned release a
|
||||||
# person lands on from the releases page.
|
# person lands on from the releases page.
|
||||||
release="$(gh -X POST "$API/releases" -d "$(python3 -c '
|
body_json="$(python3 -c '
|
||||||
import json
|
import json
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": "'"$TAG"'",
|
"tag_name": "'"$TAG"'",
|
||||||
"name": "Linux update channel",
|
"name": "Linux update channel",
|
||||||
"body": "Rolling AppImage build that Triple-C’s in-app updater reads. "
|
"body": "Rolling AppImage build that Triple-C\u2019s in-app updater reads. "
|
||||||
"The two files here are replaced on every release; for a specific "
|
"The two files here are replaced on every release; for a specific "
|
||||||
"version, use the versioned releases instead.",
|
"version, use the versioned releases instead.",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
"make_latest": "false",
|
"make_latest": "false",
|
||||||
}))')")"
|
}))')"
|
||||||
|
|
||||||
|
# `already_exists` is a benign, recoverable answer, not a reason to abort the
|
||||||
|
# last step of build-linux and lose the release with it. It means a release
|
||||||
|
# for this tag exists but the listing above did not show it — a draft that has
|
||||||
|
# sunk past the first page, since a draft's created_at is frozen while newer
|
||||||
|
# releases push it down. Re-ask by tag and carry on.
|
||||||
|
create_body="$(mktemp)"
|
||||||
|
create_code="$(curl -s -o "$create_body" -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" \
|
||||||
|
-X POST "$API/releases" -d "$body_json")"
|
||||||
|
|
||||||
|
case "$create_code" in
|
||||||
|
201)
|
||||||
|
release="$(cat "$create_body")"
|
||||||
|
;;
|
||||||
|
422)
|
||||||
|
if grep -q "already_exists" "$create_body"; then
|
||||||
|
echo " a release for $TAG already exists but was not listed — reusing it"
|
||||||
|
release="$(gh "$API/releases/tags/$TAG")"
|
||||||
|
else
|
||||||
|
echo "FAILED: GitHub rejected the release (422):" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FAILED: creating the $TAG release returned $create_code:" >&2
|
||||||
|
cat "$create_body" >&2
|
||||||
|
rm -f "$create_body"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$create_body"
|
||||||
|
|
||||||
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
|
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Removing superseded assets from release $release_id"
|
# One asset at a time, delete immediately followed by upload. Deleting both up
|
||||||
printf '%s' "$release" | python3 -c '
|
# front leaves the channel holding a fresh AppImage and no .zsync if the second
|
||||||
|
# upload fails, and a client that cannot fetch the .zsync simply stops updating
|
||||||
|
# — no error anyone here would see.
|
||||||
|
asset_ids="$(printf '%s' "$release" | python3 -c '
|
||||||
import sys, json
|
import sys, json
|
||||||
keep = set(sys.argv[1:])
|
keep = set(sys.argv[1:])
|
||||||
|
out = {}
|
||||||
for a in json.load(sys.stdin).get("assets", []):
|
for a in json.load(sys.stdin).get("assets", []):
|
||||||
if a["name"] in keep:
|
if a["name"] in keep:
|
||||||
print(a["id"])
|
out[a["name"]] = a["id"]
|
||||||
' "${ASSETS[@]}" | while read -r asset_id; do
|
print(json.dumps(out))
|
||||||
[ -n "$asset_id" ] || continue
|
' "${ASSETS[@]}")"
|
||||||
gh -X DELETE "$API/releases/assets/$asset_id" >/dev/null || true
|
|
||||||
done
|
|
||||||
|
|
||||||
|
# --retry/--max-time/--http1.1 for the reason the Gitea upload steps in this
|
||||||
|
# repo carry them: real mid-stream failures on large assets (curl 92 and 28).
|
||||||
for asset in "${ASSETS[@]}"; do
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
stale_id="$(printf '%s' "$asset_ids" | python3 -c 'import sys,json;print(json.load(sys.stdin).get(sys.argv[1],""))' "$asset")"
|
||||||
|
if [ -n "$stale_id" ]; then
|
||||||
|
echo "==> Replacing $asset (dropping superseded asset $stale_id)"
|
||||||
|
gh -X DELETE "$API/releases/assets/$stale_id" >/dev/null || true
|
||||||
|
fi
|
||||||
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
|
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
|
||||||
curl -sf -X POST \
|
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
|
||||||
|
-X POST \
|
||||||
-H "Authorization: Bearer $GH_PAT" \
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@$asset" \
|
--data-binary "@$asset" \
|
||||||
@@ -84,12 +237,22 @@ done
|
|||||||
# The updater is only as good as this URL, and a silent failure here means
|
# The updater is only as good as this URL, and a silent failure here means
|
||||||
# every installed copy quietly stops updating. Confirm both are actually
|
# every installed copy quietly stops updating. Confirm both are actually
|
||||||
# fetchable at the address the AppImage was built to check.
|
# fetchable at the address the AppImage was built to check.
|
||||||
|
# Size as well as status: a 200 only proves something is served at the
|
||||||
|
# address, not that it is this build. GitHub accepting a truncated upload
|
||||||
|
# would pass a status-only check and then fail every client's checksum.
|
||||||
echo "==> Verifying the published URLs"
|
echo "==> Verifying the published URLs"
|
||||||
for asset in "${ASSETS[@]}"; do
|
for asset in "${ASSETS[@]}"; do
|
||||||
url="https://github.com/$REPO/releases/download/$TAG/$asset"
|
url="https://github.com/$REPO/releases/download/$TAG/$asset"
|
||||||
code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")"
|
local_size="$(stat -c %s "$asset")"
|
||||||
[ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; }
|
|
||||||
echo " $code $url"
|
headers="$(curl -sIL "$url" | tr -d '\r')"
|
||||||
|
code="$(printf '%s\n' "$headers" | awk '/^HTTP\//{c=$2} END{print c}')"
|
||||||
|
served="$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:"{n=$2} END{print n}')"
|
||||||
|
|
||||||
|
[ "$code" = "200" ] || { echo "FAILED: $url returned ${code:-no status}" >&2; exit 1; }
|
||||||
|
[ "$served" = "$local_size" ] \
|
||||||
|
|| { echo "FAILED: $url serves ${served:-unknown} bytes, built $local_size." >&2; exit 1; }
|
||||||
|
echo " $code $served bytes $url"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "OK: $TAG updated."
|
echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."
|
||||||
|
|||||||
Reference in New Issue
Block a user