Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb86aa95b7 | |||
| 3228e6cdd7 | |||
| 3344ce1cbf | |||
| d642cc64de | |||
| e3502876eb | |||
| 4f41f0d98b | |||
| c9dc232fc4 | |||
| 2d4fce935f | |||
| e739f6aaff | |||
| 550159fc63 | |||
| e3c874bc75 | |||
| 6cae0e7feb | |||
| b566446b75 | |||
| 601a2db3cf | |||
| b795e27251 | |||
| 19d4cbce27 | |||
| 946ea03956 | |||
| ba4cb4176d | |||
| 4b56610ff5 | |||
| db51abb970 | |||
| d947824436 | |||
| c2b21b794c | |||
| 40493ae284 | |||
| 2e81b52205 |
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: |
|
||||||
@@ -119,6 +151,116 @@ jobs:
|
|||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
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
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
VERSION="0.1.${COMMIT_COUNT}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${VERSION}"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
echo "Rust already installed: $(rustc --version)"
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Install Tauri CLI
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app (universal)
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.version.outputs.VERSION }}-mac"
|
||||||
|
# Create release
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
|
# Upload each artifact
|
||||||
|
for file in artifacts/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
115
CLAUDE.md
Normal file
115
CLAUDE.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Triple-C (Claude-Code-Container) is a Tauri v2 desktop application that sandboxes Claude Code inside Docker containers. It has two main parts: a React/TypeScript frontend, a Rust backend, and a Docker container image definition.
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
All frontend/tauri commands run from the `app/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm ci # Install dependencies (required first time)
|
||||||
|
npx tauri dev # Launch app in dev mode with hot reload (Vite on port 1420)
|
||||||
|
npx tauri build # Production build (outputs to src-tauri/target/release/bundle/)
|
||||||
|
npm run build # Frontend-only build (tsc + vite)
|
||||||
|
npm run test # Run Vitest once
|
||||||
|
npm run test:watch # Run Vitest in watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust backend is compiled automatically by `tauri dev`/`tauri build`. To check Rust independently:
|
||||||
|
```bash
|
||||||
|
cd app/src-tauri
|
||||||
|
cargo check # Type-check without full build
|
||||||
|
cargo build # Build Rust backend only
|
||||||
|
```
|
||||||
|
|
||||||
|
Container image:
|
||||||
|
```bash
|
||||||
|
docker build -t triple-c-sandbox ./container
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux Build Dependencies (Ubuntu/Debian)
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf libssl-dev pkg-config build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two-Process Model (Tauri IPC)
|
||||||
|
|
||||||
|
- **React frontend** (`app/src/`) renders UI in the OS webview
|
||||||
|
- **Rust backend** (`app/src-tauri/src/`) handles Docker API, credential storage, and terminal I/O
|
||||||
|
- Communication uses two patterns:
|
||||||
|
- `invoke()` — request/response for discrete operations (CRUD, start/stop containers)
|
||||||
|
- `emit()`/`listen()` — event streaming for continuous data (terminal I/O)
|
||||||
|
|
||||||
|
### Terminal I/O Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User keystroke → xterm.js onData() → invoke("terminal_input") → mpsc channel → docker exec stdin
|
||||||
|
docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → listen() → xterm.js write()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Structure (`app/src/`)
|
||||||
|
|
||||||
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
|
||||||
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||||
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||||
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
|
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
||||||
|
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
||||||
|
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
|
||||||
|
|
||||||
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
|
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
|
||||||
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
|
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||||
|
- `image.rs` — Image build/pull with progress streaming
|
||||||
|
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||||
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
|
### Container (`container/`)
|
||||||
|
|
||||||
|
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
|
||||||
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity`
|
||||||
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Per-project, independently configured:
|
||||||
|
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||||
|
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`). No separate tailwind config file.
|
||||||
|
- All colors use CSS custom properties in `index.css` `:root` (e.g., `--bg-primary`, `--text-secondary`, `--accent`)
|
||||||
|
- `color-scheme: dark` is set on `:root` for native dark-mode controls
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
||||||
|
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
||||||
|
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
|
||||||
|
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
||||||
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npx vitest run src/path/to/test.test.ts
|
||||||
|
```
|
||||||
397
HOW-TO-USE.md
Normal file
397
HOW-TO-USE.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# How to Use Triple-C
|
||||||
|
|
||||||
|
Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code inside isolated Docker containers. Each project gets its own sandboxed environment with bind-mounted directories, so Claude only has access to the files you explicitly provide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Triple-C requires a running Docker daemon. Install one of the following:
|
||||||
|
|
||||||
|
| Platform | Option | Link |
|
||||||
|
|----------|--------|------|
|
||||||
|
| **Windows** | Docker Desktop | https://docs.docker.com/desktop/install/windows-install/ |
|
||||||
|
| **macOS** | Docker Desktop | https://docs.docker.com/desktop/install/mac-install/ |
|
||||||
|
| **Linux** | Docker Engine | https://docs.docker.com/engine/install/ |
|
||||||
|
| **Linux** | Docker Desktop (alternative) | https://docs.docker.com/desktop/install/linux/ |
|
||||||
|
|
||||||
|
After installation, verify Docker is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Windows note:** Docker Desktop must be running before launching Triple-C. The app communicates with Docker through the named pipe at `//./pipe/docker_engine`.
|
||||||
|
|
||||||
|
> **Linux note:** Your user must have permission to access the Docker socket (`/var/run/docker.sock`). Either add your user to the `docker` group (`sudo usermod -aG docker $USER`, then log out and back in) or run Docker in rootless mode.
|
||||||
|
|
||||||
|
### Claude Code Account
|
||||||
|
|
||||||
|
You need access to Claude Code through one of:
|
||||||
|
|
||||||
|
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||||
|
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Launch
|
||||||
|
|
||||||
|
### 1. Get the Container Image
|
||||||
|
|
||||||
|
When you first open Triple-C, go to the **Settings** tab in the sidebar. Under **Docker**, you'll see:
|
||||||
|
|
||||||
|
- **Docker Status** — Should show "Connected" (green). If it shows "Not Available", make sure Docker is running.
|
||||||
|
- **Image Status** — Will show "Not Found" on first launch.
|
||||||
|
|
||||||
|
Choose an **Image Source**:
|
||||||
|
|
||||||
|
| Source | Description | When to Use |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users |
|
||||||
|
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
|
||||||
|
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
|
||||||
|
|
||||||
|
Click **Pull Image** (for Registry/Custom) or **Build Image** (for Local Build). A progress log will stream below the button. When complete, the status changes to "Ready" (green).
|
||||||
|
|
||||||
|
### 2. Create Your First Project
|
||||||
|
|
||||||
|
Switch to the **Projects** tab in the sidebar and click the **+** button.
|
||||||
|
|
||||||
|
1. **Project Name** — Give it a meaningful name (e.g., "my-web-app").
|
||||||
|
2. **Folders** — Click **Browse** to select a directory on your host machine. This directory will be mounted into the container at `/workspace/<folder-name>`. You can add multiple folders with the **+** button at the bottom of the folder list.
|
||||||
|
3. Click **Add Project**.
|
||||||
|
|
||||||
|
### 3. Start the Container
|
||||||
|
|
||||||
|
Select your project in the sidebar and click **Start**. The status dot changes from gray (stopped) to orange (starting) to green (running).
|
||||||
|
|
||||||
|
### 4. Open a Terminal
|
||||||
|
|
||||||
|
Click the **Terminal** button (highlighted in accent color) to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
||||||
|
|
||||||
|
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
|
||||||
|
|
||||||
|
### 5. Authenticate
|
||||||
|
|
||||||
|
**Anthropic (OAuth) — default:**
|
||||||
|
|
||||||
|
1. Type `claude login` or `/login` in the terminal.
|
||||||
|
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||||
|
3. Complete the login in your browser. The token is saved and persists across container stops and resets.
|
||||||
|
|
||||||
|
**AWS Bedrock:**
|
||||||
|
|
||||||
|
1. Stop the container first (settings can only be changed while stopped).
|
||||||
|
2. In the project card, switch the auth mode to **Bedrock**.
|
||||||
|
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
|
||||||
|
4. Start the container again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Interface
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar [ Terminal Tabs ] Docker ● Image ●│
|
||||||
|
├────────────┬────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ │
|
||||||
|
│ │ Terminal View │
|
||||||
|
│ Projects │ (xterm.js) │
|
||||||
|
│ Settings │ │
|
||||||
|
│ │ │
|
||||||
|
├────────────┴────────────────────────────────────────┤
|
||||||
|
│ StatusBar X projects · X running · X terminals │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **TopBar** — Terminal tabs for switching between sessions. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
||||||
|
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
|
||||||
|
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
|
||||||
|
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Management
|
||||||
|
|
||||||
|
### Project Status
|
||||||
|
|
||||||
|
Each project shows a colored status dot:
|
||||||
|
|
||||||
|
| Color | Status | Meaning |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Gray | Stopped | Container is not running |
|
||||||
|
| Orange | Starting / Stopping | Container is transitioning |
|
||||||
|
| Green | Running | Container is active, ready for terminals |
|
||||||
|
| Red | Error | Something went wrong (check error message) |
|
||||||
|
|
||||||
|
### Project Actions
|
||||||
|
|
||||||
|
Select a project in the sidebar to see its action buttons:
|
||||||
|
|
||||||
|
| Button | When Available | What It Does |
|
||||||
|
|--------|---------------|--------------|
|
||||||
|
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||||
|
| **Stop** | Running | Stops the container but preserves its state |
|
||||||
|
| **Terminal** | Running | Opens a new terminal session in this container |
|
||||||
|
| **Reset** | Stopped | Destroys and recreates the container from scratch |
|
||||||
|
| **Config** | Always | Toggles the configuration panel |
|
||||||
|
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
|
||||||
|
|
||||||
|
**Reset** removes the container and creates a fresh one. However, your Claude Code configuration (including OAuth tokens from `claude login`) is stored in a separate Docker volume and survives resets.
|
||||||
|
|
||||||
|
Only **Remove** deletes everything, including the config volume and any stored credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Configuration
|
||||||
|
|
||||||
|
Click **Config** on a selected project to expand the configuration panel. Settings can only be changed when the container is **stopped** (an orange warning box appears if the container is running).
|
||||||
|
|
||||||
|
### Mounted Folders
|
||||||
|
|
||||||
|
Each project mounts one or more host directories into the container. The mount appears at `/workspace/<mount-name>` inside the container.
|
||||||
|
|
||||||
|
- Click **Browse** ("...") to change the host path
|
||||||
|
- Edit the mount name to control where it appears inside `/workspace/`
|
||||||
|
- Click **+** to add more folders, or **x** to remove one
|
||||||
|
- Mount names must be unique and use only letters, numbers, dashes, underscores, and dots
|
||||||
|
|
||||||
|
### SSH Keys
|
||||||
|
|
||||||
|
Specify the path to your SSH key directory (typically `~/.ssh`). Keys are mounted read-only and copied into the container with correct permissions. This enables `git clone` via SSH inside the container.
|
||||||
|
|
||||||
|
### Git Configuration
|
||||||
|
|
||||||
|
- **Git Name / Email** — Sets `git config user.name` and `user.email` inside the container.
|
||||||
|
- **Git HTTPS Token** — A personal access token (e.g., from GitHub) for HTTPS git operations. Stored securely in your OS keychain — never written to disk in plaintext.
|
||||||
|
|
||||||
|
### Allow Container Spawning
|
||||||
|
|
||||||
|
When enabled, the host Docker socket is mounted into the container so Claude Code can create sibling containers (e.g., for running databases, test environments). This is **off by default** for security.
|
||||||
|
|
||||||
|
> Toggling this requires stopping and restarting the container to take effect.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
|
||||||
|
|
||||||
|
> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `CLAUDE_`, `TRIPLE_C_`) are filtered out to prevent conflicts with internal variables.
|
||||||
|
|
||||||
|
### Port Mappings
|
||||||
|
|
||||||
|
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
|
||||||
|
|
||||||
|
Each mapping specifies:
|
||||||
|
- **Host Port** — The port on your machine (1–65535)
|
||||||
|
- **Container Port** — The port inside the container (1–65535)
|
||||||
|
- **Protocol** — TCP (default) or UDP
|
||||||
|
|
||||||
|
### Claude Instructions
|
||||||
|
|
||||||
|
Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AWS Bedrock Configuration
|
||||||
|
|
||||||
|
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
|
||||||
|
|
||||||
|
### Authentication Methods
|
||||||
|
|
||||||
|
| Method | Fields | Use Case |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| **Keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup |
|
||||||
|
| **Profile** | AWS Profile name | Uses `~/.aws/config` and `~/.aws/credentials` on the host |
|
||||||
|
| **Token** | Bearer Token | Temporary bearer token authentication |
|
||||||
|
|
||||||
|
### Additional Bedrock Settings
|
||||||
|
|
||||||
|
- **AWS Region** — Required. The region where your Bedrock models are deployed (e.g., `us-east-1`).
|
||||||
|
- **Model ID** — Optional. Override the default Claude model (e.g., `anthropic.claude-sonnet-4-20250514-v1:0`).
|
||||||
|
|
||||||
|
### Global AWS Defaults
|
||||||
|
|
||||||
|
In **Settings > AWS Configuration**, you can set defaults that apply to all Bedrock projects:
|
||||||
|
|
||||||
|
- **AWS Config Path** — Path to your `~/.aws` directory. Click **Detect** to auto-find it.
|
||||||
|
- **Default Profile** — Select from profiles found in your AWS config.
|
||||||
|
- **Default Region** — Fallback region for projects that don't specify one.
|
||||||
|
|
||||||
|
Per-project settings always override these global defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Access global settings via the **Settings** tab in the sidebar.
|
||||||
|
|
||||||
|
### Docker Settings
|
||||||
|
|
||||||
|
- **Docker Status** — Connection status to the Docker daemon.
|
||||||
|
- **Image Source** — Where to get the sandbox container image (Registry, Local Build, or Custom).
|
||||||
|
- **Pull / Build Image** — Download or build the image. Progress streams in real time.
|
||||||
|
- **Refresh** — Re-check Docker and image status.
|
||||||
|
|
||||||
|
### Container Timezone
|
||||||
|
|
||||||
|
Set the timezone for all containers (IANA format, e.g., `America/New_York`, `Europe/London`, `UTC`). Auto-detected from your host on first launch. This affects scheduled task timing inside containers.
|
||||||
|
|
||||||
|
### Global Claude Instructions
|
||||||
|
|
||||||
|
Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in every container, before any per-project instructions.
|
||||||
|
|
||||||
|
### Global Environment Variables
|
||||||
|
|
||||||
|
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
- **Current Version** — The installed version of Triple-C.
|
||||||
|
- **Auto-check** — Toggle automatic update checks (every 24 hours).
|
||||||
|
- **Check now** — Manually check for updates.
|
||||||
|
|
||||||
|
When an update is available, a pulsing **Update** button appears in the top bar. Click it to see release notes and download links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal Features
|
||||||
|
|
||||||
|
### Multiple Sessions
|
||||||
|
|
||||||
|
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it.
|
||||||
|
|
||||||
|
### URL Detection
|
||||||
|
|
||||||
|
When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detects it and shows a toast notification at the top of the terminal with an **Open** button. Clicking it opens the URL in your default browser. The toast auto-dismisses after 30 seconds.
|
||||||
|
|
||||||
|
Shorter URLs in terminal output are also clickable directly.
|
||||||
|
|
||||||
|
### Image Paste
|
||||||
|
|
||||||
|
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container and the file path is injected into the terminal input so Claude Code can reference it.
|
||||||
|
|
||||||
|
### Terminal Rendering
|
||||||
|
|
||||||
|
The terminal uses WebGL for hardware-accelerated rendering of the active tab. Inactive tabs fall back to canvas rendering to conserve GPU resources. The terminal automatically resizes when you resize the window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduled Tasks (Inside the Container)
|
||||||
|
|
||||||
|
Once inside a running container terminal, you can set up recurring or one-time tasks using `triple-c-scheduler`. Tasks run as separate Claude Code sessions.
|
||||||
|
|
||||||
|
### Create a Recurring Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "daily-review" --schedule "0 9 * * *" --prompt "Review open issues and summarize"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a One-Time Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "migrate-db" --at "2026-03-05 14:00" --prompt "Run database migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
One-time tasks automatically remove themselves after execution.
|
||||||
|
|
||||||
|
### Manage Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler list # List all tasks
|
||||||
|
triple-c-scheduler enable --id abc123 # Enable a task
|
||||||
|
triple-c-scheduler disable --id abc123 # Disable a task
|
||||||
|
triple-c-scheduler remove --id abc123 # Delete a task
|
||||||
|
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
||||||
|
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||||
|
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||||
|
triple-c-scheduler notifications # View completion notifications
|
||||||
|
triple-c-scheduler notifications --clear # Clear notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Schedule Format
|
||||||
|
|
||||||
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
|
|
||||||
|
| Example | Meaning |
|
||||||
|
|---------|---------|
|
||||||
|
| `*/30 * * * *` | Every 30 minutes |
|
||||||
|
| `0 9 * * 1-5` | 9:00 AM on weekdays |
|
||||||
|
| `0 */2 * * *` | Every 2 hours |
|
||||||
|
| `0 0 1 * *` | Midnight on the 1st of each month |
|
||||||
|
|
||||||
|
### Working Directory
|
||||||
|
|
||||||
|
By default, tasks run in `/workspace`. Use `--working-dir` to specify a different directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run tests" --working-dir /workspace/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Inside the Container
|
||||||
|
|
||||||
|
The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Claude Code | Latest | AI coding assistant (the tool being sandboxed) |
|
||||||
|
| Node.js | 22 LTS | JavaScript/TypeScript development |
|
||||||
|
| pnpm | Latest | Fast Node.js package manager |
|
||||||
|
| Python | 3.12 | Python development |
|
||||||
|
| uv | Latest | Fast Python package manager |
|
||||||
|
| ruff | Latest | Python linter/formatter |
|
||||||
|
| Rust | Stable | Rust development (via rustup) |
|
||||||
|
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||||
|
| git | Latest | Version control |
|
||||||
|
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||||
|
| AWS CLI | v2 | AWS services and Bedrock |
|
||||||
|
| ripgrep | Latest | Fast code search |
|
||||||
|
| build-essential | — | C/C++ compiler toolchain |
|
||||||
|
| openssh-client | — | SSH for git and remote access |
|
||||||
|
|
||||||
|
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Docker is "Not Available"
|
||||||
|
|
||||||
|
- **Is Docker running?** Start Docker Desktop or the Docker daemon (`sudo systemctl start docker`).
|
||||||
|
- **Permissions?** On Linux, ensure your user is in the `docker` group or the socket is accessible.
|
||||||
|
- **Custom socket path?** If your Docker socket is not at the default location, set it in Settings. The app expects `/var/run/docker.sock` on Linux/macOS or `//./pipe/docker_engine` on Windows.
|
||||||
|
|
||||||
|
### Image is "Not Found"
|
||||||
|
|
||||||
|
- Click **Pull Image** or **Build Image** in Settings > Docker.
|
||||||
|
- If pulling fails, check your network connection and whether you can reach the registry.
|
||||||
|
- Try switching to **Local Build** as an alternative.
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
- Check that the Docker image is "Ready" in Settings.
|
||||||
|
- Verify that the mounted folder paths exist on your host.
|
||||||
|
- Look at the error message displayed in red below the project card.
|
||||||
|
|
||||||
|
### OAuth Login URL Not Opening
|
||||||
|
|
||||||
|
- Triple-C detects long URLs printed by `claude login` and shows a toast with an **Open** button.
|
||||||
|
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
||||||
|
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
||||||
|
|
||||||
|
### File Permission Issues
|
||||||
|
|
||||||
|
- Triple-C automatically remaps the container user's UID/GID to match your host user, so files created inside the container should have the correct ownership on your host.
|
||||||
|
- If you see permission errors, try resetting the container (stop, then click **Reset**).
|
||||||
|
|
||||||
|
### Settings Won't Save
|
||||||
|
|
||||||
|
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
|
||||||
|
- Some changes (like toggling Docker access or changing mounted folders) trigger an automatic container recreation on the next start.
|
||||||
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
app/src-tauri/Cargo.lock
generated
1
app/src-tauri/Cargo.lock
generated
@@ -4675,6 +4675,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"fern",
|
"fern",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"iana-time-zone",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ log = "0.4"
|
|||||||
fern = { version = "0.7", features = ["date-based"] }
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
iana-time-zone = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -81,12 +81,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
|
||||||
@@ -153,28 +160,42 @@ 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.global_custom_env_vars,
|
||||||
.await
|
settings.timezone.as_deref(),
|
||||||
.unwrap_or(false);
|
).await.unwrap_or(false);
|
||||||
if needs_recreation {
|
|
||||||
log::info!("Container config changed, recreating container for project {}", project.id);
|
if needs_recreate {
|
||||||
|
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||||
|
// Snapshot the filesystem before destroying
|
||||||
|
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||||
|
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||||
|
}
|
||||||
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(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
@@ -183,14 +204,26 @@ pub async fn start_project_container(
|
|||||||
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()
|
||||||
|
};
|
||||||
|
|
||||||
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(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
@@ -257,6 +290,14 @@ 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, state).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,33 @@ pub async fn pull_image(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||||
|
// Try the iana-time-zone crate first (cross-platform)
|
||||||
|
match iana_time_zone::get_timezone() {
|
||||||
|
Ok(tz) => return Ok(tz),
|
||||||
|
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check TZ env var
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read /etc/timezone (Linux)
|
||||||
|
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
||||||
|
let tz = tz.trim().to_string();
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to UTC if detection fails
|
||||||
|
Ok("UTC".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
|||||||
@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
|
|||||||
state.exec_manager.close_session(&session_id).await;
|
state.exec_manager.close_session(&session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn paste_image_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
image_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let file_name = format!("clipboard_{}.png", timestamp);
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,6 +11,72 @@ use std::hash::{Hash, Hasher};
|
|||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
|
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
|
||||||
|
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
|
||||||
|
- `triple-c-scheduler list` — List all scheduled tasks
|
||||||
|
- `triple-c-scheduler remove --id ID` — Remove a task
|
||||||
|
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
|
||||||
|
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
|
||||||
|
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
|
||||||
|
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
|
||||||
|
|
||||||
|
### Cron format
|
||||||
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
|
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
|
||||||
|
|
||||||
|
### One-time tasks
|
||||||
|
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
|
||||||
|
|
||||||
|
### Checking results
|
||||||
|
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
|
||||||
|
|
||||||
|
### Timezone
|
||||||
|
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 {
|
||||||
@@ -147,6 +214,7 @@ pub async fn create_container(
|
|||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
global_custom_env_vars: &[EnvVar],
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -269,27 +337,20 @@ pub async fn create_container(
|
|||||||
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||||
|
|
||||||
// Claude instructions (global + per-project, plus port mapping info)
|
// Container timezone
|
||||||
let mut combined_instructions = merge_claude_instructions(
|
if let Some(tz) = timezone {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
env_vars.push(format!("TZ={}", tz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||||
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -307,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)),
|
||||||
@@ -400,10 +473,12 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||||
|
init: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -476,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).
|
||||||
@@ -484,6 +636,7 @@ pub async fn container_needs_recreation(
|
|||||||
project: &Project,
|
project: &Project,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
global_custom_env_vars: &[EnvVar],
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
@@ -571,6 +724,14 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timezone ─────────────────────────────────────────────────────────
|
||||||
|
let expected_tz = timezone.unwrap_or("");
|
||||||
|
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
|
||||||
|
if container_tz != expected_tz {
|
||||||
|
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── SSH key path mount ───────────────────────────────────────────────
|
// ── SSH key path mount ───────────────────────────────────────────────
|
||||||
let ssh_mount_source = mounts
|
let ssh_mount_source = mounts
|
||||||
.and_then(|m| {
|
.and_then(|m| {
|
||||||
@@ -629,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() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use bollard::container::UploadToContainerOptions;
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -212,4 +213,51 @@ impl ExecSessionManager {
|
|||||||
session.shutdown();
|
session.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
Ok(session.container_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_file_to_container(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Build a tar archive in memory containing the file
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: "/tmp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("/tmp/{}", file_name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::models::container_config;
|
|||||||
|
|
||||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||||
|
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||||
|
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||||
|
|
||||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
header.set_cksum();
|
header.set_cksum();
|
||||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||||
|
|
||||||
|
let scheduler_bytes = SCHEDULER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(scheduler_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
|
||||||
|
|
||||||
|
let task_runner_bytes = TASK_RUNNER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(task_runner_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
|
||||||
|
|
||||||
archive.finish()?;
|
archive.finish()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ pub fn run() {
|
|||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
commands::settings_commands::detect_aws_config,
|
commands::settings_commands::detect_aws_config,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
|
commands::settings_commands::detect_host_timezone,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
// Updates
|
// Updates
|
||||||
commands::update_commands::get_app_version,
|
commands::update_commands::get_app_version,
|
||||||
commands::update_commands::check_for_updates,
|
commands::update_commands::check_for_updates,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub struct AppSettings {
|
|||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dismissed_update_version: Option<String>,
|
pub dismissed_update_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -84,6 +86,7 @@ impl Default for AppSettings {
|
|||||||
global_custom_env_vars: Vec::new(),
|
global_custom_env_vars: Vec::new(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
|
timezone: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSettings } from "../../hooks/useSettings";
|
|||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
@@ -14,6 +15,7 @@ export default function SettingsPanel() {
|
|||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
||||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
|
||||||
@@ -21,7 +23,18 @@ export default function SettingsPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||||
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||||
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
|
setTimezone(appSettings?.timezone ?? "");
|
||||||
|
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
|
||||||
|
|
||||||
|
// Auto-detect timezone on first load if not yet set
|
||||||
|
useEffect(() => {
|
||||||
|
if (appSettings && !appSettings.timezone) {
|
||||||
|
detectHostTimezone().then((tz) => {
|
||||||
|
setTimezone(tz);
|
||||||
|
saveSettings({ ...appSettings, timezone: tz });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [appSettings?.timezone]);
|
||||||
|
|
||||||
const handleCheckNow = async () => {
|
const handleCheckNow = async () => {
|
||||||
setCheckingUpdates(true);
|
setCheckingUpdates(true);
|
||||||
@@ -46,6 +59,26 @@ export default function SettingsPanel() {
|
|||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
|
{/* Container Timezone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, timezone: timezone || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="UTC"
|
||||||
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Global Claude Instructions */}
|
{/* Global Claude Instructions */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
|
import UrlToast from "./UrlToast";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -14,10 +16,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -79,12 +86,50 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle image paste: intercept paste events with image data,
|
||||||
|
// upload to the container, and inject the file path into terminal input.
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const blob = item.getAsFile();
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
blob.arrayBuffer().then(async (buf) => {
|
||||||
|
try {
|
||||||
|
setImagePasteMsg("Uploading image...");
|
||||||
|
const data = new Uint8Array(buf);
|
||||||
|
const filePath = await pasteImage(sessionId, data);
|
||||||
|
// Inject the file path into terminal stdin
|
||||||
|
sendInput(sessionId, filePath);
|
||||||
|
setImagePasteMsg(`Image saved to ${filePath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Image paste failed:", err);
|
||||||
|
setImagePasteMsg("Image paste failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // Only handle the first image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||||
|
detectorRef.current = detector;
|
||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
detector.feed(data);
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
if (aborted) unlisten();
|
if (aborted) unlisten();
|
||||||
return unlisten;
|
return unlisten;
|
||||||
@@ -116,7 +161,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
|
detector.dispose();
|
||||||
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
@@ -160,11 +208,54 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
// Auto-dismiss toast after 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detectedUrl) return;
|
||||||
|
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
// Auto-dismiss image paste message after 3 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imagePasteMsg) return;
|
||||||
|
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [imagePasteMsg]);
|
||||||
|
|
||||||
|
const handleOpenUrl = useCallback(() => {
|
||||||
|
if (detectedUrl) {
|
||||||
|
openUrl(detectedUrl).catch((e) =>
|
||||||
|
console.error("Failed to open URL:", e),
|
||||||
|
);
|
||||||
|
setDetectedUrl(null);
|
||||||
|
}
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={terminalContainerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||||
style={{ padding: "8px" }}
|
>
|
||||||
/>
|
{detectedUrl && (
|
||||||
|
<UrlToast
|
||||||
|
url={detectedUrl}
|
||||||
|
onOpen={handleOpenUrl}
|
||||||
|
onDismiss={() => setDetectedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imagePasteMsg && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
|
||||||
|
onClick={() => setImagePasteMsg(null)}
|
||||||
|
>
|
||||||
|
{imagePasteMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ padding: "8px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
101
app/src/components/terminal/UrlToast.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-slide-down"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 40,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
maxWidth: "min(90%, 600px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Long URL detected
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent)")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-primary)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||||
|
}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export function useTerminal() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pasteImage = useCallback(
|
||||||
|
async (sessionId: string, imageData: Uint8Array) => {
|
||||||
|
const bytes = Array.from(imageData);
|
||||||
|
return commands.pasteImageToTerminal(sessionId, bytes);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onOutput = useCallback(
|
const onOutput = useCallback(
|
||||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||||
const eventName = `terminal-output-${sessionId}`;
|
const eventName = `terminal-output-${sessionId}`;
|
||||||
@@ -76,6 +84,7 @@ export function useTerminal() {
|
|||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
sendInput,
|
sendInput,
|
||||||
|
pasteImage,
|
||||||
resize,
|
resize,
|
||||||
onOutput,
|
onOutput,
|
||||||
onExit,
|
onExit,
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast slide-down animation */
|
||||||
|
@keyframes slide-down {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const detectAwsConfig = () =>
|
|||||||
invoke<string | null>("detect_aws_config");
|
invoke<string | null>("detect_aws_config");
|
||||||
export const listAwsProfiles = () =>
|
export const listAwsProfiles = () =>
|
||||||
invoke<string[]>("list_aws_profiles");
|
invoke<string[]>("list_aws_profiles");
|
||||||
|
export const detectHostTimezone = () =>
|
||||||
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||||
@@ -45,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|||||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||||
export const closeTerminalSession = (sessionId: string) =>
|
export const closeTerminalSession = (sessionId: string) =>
|
||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||||
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface AppSettings {
|
|||||||
global_custom_env_vars: EnvVar[];
|
global_custom_env_vars: EnvVar[];
|
||||||
auto_check_updates: boolean;
|
auto_check_updates: boolean;
|
||||||
dismissed_update_version: string | null;
|
dismissed_update_version: string | null;
|
||||||
|
timezone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
|
|||||||
127
app/src/lib/urlDetector.ts
Normal file
127
app/src/lib/urlDetector.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
||||||
|
*
|
||||||
|
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
||||||
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||||
|
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||||
|
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||||
|
*
|
||||||
|
* When a URL match extends to the end of the flattened buffer, emission is
|
||||||
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||||
|
* the pending URL if no further data arrives within 500 ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANSI_RE =
|
||||||
|
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||||
|
|
||||||
|
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
||||||
|
const MIN_URL_LENGTH = 100;
|
||||||
|
|
||||||
|
export type UrlCallback = (url: string) => void;
|
||||||
|
|
||||||
|
export class UrlDetector {
|
||||||
|
private decoder = new TextDecoder();
|
||||||
|
private buffer = "";
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastEmitted = "";
|
||||||
|
private pendingUrl: string | null = null;
|
||||||
|
private callback: UrlCallback;
|
||||||
|
|
||||||
|
constructor(callback: UrlCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed raw PTY output chunks. */
|
||||||
|
feed(data: Uint8Array): void {
|
||||||
|
this.buffer += this.decoder.decode(data, { stream: true });
|
||||||
|
|
||||||
|
// Cap buffer to avoid unbounded growth
|
||||||
|
if (this.buffer.length > MAX_BUFFER) {
|
||||||
|
this.buffer = this.buffer.slice(-MAX_BUFFER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel pending timers — new data arrived, rescan from scratch
|
||||||
|
if (this.timer !== null) clearTimeout(this.timer);
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce — scan after 300 ms of silence
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.scan();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scan(): void {
|
||||||
|
// 1. Strip ANSI escape sequences
|
||||||
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
|
|
||||||
|
// 2. Flatten the buffer:
|
||||||
|
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||||
|
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||||
|
const flat = clean
|
||||||
|
.replace(/(\r?\n){2,}/g, " ")
|
||||||
|
.replace(/[\r\n]/g, "");
|
||||||
|
|
||||||
|
if (!flat) return;
|
||||||
|
|
||||||
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||||
|
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = urlRe.exec(flat)) !== null) {
|
||||||
|
const url = m[0];
|
||||||
|
|
||||||
|
// 4. Filter by length
|
||||||
|
if (url.length < MIN_URL_LENGTH) continue;
|
||||||
|
|
||||||
|
// 5. If the match extends to the very end of the flattened string,
|
||||||
|
// more chunks may still be arriving — defer emission.
|
||||||
|
if (m.index + url.length >= flat.length) {
|
||||||
|
this.pendingUrl = url;
|
||||||
|
this.confirmTimer = setTimeout(() => {
|
||||||
|
this.confirmTimer = null;
|
||||||
|
this.emitPending();
|
||||||
|
}, CONFIRM_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
|
this.pendingUrl = null;
|
||||||
|
if (url !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = url;
|
||||||
|
this.callback(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan finished without a URL at the buffer end.
|
||||||
|
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
||||||
|
if (this.pendingUrl) {
|
||||||
|
this.emitPending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitPending(): void {
|
||||||
|
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = this.pendingUrl;
|
||||||
|
this.callback(this.pendingUrl);
|
||||||
|
}
|
||||||
|
this.pendingUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
unzip \
|
unzip \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
cron \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||||
@@ -101,5 +103,9 @@ WORKDIR /workspace
|
|||||||
USER root
|
USER root
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-scheduler
|
||||||
|
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-task-runner
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|||||||
@@ -113,6 +113,59 @@ if [ -S /var/run/docker.sock ]; then
|
|||||||
usermod -aG "$DOCKER_GROUP" claude
|
usermod -aG "$DOCKER_GROUP" claude
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Timezone setup ───────────────────────────────────────────────────────────
|
||||||
|
if [ -n "${TZ:-}" ]; then
|
||||||
|
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||||
|
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
echo "entrypoint: timezone set to $TZ"
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Scheduler setup ─────────────────────────────────────────────────────────
|
||||||
|
SCHEDULER_DIR="/home/claude/.claude/scheduler"
|
||||||
|
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
|
||||||
|
chown -R claude:claude "$SCHEDULER_DIR"
|
||||||
|
|
||||||
|
# Start cron daemon (runs as root, executes jobs per user crontab)
|
||||||
|
cron
|
||||||
|
|
||||||
|
# Save environment variables for cron jobs (cron runs with a minimal env)
|
||||||
|
ENV_FILE="$SCHEDULER_DIR/.env"
|
||||||
|
: > "$ENV_FILE"
|
||||||
|
env | while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
|
||||||
|
# Escape single quotes in value and write as KEY='VALUE'
|
||||||
|
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||||
|
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
chown claude:claude "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# Restore crontab from persisted task JSON files (survives container recreation)
|
||||||
|
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
|
||||||
|
CRON_TMP=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
|
||||||
|
echo "" >> "$CRON_TMP"
|
||||||
|
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
|
||||||
|
done
|
||||||
|
crontab -u claude "$CRON_TMP" 2>/dev/null || true
|
||||||
|
rm -f "$CRON_TMP"
|
||||||
|
echo "entrypoint: restored crontab from persisted tasks"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
||||||
echo "Triple-C container ready."
|
echo "Triple-C container ready."
|
||||||
exec su -s /bin/bash claude -c "exec sleep infinity"
|
exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||||
|
|||||||
436
container/triple-c-scheduler
Normal file
436
container/triple-c-scheduler
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
|
||||||
|
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_id() {
|
||||||
|
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_crontab() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
# Header
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
|
||||||
|
echo "" >> "$tmp"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
local enabled schedule id
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
|
||||||
|
done
|
||||||
|
|
||||||
|
crontab "$tmp" 2>/dev/null || true
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: triple-c-scheduler <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add Add a new scheduled task
|
||||||
|
remove Remove a task
|
||||||
|
enable Enable a disabled task
|
||||||
|
disable Disable a task
|
||||||
|
list List all tasks
|
||||||
|
logs Show execution logs
|
||||||
|
run Manually trigger a task now
|
||||||
|
notifications Show or clear completion notifications
|
||||||
|
|
||||||
|
Add options:
|
||||||
|
--name NAME Task name (required)
|
||||||
|
--prompt "TASK" Task prompt for Claude (required)
|
||||||
|
--schedule "CRON" Cron schedule expression (for recurring tasks)
|
||||||
|
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
|
||||||
|
--working-dir DIR Working directory (default: /workspace)
|
||||||
|
|
||||||
|
Remove/Enable/Disable/Run options:
|
||||||
|
--id ID Task ID (required)
|
||||||
|
|
||||||
|
Logs options:
|
||||||
|
--id ID Show logs for a specific task (optional)
|
||||||
|
--tail N Show last N lines (default: 50)
|
||||||
|
|
||||||
|
Notifications options:
|
||||||
|
--clear Clear all notifications
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
|
||||||
|
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
|
||||||
|
triple-c-scheduler list
|
||||||
|
triple-c-scheduler logs --id a1b2c3d4 --tail 20
|
||||||
|
triple-c-scheduler run --id a1b2c3d4
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_add() {
|
||||||
|
local name="" prompt="" schedule="" at="" working_dir="/workspace"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) name="$2"; shift 2 ;;
|
||||||
|
--prompt) prompt="$2"; shift 2 ;;
|
||||||
|
--schedule) schedule="$2"; shift 2 ;;
|
||||||
|
--at) at="$2"; shift 2 ;;
|
||||||
|
--working-dir) working_dir="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo "Error: --name is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$prompt" ]; then
|
||||||
|
echo "Error: --prompt is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$schedule" ] && [ -z "$at" ]; then
|
||||||
|
echo "Error: either --schedule or --at is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$schedule" ] && [ -n "$at" ]; then
|
||||||
|
echo "Error: use either --schedule or --at, not both" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local id task_type cron_expr
|
||||||
|
id=$(generate_id)
|
||||||
|
|
||||||
|
if [ -n "$at" ]; then
|
||||||
|
task_type="once"
|
||||||
|
# Parse "YYYY-MM-DD HH:MM" into cron expression
|
||||||
|
local year month day hour minute
|
||||||
|
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
|
||||||
|
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
year="${BASH_REMATCH[1]}"
|
||||||
|
month="${BASH_REMATCH[2]}"
|
||||||
|
day="${BASH_REMATCH[3]}"
|
||||||
|
hour="${BASH_REMATCH[4]}"
|
||||||
|
minute="${BASH_REMATCH[5]}"
|
||||||
|
# Remove leading zeros for cron
|
||||||
|
month=$((10#$month))
|
||||||
|
day=$((10#$day))
|
||||||
|
hour=$((10#$hour))
|
||||||
|
minute=$((10#$minute))
|
||||||
|
cron_expr="$minute $hour $day $month *"
|
||||||
|
else
|
||||||
|
task_type="recurring"
|
||||||
|
cron_expr="$schedule"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local created_at
|
||||||
|
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
local task_json
|
||||||
|
task_json=$(jq -n \
|
||||||
|
--arg id "$id" \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg prompt "$prompt" \
|
||||||
|
--arg schedule "$cron_expr" \
|
||||||
|
--arg type "$task_type" \
|
||||||
|
--arg at "$at" \
|
||||||
|
--arg created_at "$created_at" \
|
||||||
|
--argjson enabled true \
|
||||||
|
--arg working_dir "$working_dir" \
|
||||||
|
'{
|
||||||
|
id: $id,
|
||||||
|
name: $name,
|
||||||
|
prompt: $prompt,
|
||||||
|
schedule: $schedule,
|
||||||
|
type: $type,
|
||||||
|
at: $at,
|
||||||
|
created_at: $created_at,
|
||||||
|
enabled: $enabled,
|
||||||
|
working_dir: $working_dir
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "$task_json" > "$TASKS_DIR/${id}.json"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
echo "Task created:"
|
||||||
|
echo " ID: $id"
|
||||||
|
echo " Name: $name"
|
||||||
|
echo " Type: $task_type"
|
||||||
|
if [ "$task_type" = "once" ]; then
|
||||||
|
echo " At: $at"
|
||||||
|
fi
|
||||||
|
echo " Schedule: $cron_expr"
|
||||||
|
echo " Prompt: $prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_remove() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
rm -f "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
echo "Removed task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_enable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Enabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_disable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Disabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local found=false
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
found=true
|
||||||
|
local id name type enabled schedule at prompt
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
type=$(jq -r '.type' "$task_file")
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
at=$(jq -r '.at // ""' "$task_file")
|
||||||
|
prompt=$(jq -r '.prompt' "$task_file")
|
||||||
|
|
||||||
|
local display_schedule="$schedule"
|
||||||
|
if [ "$type" = "once" ] && [ -n "$at" ]; then
|
||||||
|
display_schedule="at $at"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Truncate long fields for display
|
||||||
|
[ ${#name} -gt 20 ] && name="${name:0:17}..."
|
||||||
|
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
|
||||||
|
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
|
||||||
|
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No scheduled tasks."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local id="" tail_n=50
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
--tail) tail_n="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
local log_dir="$LOGS_DIR/$id"
|
||||||
|
if [ ! -d "$log_dir" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Show the most recent log file
|
||||||
|
local latest
|
||||||
|
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "=== Latest log for task $id: $(basename "$latest") ==="
|
||||||
|
tail -n "$tail_n" "$latest"
|
||||||
|
else
|
||||||
|
# Show recent logs across all tasks
|
||||||
|
local all_logs
|
||||||
|
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
|
||||||
|
if [ -z "$all_logs" ]; then
|
||||||
|
echo "No logs found."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for log_file in $all_logs; do
|
||||||
|
local task_id
|
||||||
|
task_id=$(basename "$(dirname "$log_file")")
|
||||||
|
echo "=== Task $task_id: $(basename "$log_file") ==="
|
||||||
|
tail -n 5 "$log_file"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Manually triggering task '$name' ($id)..."
|
||||||
|
/usr/local/bin/triple-c-task-runner "$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_notifications() {
|
||||||
|
local clear=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--clear) clear=true; shift ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$clear" = "true" ]; then
|
||||||
|
rm -f "$NOTIFICATIONS_DIR"/*.notify
|
||||||
|
echo "Notifications cleared."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=false
|
||||||
|
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
|
||||||
|
[ -f "$notify_file" ] || continue
|
||||||
|
found=true
|
||||||
|
cat "$notify_file"
|
||||||
|
echo "---"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No notifications."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
add) cmd_add "$@" ;;
|
||||||
|
remove) cmd_remove "$@" ;;
|
||||||
|
enable) cmd_enable "$@" ;;
|
||||||
|
disable) cmd_disable "$@" ;;
|
||||||
|
list) cmd_list ;;
|
||||||
|
logs) cmd_logs "$@" ;;
|
||||||
|
run) cmd_run "$@" ;;
|
||||||
|
notifications) cmd_notifications "$@" ;;
|
||||||
|
help|--help|-h) usage ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $command" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
142
container/triple-c-task-runner
Normal file
142
container/triple-c-task-runner
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
|
||||||
|
# Called by cron with a task ID argument. Handles locking, logging,
|
||||||
|
# notifications, one-time task cleanup, and log pruning.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
ENV_FILE="${SCHEDULER_DIR}/.env"
|
||||||
|
|
||||||
|
TASK_ID="${1:-}"
|
||||||
|
|
||||||
|
if [ -z "$TASK_ID" ]; then
|
||||||
|
echo "Usage: triple-c-task-runner <task-id>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
|
||||||
|
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
|
||||||
|
|
||||||
|
if [ ! -f "$TASK_FILE" ]; then
|
||||||
|
echo "Task file not found: $TASK_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
|
||||||
|
exec 200>"$LOCK_FILE"
|
||||||
|
if ! flock -n 200; then
|
||||||
|
echo "Task $TASK_ID is already running, skipping." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Source saved environment ─────────────────────────────────────────────────
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Read task definition ────────────────────────────────────────────────────
|
||||||
|
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
|
||||||
|
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
|
||||||
|
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
||||||
|
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
|
||||||
|
|
||||||
|
# ── Prepare log directory ───────────────────────────────────────────────────
|
||||||
|
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
||||||
|
mkdir -p "$TASK_LOG_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||||
|
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
|
||||||
|
|
||||||
|
# ── Execute Claude agent ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
|
||||||
|
echo "=== Started: $(date) ==="
|
||||||
|
echo "=== Working dir: $WORKING_DIR ==="
|
||||||
|
echo "=== Prompt: $PROMPT ==="
|
||||||
|
echo ""
|
||||||
|
} > "$LOG_FILE"
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
if [ -d "$WORKING_DIR" ]; then
|
||||||
|
cd "$WORKING_DIR"
|
||||||
|
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
|
||||||
|
else
|
||||||
|
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "=== Finished: $(date) ==="
|
||||||
|
echo "=== Exit code: $EXIT_CODE ==="
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── Write notification ──────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$NOTIFICATIONS_DIR"
|
||||||
|
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
||||||
|
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS="SUCCESS"
|
||||||
|
else
|
||||||
|
STATUS="FAILED (exit code $EXIT_CODE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract a summary (last 10 meaningful lines before the footer)
|
||||||
|
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
|
||||||
|
|
||||||
|
cat > "$NOTIFY_FILE" <<NOTIFY
|
||||||
|
Task: $TASK_NAME ($TASK_ID)
|
||||||
|
Status: $STATUS
|
||||||
|
Time: $(date)
|
||||||
|
Type: $TASK_TYPE
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
$SUMMARY
|
||||||
|
NOTIFY
|
||||||
|
|
||||||
|
# ── One-time task cleanup ───────────────────────────────────────────────────
|
||||||
|
if [ "$TASK_TYPE" = "once" ]; then
|
||||||
|
rm -f "$TASK_FILE"
|
||||||
|
# Rebuild crontab to remove the completed one-time task
|
||||||
|
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
|
||||||
|
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
|
||||||
|
TMP_CRON=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
|
||||||
|
echo "" >> "$TMP_CRON"
|
||||||
|
for tf in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$tf" ] || continue
|
||||||
|
local_enabled=$(jq -r '.enabled' "$tf")
|
||||||
|
[ "$local_enabled" = "true" ] || continue
|
||||||
|
local_schedule=$(jq -r '.schedule' "$tf")
|
||||||
|
local_id=$(jq -r '.id' "$tf")
|
||||||
|
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
|
||||||
|
done
|
||||||
|
crontab "$TMP_CRON" 2>/dev/null || true
|
||||||
|
rm -f "$TMP_CRON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
|
||||||
|
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$LOG_COUNT" -gt 20 ]; then
|
||||||
|
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||||
|
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||||
|
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release lock
|
||||||
|
flock -u 200
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
Reference in New Issue
Block a user