Compare commits

..

19 Commits

Author SHA1 Message Date
1aced2d860 feat: add progress feedback during slow container starts
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 7m23s
Sync Release to GitHub / sync-release (release) Successful in 2s
Emit container-progress events from Rust at key milestones (checking
image, saving state, recreating, starting, stopping) and display them
in ProjectCard instead of the static "starting.../stopping..." text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:43:01 -08:00
652e451afe fix: prevent projects from getting stuck in starting/stopping state
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-linux (push) Successful in 5m46s
Sync Release to GitHub / sync-release (release) Successful in 1s
Build App / build-windows (push) Successful in 6m35s
Reconcile stale transient statuses on app startup, add Force Stop button
for transient states, and harden stop_project_container error handling so
Docker failures don't leave projects permanently stuck.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:12:49 -08:00
eb86aa95b7 fix: persist full container state across stop/start and config-change recreation
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 2m29s
Build App / build-linux (push) Successful in 4m34s
Sync Release to GitHub / sync-release (release) Successful in 1s
- Add home volume (triple-c-home-{id}) for /home/claude to persist
  .claude.json, .local, and other user-level state across restarts
- Add docker commit before recreation: when container_needs_recreation()
  triggers, snapshot the container to preserve system-level changes
  (apt/pip/npm installs), then create the new container from that snapshot
