Compare commits
20 Commits
v0.1.56-wi
...
v0.1.76
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ddc705925 | |||
| 1aced2d860 | |||
| 652e451afe | |||
| eb86aa95b7 | |||
| 3228e6cdd7 | |||
| 3344ce1cbf | |||
| d642cc64de | |||
| e3502876eb | |||
| 4f41f0d98b | |||
| c9dc232fc4 | |||
| 2d4fce935f | |||
| e739f6aaff | |||
| 550159fc63 | |||
| e3c874bc75 | |||
| 6cae0e7feb | |||
| b566446b75 | |||
| 601a2db3cf | |||
| b795e27251 | |||
| 19d4cbce27 | |||
| 946ea03956 |
84
.gitea/workflows/backfill-releases.yml
Normal file
84
.gitea/workflows/backfill-releases.yml
Normal 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."
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
60
.gitea/workflows/sync-release.yml
Normal file
60
.gitea/workflows/sync-release.yml
Normal 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."
|
||||||
53
BUILDING.md
53
BUILDING.md
@@ -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
60
TODO.md
Normal 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`
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
operation: "starting" | "stopping" | "resetting";
|
||||||
|
progressMsg: string | null;
|
||||||
|
error: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
onForceStop: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationLabels: Record<string, string> = {
|
||||||
|
starting: "Starting",
|
||||||
|
stopping: "Stopping",
|
||||||
|
resetting: "Resetting",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContainerProgressModal({
|
||||||
|
projectName,
|
||||||
|
operation,
|
||||||
|
progressMsg,
|
||||||
|
error,
|
||||||
|
completed,
|
||||||
|
onForceStop,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-close on success after 800ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (completed && !error) {
|
||||||
|
const timer = setTimeout(onClose, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
// Escape to close (only when completed or error)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && (completed || error)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||||
|
},
|
||||||
|
[completed, error, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inProgress = !completed && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
||||||
|
<h3 className="text-sm font-semibold mb-4">
|
||||||
|
{operationLabels[operation]} “{projectName}”
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Spinner / checkmark / error icon */}
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
{error ? (
|
||||||
|
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||||
|
) : completed ? (
|
||||||
|
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress message */}
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||||
|
{error
|
||||||
|
? <span className="text-[var(--error)]">{error}</span>
|
||||||
|
: completed
|
||||||
|
? "Done!"
|
||||||
|
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{inProgress && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||||
|
>
|
||||||
|
Force Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(completed || error) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -7,6 +8,7 @@ import { useAppState } from "../../store/appState";
|
|||||||
import EnvVarsModal from "./EnvVarsModal";
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
import PortMappingsModal from "./PortMappingsModal";
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -23,6 +25,9 @@ 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 [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
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,9 +69,38 @@ 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]);
|
||||||
|
|
||||||
|
// Mark operation completed when status settles
|
||||||
|
useEffect(() => {
|
||||||
|
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
||||||
|
if (activeOperation) {
|
||||||
|
setOperationCompleted(true);
|
||||||
|
}
|
||||||
|
// Clear progress if no modal is managing it
|
||||||
|
if (!activeOperation) {
|
||||||
|
setProgressMsg(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [project.status, activeOperation]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("starting");
|
||||||
try {
|
try {
|
||||||
await start(project.id);
|
await start(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -79,6 +113,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("stopping");
|
||||||
try {
|
try {
|
||||||
await stop(project.id);
|
await stop(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,6 +133,21 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleForceStop = async () => {
|
||||||
|
try {
|
||||||
|
await stop(project.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveOperation(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const defaultBedrockConfig: BedrockConfig = {
|
const defaultBedrockConfig: BedrockConfig = {
|
||||||
auth_method: "static_credentials",
|
auth_method: "static_credentials",
|
||||||
aws_region: "us-east-1",
|
aws_region: "us-east-1",
|
||||||
@@ -302,6 +354,10 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("resetting");
|
||||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
@@ -315,9 +371,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); }}
|
||||||
@@ -722,6 +781,18 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
onClose={() => setShowClaudeInstructionsModal(false)}
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeOperation && (
|
||||||
|
<ContainerProgressModal
|
||||||
|
projectName={project.name}
|
||||||
|
operation={activeOperation}
|
||||||
|
progressMsg={progressMsg}
|
||||||
|
error={error}
|
||||||
|
completed={operationCompleted}
|
||||||
|
onForceStop={handleForceStop}
|
||||||
|
onClose={closeModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -86,6 +87,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track scroll position to show "Jump to Current" button
|
||||||
|
const scrollDisposable = term.onScroll(() => {
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle image paste: intercept paste events with image data,
|
// Handle image paste: intercept paste events with image data,
|
||||||
// upload to the container, and inject the file path into terminal input.
|
// upload to the container, and inject the file path into terminal input.
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
@@ -164,6 +171,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
detector.dispose();
|
detector.dispose();
|
||||||
detectorRef.current = null;
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
|
scrollDisposable.dispose();
|
||||||
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?.());
|
||||||
@@ -231,6 +239,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [detectedUrl]);
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
termRef.current?.scrollToBottom();
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={terminalContainerRef}
|
ref={terminalContainerRef}
|
||||||
@@ -251,6 +264,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Jump to Current ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,31 +50,45 @@ export function useProjects() {
|
|||||||
[removeProjectFromList],
|
[removeProjectFromList],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setOptimisticStatus = useCallback(
|
||||||
|
(id: string, status: "starting" | "stopping") => {
|
||||||
|
const { projects } = useAppState.getState();
|
||||||
|
const project = projects.find((p) => p.id === id);
|
||||||
|
if (project) {
|
||||||
|
updateProjectInList({ ...project, status });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateProjectInList],
|
||||||
|
);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.startProjectContainer(id);
|
const updated = await commands.startProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(
|
const stop = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "stopping");
|
||||||
await commands.stopProjectContainer(id);
|
await commands.stopProjectContainer(id);
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
},
|
},
|
||||||
[setProjects],
|
[setProjects, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rebuild = useCallback(
|
const rebuild = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.rebuildProjectContainer(id);
|
const updated = await commands.rebuildProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user