- On Reset/removal: delete snapshot image + both volumes for clean slate
- Remove commit from stop_project_container (stop/start preserves the
  writable layer naturally; no commit needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:16:19 -08:00
3228e6cdd7 fix: Docker status never updates after Docker starts
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 5m56s
Sync Release to GitHub / sync-release (release) Successful in 1s
Replace OnceLock with Mutex<Option<Docker>> in the Rust backend so
failed Docker connections are retried instead of cached permanently.
Add frontend polling (every 5s) when Docker is initially unavailable,
stopping once detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:46:59 -08:00
3344ce1cbf fix: prevent spurious container recreation on every start
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m1s
Build App / build-linux (push) Successful in 5m6s
Sync Release to GitHub / sync-release (release) Successful in 1s
The CLAUDE_INSTRUCTIONS env var was computed differently during container
creation (with port mapping docs + scheduler instructions appended) vs
the recreation check (bare merge only). This caused
container_needs_recreation() to always return true, triggering a full
recreate on every stop/start cycle.

Extract build_claude_instructions() helper used by both code paths so
the expected value always matches what was set at creation time.

Also add TODO.md noting planned tauri-plugin-updater integration for
seamless in-app updates on all platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:22:25 -08:00
d642cc64de fix: use browser_download_url for Gitea asset downloads
The API endpoint /releases/assets/{id} returns JSON metadata, not the
binary file. Use the browser_download_url from the asset object instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:09:41 -08:00
e3502876eb rename Triple-C.md to README.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:07:16 -08:00
4f41f0d98b feat: add Gitea actions to sync releases to GitHub
Add two new workflows:
- sync-release.yml: automatically mirrors releases (with assets) to GitHub when published on Gitea
- backfill-releases.yml: manual workflow to bulk-sync all existing Gitea releases to GitHub

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:06:29 -08:00
c9dc232fc4 fix: remove Node.js from actual path location on Act runner
Some checks failed
Build App / build-windows (push) Successful in 3m40s
Build App / build-linux (push) Successful in 7m4s
Build App / build-macos (push) Failing after 11m34s
The Act runner has Node 18 at /opt/acttoolcache/node/18.20.3/x64/bin/,
not at /usr/local/bin/. Use $(dirname "$(which node)") to find and
remove the actual binary location before installing Node 22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:08:46 -08:00
2d4fce935f fix: remove old Node.js before installing v22 on Linux runner
Some checks failed
Build App / build-linux (push) Failing after 2m34s
Build App / build-windows (push) Successful in 3m40s
Build App / build-macos (push) Has been cancelled
The Act runner has Node 18 at /usr/local/bin/node which takes
precedence over the apt-installed /usr/bin/node. Even after
running nodesource setup and apt-get install, the old Node 18
binary remained in the PATH. Now removes old binaries and uses
hash -r to force path re-lookup. Also removes package-lock.json
before npm install to ensure correct platform-specific bindings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:03:31 -08:00
e739f6aaff fix: check Node.js version, not just presence, in CI
Some checks failed
Build App / build-macos (push) Successful in 2m23s
Build App / build-linux (push) Failing after 3m38s
Build App / build-windows (push) Successful in 4m1s
The Act runner has Node.js v18 pre-installed, so the check
`command -v node` passes and skips installing v22. Node 18 is
too old for dependencies like vitest, jsdom, and tailwindcss/oxide.
Now checks the major version and upgrades if < 22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:54:36 -08:00
550159fc63 Fix native binding error: use npm install instead of npm ci
Some checks failed
Build App / build-linux (push) Failing after 2m25s
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 4m8s
@tailwindcss/oxide has platform-specific native bindings. The
package-lock.json was generated on a different platform, so npm ci
installs the wrong native binary. Switching to rm -rf node_modules
+ npm install lets npm resolve the correct platform-specific
optional dependency (e.g., @tailwindcss/oxide-linux-x64-gnu on
Linux, oxide-darwin-arm64 on macOS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:44:03 -08:00
e3c874bc75 Fix cargo PATH: use explicit export in every step that needs it
Some checks failed
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m1s
Build App / build-linux (push) Failing after 1m30s
The Gitea Act runner's Docker container does not reliably support
$GITHUB_PATH or sourcing ~/.cargo/env across steps. Both mechanisms
failed because the runner spawns a fresh shell for each step.

Adopted the same pattern that already works for the Windows job:
explicitly set PATH at the top of every step that calls cargo or
npx tauri. This is the most portable approach across all runner
environments (Act Docker containers, bare metal macOS, Windows).

Build history shows Linux succeeded through run#46 (JS-based
actions) and failed from run#49 onward (shell-based installs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:36:02 -08:00
6cae0e7feb Source cargo env before using rustup in install step
Some checks failed
Build App / build-macos (push) Successful in 2m22s
Build App / build-linux (push) Has been cancelled
Build App / build-windows (push) Has been cancelled
GITHUB_PATH only takes effect in subsequent steps, but rustup/rustc/cargo
are called within the same step. Adding `. "$HOME/.cargo/env"` immediately
after install puts cargo/rustup in PATH for the remainder of the step.
Fixed in both Linux and macOS jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:32:28 -08:00
b566446b75 Trigger multi-arch container build for ARM64 support
All checks were successful
Build Container / build-container (push) Successful in 8m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:31:13 -08:00
601a2db3cf Move Node.js install before checkout in Linux build
Some checks failed
Build App / build-macos (push) Failing after 6s
Build App / build-windows (push) Successful in 3m38s
Build App / build-linux (push) Failing after 3m56s
The Gitea Linux runner also lacks Node.js, causing actions/checkout@v4
(a JS action) to fail with "node: executable file not found in PATH".
Same fix as the macOS job: install Node.js via shell before any
JS-based actions run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:28:02 -08:00
b795e27251 Fix Linux build PATH and add ARM64 container support
Some checks failed
Build App / build-linux (push) Failing after 2s
Build App / build-macos (push) Failing after 11s
Build App / build-windows (push) Successful in 3m38s
Linux app build: cargo was not in PATH for subsequent steps after
shell-based install. Fixed by adding $HOME/.cargo/bin to GITHUB_PATH
(persists across steps) and setting it in the job-level env. Also
removed the now-unnecessary per-step PATH override in the macOS job.

Container build: added QEMU setup and platforms: linux/amd64,linux/arm64
to produce a multi-arch manifest. The Dockerfile already uses
arch-aware commands (dpkg --print-architecture, uname -m) so it
builds natively on both architectures. This fixes the "no matching
manifest for linux/arm64/v8" error on Apple Silicon Macs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:16:45 -08:00
19d4cbce27 Use shell-based check-before-install for all build jobs
Some checks failed
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 2m33s
Build App / build-linux (push) Failing after 3m40s
Replace JS-based GitHub Actions (dtolnay/rust-toolchain,
actions/setup-node) in the Linux job with shell commands that
check if Rust and Node.js are already present before installing.
All three jobs (Linux, macOS, Windows) now use the same pattern:
skip installation if the tool is already available on the runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:03:17 -08:00
946ea03956 Fix macOS CI build and add macOS build instructions
Some checks failed
Build App / build-windows (push) Has started running
Build App / build-linux (push) Has been cancelled
Build App / build-macos (push) Has been cancelled
The macOS Gitea runner lacks Node.js, causing actions/checkout@v4
(a JS action) to fail with "Cannot find: node in PATH". Fixed by
installing Node.js via Homebrew before checkout and replacing all
JS-based actions (setup-node, rust-toolchain, rust-cache) with
shell equivalents.

Also adds macOS section to BUILDING.md covering Xcode CLI tools,
universal binary targets, and Gatekeeper bypass instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:00:48 -08:00
15 changed files with 674 additions and 98 deletions

View File

@@ -0,0 +1,84 @@
name: Backfill Releases to GitHub
on:
workflow_dispatch:
jobs:
backfill:
runs-on: ubuntu-latest
steps:
- name: Backfill all Gitea releases to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_API: https://repo.anhonesthost.net/api/v1
GITEA_REPO: cybercovellc/triple-c
GITHUB_REPO: shadowdao/triple-c
run: |
set -e
echo "==> Fetching releases from Gitea..."
RELEASES=$(curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$GITEA_REPO/releases?limit=50")
echo "$RELEASES" | jq -c '.[]' | while read release; do
TAG=$(echo "$release" | jq -r '.tag_name')
NAME=$(echo "$release" | jq -r '.name')
BODY=$(echo "$release" | jq -r '.body')
IS_PRERELEASE=$(echo "$release" | jq -r '.prerelease')
IS_DRAFT=$(echo "$release" | jq -r '.draft')
EXISTS=$(curl -sf \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG" \
-o /dev/null -w "%{http_code}" || true)
if [ "$EXISTS" = "200" ]; then
echo "==> Skipping $TAG (already exists on GitHub)"
continue
fi
echo "==> Creating release $TAG..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$NAME\",
\"body\": $(echo "$BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "$release" | jq -c '.assets[]?' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_ID=$(echo "$asset" | jq -r '.id')
echo " ==> Downloading $ASSET_NAME..."
DOWNLOAD_URL=$(echo "$asset" | jq -r '.browser_download_url')
curl -sfL -o "/tmp/$ASSET_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
"$DOWNLOAD_URL"
echo " ==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Done: $TAG"
done
echo "==> Backfill complete."

View File

@@ -20,6 +20,32 @@ jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
echo "Node.js at: $(which node)"
node --version
npm --version
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -61,29 +87,35 @@ jobs:
xdg-utils xdg-utils
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable run: |
if command -v rustup >/dev/null 2>&1; then
- name: Rust cache echo "Rust already installed: $(rustc --version)"
uses: swatinem/rust-cache@v2 rustup update stable
with: rustup default stable
workspaces: "./app/src-tauri -> target" else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
- name: Install Node.js fi
uses: actions/setup-node@v4 export PATH="$HOME/.cargo/bin:$PATH"
with: rustc --version
node-version: "22" cargo --version
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: npm ci run: |
rm -rf node_modules package-lock.json
npm install
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
run: npx tauri --version || npm install @tauri-apps/cli run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app - name: Build Tauri app
working-directory: ./app working-directory: ./app
run: npx tauri build run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
- name: Collect artifacts - name: Collect artifacts
run: | run: |
@@ -122,6 +154,27 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
brew install node@22
brew link --overwrite node@22
fi
node --version
npm --version
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -144,31 +197,36 @@ jobs:
echo "Patched version to ${VERSION}" echo "Patched version to ${VERSION}"
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable run: |
with: if command -v rustup >/dev/null 2>&1; then
targets: aarch64-apple-darwin,x86_64-apple-darwin echo "Rust already installed: $(rustc --version)"
rustup update stable
- name: Rust cache rustup default stable
uses: swatinem/rust-cache@v2 else
with: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
workspaces: "./app/src-tauri -> target" fi
export PATH="$HOME/.cargo/bin:$PATH"
- name: Install Node.js rustup target add aarch64-apple-darwin x86_64-apple-darwin
uses: actions/setup-node@v4 rustc --version
with: cargo --version
node-version: "22"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: npm ci run: |
rm -rf node_modules
npm install
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
run: npx tauri --version || npm install @tauri-apps/cli run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app (universal) - name: Build Tauri app (universal)
working-directory: ./app working-directory: ./app
run: npx tauri build --target universal-apple-darwin run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
- name: Collect artifacts - name: Collect artifacts
run: | run: |

View File

@@ -21,6 +21,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -36,6 +39,7 @@ jobs:
with: with:
context: ./container context: ./container
file: ./container/Dockerfile file: ./container/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ gitea.event_name == 'push' }} push: ${{ gitea.event_name == 'push' }}
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

View File

@@ -0,0 +1,60 @@
name: Sync Release to GitHub
on:
release:
types: [published]
jobs:
sync-release:
runs-on: ubuntu-latest
steps:
- name: Mirror release to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
RELEASE_NAME: ${{ gitea.event.release.name }}
RELEASE_BODY: ${{ gitea.event.release.body }}
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
IS_DRAFT: ${{ gitea.event.release.draft }}
run: |
set -e
echo "==> Creating release $RELEASE_TAG on GitHub..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$RELEASE_TAG\",
\"name\": \"$RELEASE_NAME\",
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "Release created. Upload URL: $UPLOAD_URL"
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
echo "==> Downloading asset: $ASSET_NAME"
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
echo "==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Release sync complete."

View File

@@ -1,6 +1,6 @@
# Building Triple-C # Building Triple-C
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux and Windows. Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux, macOS, and Windows.
## Prerequisites (All Platforms) ## Prerequisites (All Platforms)
@@ -79,6 +79,57 @@ Build artifacts are located in `app/src-tauri/target/release/bundle/`:
| Debian pkg | `deb/*.deb` | | Debian pkg | `deb/*.deb` |
| RPM pkg | `rpm/*.rpm` | | RPM pkg | `rpm/*.rpm` |
## macOS
### 1. Install prerequisites
- **Xcode Command Line Tools** — required for the C/C++ toolchain and system headers:
```bash
xcode-select --install
```
No additional system libraries are needed — macOS includes WebKit natively.
### 2. Install Rust targets (universal binary)
To build a universal binary that runs on both Apple Silicon and Intel Macs:
```bash
rustup target add aarch64-apple-darwin x86_64-apple-darwin
```
### 3. Install frontend dependencies
```bash
cd app
npm ci
```
### 4. Build
For a universal binary (recommended for distribution):
```bash
npx tauri build --target universal-apple-darwin
```
For the current architecture only (faster, for local development):
```bash
npx tauri build
```
Build artifacts are located in `app/src-tauri/target/universal-apple-darwin/release/bundle/` (or `target/release/bundle/` for single-arch builds):
| Format | Path |
|--------|------|
| DMG | `dmg/*.dmg` |
| macOS App | `macos/*.app` |
| macOS App (compressed) | `macos/*.app.tar.gz` |
> **Note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it. Right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`
## Windows ## Windows
### 1. Install prerequisites ### 1. Install prerequisites

60
TODO.md Normal file
View File

@@ -0,0 +1,60 @@
# TODO / Future Improvements
## In-App Auto-Update via `tauri-plugin-updater`
**Priority:** High
**Status:** Planned
Currently the app detects available updates via the Gitea API (`check_for_updates` command) but cannot apply them. Users must manually download and install the new version. On macOS and Linux this is a poor experience compared to Windows (where NSIS handles upgrades cleanly).
### Recommended approach: `tauri-plugin-updater`
Full in-app auto-update: detects, downloads, verifies, and applies updates seamlessly on all platforms. The user clicks "Update" and the app restarts with the new version.
### Requirements
1. **Generate a Tauri update signing key pair** (this is Tauri's own Ed25519 key, not OS code signing):
```bash
npx @tauri-apps/cli signer generate -w ~/.tauri/triple-c.key
```
Set `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` in CI.
2. **Add `tauri-plugin-updater`** to Rust and JS dependencies.
3. **Create an update endpoint** that returns Tauri's expected JSON format:
```json
{
"version": "v0.1.100",
"notes": "Changelog here",
"pub_date": "2026-03-01T00:00:00Z",
"platforms": {
"darwin-x86_64": { "signature": "...", "url": "https://..." },
"darwin-aarch64": { "signature": "...", "url": "https://..." },
"linux-x86_64": { "signature": "...", "url": "https://..." },
"windows-x86_64": { "signature": "...", "url": "https://..." }
}
}
```
This could be a static JSON file uploaded alongside release assets, or a small API that reads from Gitea releases and reformats.
4. **Configure the updater** in `tauri.conf.json`:
```json
"plugins": {
"updater": {
"endpoints": ["https://repo.anhonesthost.net/...update-endpoint..."],
"pubkey": "<public key from step 1>"
}
}
```
5. **Add frontend UI** for the update prompt (replace or enhance the existing update check flow).
6. **Update CI pipeline** to:
- Sign bundles with the Tauri key during build
- Upload `.sig` files alongside installers
- Generate/upload the update endpoint JSON
### References
- https://v2.tauri.app/plugin/updater/
- Existing update check code: `app/src-tauri/src/commands/update_commands.rs`
- Existing models: `app/src-tauri/src/models/update_info.rs`

View File

@@ -1,10 +1,20 @@
use tauri::State; use tauri::{Emitter, State};
use crate::docker; use crate::docker;
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus}; use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
let _ = app_handle.emit(
"container-progress",
serde_json::json!({
"project_id": project_id,
"message": message,
}),
);
}
/// Extract secret fields from a project and store them in the OS keychain. /// Extract secret fields from a project and store them in the OS keychain.
fn store_secrets_for_project(project: &Project) -> Result<(), String> { fn store_secrets_for_project(project: &Project) -> Result<(), String> {
if let Some(ref token) = project.git_token { if let Some(ref token) = project.git_token {
@@ -81,12 +91,19 @@ pub async fn remove_project(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Stop and remove container if it exists // Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) { if let Some(ref project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id { if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await; state.exec_manager.close_sessions_for_container(container_id).await;
let _ = docker::stop_container(container_id).await; let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await; let _ = docker::remove_container(container_id).await;
} }
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
} }
// Clean up keychain secrets for this project // Clean up keychain secrets for this project
@@ -109,6 +126,7 @@ pub async fn update_project(
#[tauri::command] #[tauri::command]
pub async fn start_project_container( pub async fn start_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
let mut project = state let mut project = state
@@ -140,6 +158,7 @@ pub async fn start_project_container(
// Wrap container operations so that any failure resets status to Stopped. // Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async { let result: Result<String, String> = async {
// Ensure image exists // Ensure image exists
emit_progress(&app_handle, &project_id, "Checking image...");
if !docker::image_exists(&image_name).await? { if !docker::image_exists(&image_name).await? {
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
} }
@@ -153,48 +172,77 @@ pub async fn start_project_container(
// AWS config path from global settings // AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone(); let aws_config_path = settings.global_aws.aws_config_path.clone();
// Check for existing container
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
let needs_recreation = docker::container_needs_recreation( // Check if config changed — if so, snapshot + recreate
&existing_id, let needs_recreate = docker::container_needs_recreation(
&project, &existing_id,
settings.global_claude_instructions.as_deref(), &project,
&settings.global_custom_env_vars, settings.global_claude_instructions.as_deref(),
settings.timezone.as_deref(), &settings.global_custom_env_vars,
) settings.timezone.as_deref(),
.await ).await.unwrap_or(false);
.unwrap_or(false);
if needs_recreation { if needs_recreate {
log::info!("Container config changed, recreating container for project {}", project.id); log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
// Snapshot the filesystem before destroying
emit_progress(&app_handle, &project_id, "Saving container state...");
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
log::warn!("Failed to snapshot container before recreation: {}", e);
}
emit_progress(&app_handle, &project_id, "Recreating container...");
let _ = docker::stop_container(&existing_id).await; let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?; docker::remove_container(&existing_id).await?;
// Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
snapshot_image
} else {
image_name.clone()
};
let new_id = docker::create_container( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
} else { } else {
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&existing_id).await?; docker::start_container(&existing_id).await?;
existing_id existing_id
} }
} else { } else {
// Container doesn't exist (first start, or Docker pruned it).
// Check for a snapshot image first — it preserves system-level
// changes (apt/pip/npm installs) from the previous session.
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
log::info!("Creating container from snapshot image for project {}", project.id);
snapshot_image
} else {
image_name.clone()
};
emit_progress(&app_handle, &project_id, "Creating container...");
let new_id = docker::create_container( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
}; };
@@ -222,6 +270,7 @@ pub async fn start_project_container(
#[tauri::command] #[tauri::command]
pub async fn stop_project_container( pub async fn stop_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let project = state let project = state
@@ -229,22 +278,26 @@ pub async fn stop_project_container(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(ref container_id) = project.container_id { state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
if let Some(ref container_id) = project.container_id {
// Close exec sessions for this project // Close exec sessions for this project
emit_progress(&app_handle, &project_id, "Stopping container...");
state.exec_manager.close_sessions_for_container(container_id).await; state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?; if let Err(e) = docker::stop_container(container_id).await {
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
}
} }
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn rebuild_project_container( pub async fn rebuild_project_container(
project_id: String, project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
let project = state let project = state
@@ -260,8 +313,16 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?; state.projects_store.set_container_id(&project_id, None)?;
} }
// Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(&project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
// Start fresh // Start fresh
start_project_container(project_id, state).await start_project_container(project_id, app_handle, state).await
} }
fn default_docker_socket() -> String { fn default_docker_socket() -> String {

View File

@@ -1,23 +1,28 @@
use bollard::Docker; use bollard::Docker;
use std::sync::OnceLock; use std::sync::Mutex;
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new(); static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
pub fn get_docker() -> Result<&'static Docker, String> { pub fn get_docker() -> Result<Docker, String> {
let result = DOCKER.get_or_init(|| { let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
Docker::connect_with_local_defaults() if let Some(docker) = guard.as_ref() {
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e)) return Ok(docker.clone());
});
match result {
Ok(docker) => Ok(docker),
Err(e) => Err(e.clone()),
} }
let docker = Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
guard.replace(docker.clone());
Ok(docker)
} }
pub async fn check_docker_available() -> Result<bool, String> { pub async fn check_docker_available() -> Result<bool, String> {
let docker = get_docker()?; let docker = get_docker()?;
match docker.ping().await { match docker.ping().await {
Ok(_) => Ok(true), Ok(_) => Ok(true),
Err(e) => Err(format!("Docker daemon not responding: {}", e)), Err(_) => {
// Connection object exists but daemon not responding — clear cache
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
*guard = None;
Ok(false)
}
} }
} }

View File

@@ -2,6 +2,7 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -40,6 +41,42 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
### Timezone ### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#; Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
/// the same value is produced in both paths.
fn build_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
) -> Option<String> {
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
if !port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SCHEDULER_INSTRUCTIONS),
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
combined
}
/// Compute a fingerprint string for the custom environment variables. /// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation. /// Sorted alphabetically so order changes do not cause spurious recreation.
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
@@ -307,33 +344,12 @@ pub async fn create_container(
} }
} }
// Claude instructions (global + per-project, plus port mapping info) // Claude instructions (global + per-project, plus port mapping info + scheduler docs)
let mut combined_instructions = merge_claude_instructions( let combined_instructions = build_claude_instructions(
global_claude_instructions, global_claude_instructions,
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
&project.port_mappings,
); );
if !project.port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in &project.port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined_instructions = Some(match combined_instructions {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
// Scheduler instructions (always appended so all containers get scheduling docs)
let scheduler_docs = SCHEDULER_INSTRUCTIONS;
combined_instructions = Some(match combined_instructions {
Some(existing) => format!("{}\n\n{}", existing, scheduler_docs),
None => scheduler_docs.to_string(),
});
if let Some(ref instructions) = combined_instructions { if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
@@ -352,7 +368,19 @@ pub async fn create_container(
}); });
} }
// Named volume for claude config persistence // Named volume for the entire home directory — preserves ~/.claude.json,
// ~/.local (pip/npm globals), and any other user-level state across
// container stop/start cycles.
mounts.push(Mount {
target: Some("/home/claude".to_string()),
source: Some(format!("triple-c-home-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// Named volume for claude config persistence — mounted as a nested volume
// inside the home volume; Docker gives the more-specific mount precedence.
mounts.push(Mount { mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()), target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)), source: Some(format!("triple-c-claude-config-{}", project.id)),
@@ -523,6 +551,83 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e)) .map_err(|e| format!("Failed to remove container: {}", e))
} }
/// Return the snapshot image name for a project.
pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id)
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal. The Config is left empty so that secrets injected as env vars are
/// NOT baked into the image.
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
// Parse repo:tag
let (repo, tag) = match image_name.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (image_name.clone(), "latest".to_string()),
};
let options = CommitContainerOptions {
container: container_id.to_string(),
repo: repo.clone(),
tag: tag.clone(),
pause: true,
..Default::default()
};
// Empty config — no env vars / cmd baked in
let config = Config::<String> {
..Default::default()
};
docker
.commit_container(options, config)
.await
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag);
Ok(())
}
/// Remove the snapshot image for a project (used on Reset / project removal).
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
docker
.remove_image(
&image_name,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
log::info!("Removed snapshot image {}", image_name);
Ok(())
}
/// Remove both named volumes for a project (used on Reset / project removal).
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
for vol in [
format!("triple-c-home-{}", project.id),
format!("triple-c-claude-config-{}", project.id),
] {
match docker.remove_volume(&vol, None).await {
Ok(_) => log::info!("Removed volume {}", vol),
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
}
}
Ok(())
}
/// Check whether the existing container's configuration still matches the /// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be /// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ). /// recreated (mounts or env vars differ).
@@ -685,9 +790,10 @@ pub async fn container_needs_recreation(
} }
// ── Claude instructions ─────────────────────────────────────────────── // ── Claude instructions ───────────────────────────────────────────────
let expected_instructions = merge_claude_instructions( let expected_instructions = build_claude_instructions(
global_claude_instructions, global_claude_instructions,
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
&project.port_mappings,
); );
let container_instructions = get_env("CLAUDE_INSTRUCTIONS"); let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
if container_instructions.as_deref() != expected_instructions.as_deref() { if container_instructions.as_deref() != expected_instructions.as_deref() {

View File

@@ -70,17 +70,38 @@ impl ProjectsStore {
(Vec::new(), false) (Vec::new(), false)
}; };
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {
match p.status {
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
log::warn!(
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
p.name,
p.id
);
p.status = crate::models::ProjectStatus::Stopped;
p.updated_at = chrono::Utc::now().to_rfc3339();
needs_save = true;
}
_ => {}
}
}
let store = Self { let store = Self {
projects: Mutex::new(projects), projects: Mutex::new(projects),
file_path, file_path,
}; };
// Persist migrated format back to disk // Persist migrated/reconciled format back to disk
if needs_save { if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format"); log::info!("Saving reconciled/migrated projects.json to disk");
let projects = store.lock(); let projects = store.lock();
if let Err(e) = store.save(&projects) { if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e); log::error!("Failed to save projects: {}", e);
} }
} }

View File

@@ -11,7 +11,7 @@ import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState"; import { useAppState } from "./store/appState";
export default function App() { export default function App() {
const { checkDocker, checkImage } = useDocker(); const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings(); const { loadSettings } = useSettings();
const { refresh } = useProjects(); const { refresh } = useProjects();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates(); const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
@@ -22,8 +22,13 @@ export default function App() {
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
let stopPolling: (() => void) | undefined;
checkDocker().then((available) => { checkDocker().then((available) => {
if (available) checkImage(); if (available) {
checkImage();
} else {
stopPolling = startDockerPolling();
}
}); });
refresh(); refresh();
@@ -34,6 +39,7 @@ export default function App() {
return () => { return () => {
clearTimeout(updateTimer); clearTimeout(updateTimer);
cleanup?.(); cleanup?.();
stopPolling?.();
}; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
@@ -23,6 +24,7 @@ export default function ProjectCard({ project }: Props) {
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null);
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error"; const isStopped = project.status === "stopped" || project.status === "error";
@@ -64,6 +66,26 @@ export default function ProjectCard({ project }: Props) {
setBedrockModelId(project.bedrock_config?.model_id ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? "");
}, [project]); }, [project]);
// Listen for container progress events
useEffect(() => {
const unlisten = listen<{ project_id: string; message: string }>(
"container-progress",
(event) => {
if (event.payload.project_id === project.id) {
setProgressMsg(event.payload.message);
}
}
);
return () => { unlisten.then((f) => f()); };
}, [project.id]);
// Clear progress when status settles
useEffect(() => {
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
setProgressMsg(null);
}
}, [project.status]);
const handleStart = async () => { const handleStart = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -315,9 +337,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
</> </>
) : ( ) : (
<span className="text-xs text-[var(--text-secondary)]"> <>
{project.status}... <span className="text-xs text-[var(--text-secondary)]">
</span> {progressMsg ?? `${project.status}...`}
</span>
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
</>
)} )}
<ActionButton <ActionButton
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }} onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
@@ -59,6 +59,39 @@ export function useDocker() {
[setImageExists], [setImageExists],
); );
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startDockerPolling = useCallback(() => {
// Don't start if already polling
if (pollingRef.current) return () => {};
const interval = setInterval(async () => {
try {
const available = await commands.checkDocker();
if (available) {
clearInterval(interval);
pollingRef.current = null;
setDockerAvailable(true);
// Also check image once Docker is available
try {
const exists = await commands.checkImageExists();
setImageExists(exists);
} catch {
setImageExists(false);
}
}
} catch {
// Still not available, keep polling
}
}, 5000);
pollingRef.current = interval;
return () => {
clearInterval(interval);
pollingRef.current = null;
};
}, [setDockerAvailable, setImageExists]);
const pullImage = useCallback( const pullImage = useCallback(
async (imageName: string, onProgress?: (msg: string) => void) => { async (imageName: string, onProgress?: (msg: string) => void) => {
const unlisten = onProgress const unlisten = onProgress
@@ -84,5 +117,6 @@ export function useDocker() {
checkImage, checkImage,
buildImage, buildImage,
pullImage, pullImage,
startDockerPolling,
}; };
} }

View File

@@ -1,5 +1,6 @@
FROM ubuntu:24.04 FROM ubuntu:24.04
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
# Avoid interactive prompts during package install # Avoid interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive