Compare commits
105
Commits
@@ -0,0 +1,317 @@
|
|||||||
|
name: Build App (Preview)
|
||||||
|
|
||||||
|
# Builds the Tauri app for branches other than main and exposes the bundles as
|
||||||
|
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
||||||
|
# smoke-testing of feature branches before they merge.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compute-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute preview version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
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
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
# Tauri / Cargo require a strict semver; strip the preview suffix for
|
||||||
|
# the bundle version but keep it in the artifact filename.
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxdo-dev \
|
||||||
|
patchelf \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
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"
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
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
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
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/')
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
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: Set app version
|
||||||
|
run: |
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
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 macOS artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: cmd
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$raw = "${{ needs.compute-version.outputs.version }}"
|
||||||
|
$version = $raw.Split('-')[0]
|
||||||
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
where rustup >nul 2>&1 && (
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
) || (
|
||||||
|
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
|
||||||
|
rustup-init.exe -y --default-toolchain stable
|
||||||
|
del rustup-init.exe
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
where node >nul 2>&1 && (
|
||||||
|
node --version
|
||||||
|
) || (
|
||||||
|
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
||||||
|
msiexec /i node-install.msi /quiet /norestart
|
||||||
|
del node-install.msi
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Verify tools
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Install Tauri CLI via cargo
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
cargo install tauri-cli --version "^2"
|
||||||
|
|
||||||
|
- name: Fix npm platform detection
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm config set os win32
|
||||||
|
npm config list
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
if exist node_modules rmdir /s /q node_modules
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
cargo tauri build
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
mkdir artifacts
|
||||||
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
||||||
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
|
dir artifacts\
|
||||||
|
|
||||||
|
- name: Upload Windows artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
+236
-52
@@ -5,11 +5,13 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "app/**"
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "app/**"
|
- "app/**"
|
||||||
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -18,10 +20,44 @@ env:
|
|||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
compute-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
|
- name: Compute version from VERSION file and tags
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||||
|
|
||||||
|
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
||||||
|
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
||||||
|
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$LATEST_TAG" ]; then
|
||||||
|
echo "Latest matching tag: ${LATEST_TAG}"
|
||||||
|
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||||
|
else
|
||||||
|
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
||||||
|
PATCH=$(git rev-list --count HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -54,17 +90,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Compute version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
|
||||||
VERSION="0.2.${COMMIT_COUNT}"
|
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Computed version: ${VERSION}"
|
|
||||||
|
|
||||||
- name: Set app version
|
- name: Set app version
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
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/package.json
|
||||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
@@ -133,7 +161,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="v${{ steps.version.outputs.VERSION }}"
|
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||||
# Create release
|
# Create release
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
@@ -156,6 +184,7 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
needs: [compute-version]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -183,17 +212,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Compute version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
|
||||||
VERSION="0.2.${COMMIT_COUNT}"
|
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Computed version: ${VERSION}"
|
|
||||||
|
|
||||||
- name: Set app version
|
- name: Set app version
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
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/package.json
|
||||||
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
@@ -243,21 +264,72 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="v${{ steps.version.outputs.VERSION }}-mac"
|
set -euo pipefail
|
||||||
# Create release
|
TAG="v${{ needs.compute-version.outputs.version }}-mac"
|
||||||
curl -s -X POST \
|
|
||||||
|
# Idempotent get-or-create. macOS upload has historically failed
|
||||||
|
# mid-stream (curl exit 92, exit 28), leaving the release record
|
||||||
|
# with empty assets. A naive POST /releases on the next run hits
|
||||||
|
# 409 from Gitea for the duplicate tag, the JSON parse below
|
||||||
|
# then yields an empty RELEASE_ID, and pipefail aborts with an
|
||||||
|
# opaque exit 1. Look the release up by tag first; create only
|
||||||
|
# if it doesn't exist; reuse the existing id otherwise.
|
||||||
|
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200)
|
||||||
|
echo "Release ${TAG} already exists, reusing"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "Release ${TAG} not found, creating"
|
||||||
|
curl -fsS -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-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 }}\"}" \
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
"${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 "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "Failed to parse release id; response was:" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
# Upload each artifact
|
|
||||||
|
# Upload each artifact. If an asset with the same name already
|
||||||
|
# exists on the release (left over from a partial prior run),
|
||||||
|
# delete it first so the upload is replace-not-conflict.
|
||||||
|
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
|
||||||
|
# the macOS runner has hit, retries with backoff for transient
|
||||||
|
# drops, and -f so HTTP errors stop being silently swallowed.
|
||||||
for file in artifacts/*; do
|
for file in artifacts/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
EXISTING_ID=$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST \
|
curl -fsS --http1.1 \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||||
|
--max-time 600 \
|
||||||
|
-X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file}" \
|
--data-binary "@${file}" \
|
||||||
@@ -266,6 +338,7 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
needs: [compute-version]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -275,23 +348,53 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Compute version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
|
|
||||||
set "VERSION=0.2.%COMMIT_COUNT%"
|
|
||||||
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
|
||||||
echo Computed version: %VERSION%
|
|
||||||
|
|
||||||
- name: Set app version
|
- name: Set app version
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ steps.version.outputs.VERSION }}"
|
$version = "${{ needs.compute-version.outputs.version }}"
|
||||||
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
Write-Host "Patched version to $version"
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install MSVC C++ build tools
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
|
||||||
|
rem This job previously assumed a hand-provisioned runner; a runner
|
||||||
|
rem without them registers fine, advertises windows-latest, accepts the
|
||||||
|
rem job, downloads the whole crate graph and only then fails at link
|
||||||
|
rem time with "linker `link.exe` not found".
|
||||||
|
rem
|
||||||
|
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
|
||||||
|
rem installing is enough - no dev-shell activation needed here.
|
||||||
|
rem
|
||||||
|
rem Delayed expansion is required: %VAR% inside a parenthesised block
|
||||||
|
rem is substituted when the block is PARSED, not when it runs, so both
|
||||||
|
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
set "VCPATH="
|
||||||
|
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||||
|
if exist "%VSWHERE%" (
|
||||||
|
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
|
||||||
|
)
|
||||||
|
if defined VCPATH (
|
||||||
|
echo MSVC build tools already present at !VCPATH!
|
||||||
|
) else (
|
||||||
|
echo MSVC build tools not found - installing Visual Studio Build Tools
|
||||||
|
curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
|
||||||
|
"%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
|
||||||
|
set "VSEXIT=!ERRORLEVEL!"
|
||||||
|
del "%TEMP%\vs_BuildTools.exe" 2>nul
|
||||||
|
rem 3010 means installed, reboot pending - a success for our purposes.
|
||||||
|
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
|
||||||
|
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Visual Studio Build Tools installed
|
||||||
|
)
|
||||||
|
endlocal
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
run: |
|
run: |
|
||||||
where rustup >nul 2>&1 && (
|
where rustup >nul 2>&1 && (
|
||||||
@@ -351,36 +454,117 @@ jobs:
|
|||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
||||||
|
rem makensis.exe - is 32-bit. A runner running as SYSTEM has
|
||||||
|
rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
|
||||||
|
rem WOW64 redirection sends 32-bit processes reading System32 to
|
||||||
|
rem SysWOW64, so they cannot see their own directory: candle exits
|
||||||
|
rem 0x80131700 and makensis reports "Unable to start child process,
|
||||||
|
rem error 0x2".
|
||||||
|
rem
|
||||||
|
rem The build VM carries junctions from the SysWOW64 view of
|
||||||
|
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
|
||||||
|
rem System32 originals, which makes the redirected view resolve. A
|
||||||
|
rem runner running as a normal user needs no such patch.
|
||||||
|
cargo tauri build --bundles msi,nsis
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
mkdir artifacts
|
mkdir artifacts
|
||||||
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1
|
||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
$ErrorActionPreference = "Stop"
|
||||||
echo Creating release %TAG%...
|
$tag = "v$env:VERSION-win"
|
||||||
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 }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
:found
|
|
||||||
echo Release ID: %RELEASE_ID%
|
# Idempotent get-or-create. The old cmd-batch version swallowed
|
||||||
for %%f in (artifacts\*) do (
|
# curl errors and parsed the release id with findstr, so a 409 on
|
||||||
echo Uploading %%~nxf...
|
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
|
# a malformed .../releases//assets URL while the step still reported
|
||||||
)
|
# success. Look the release up by tag first; create only on 404.
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
|
||||||
|
Write-Host "Release $tag already exists, reusing"
|
||||||
|
} catch {
|
||||||
|
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||||
|
Write-Host "Release $tag not found, creating"
|
||||||
|
$body = @{
|
||||||
|
tag_name = $tag
|
||||||
|
name = "Triple-C v$env:VERSION (Windows)"
|
||||||
|
body = "Automated build from commit $env:COMMIT_SHA"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$release = Invoke-RestMethod -Method Post -Headers $headers `
|
||||||
|
-ContentType "application/json" -Body $body -Uri "$api/releases"
|
||||||
|
} else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = $release.id
|
||||||
|
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
|
||||||
|
Write-Host "Release ID: $releaseId"
|
||||||
|
|
||||||
|
# Upload each artifact. Delete any same-named asset left over from a
|
||||||
|
# partial prior run first, so the upload replaces rather than 409s.
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
|
||||||
|
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Deleting existing asset $name (id $($dupe.id))"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
create-tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create version tag
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
echo "Creating tag ${TAG}..."
|
||||||
|
|
||||||
|
# Create annotated tag via Gitea API
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||||
|
|
||||||
|
echo "Tag ${TAG} created successfully"
|
||||||
|
|
||||||
sync-to-github:
|
sync-to-github:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-linux, build-macos, build-windows]
|
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
@@ -389,7 +573,7 @@ jobs:
|
|||||||
- name: Download artifacts from Gitea releases
|
- name: Download artifacts from Gitea releases
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
VERSION: ${{ needs.build-linux.outputs.version }}
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
@@ -418,7 +602,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub release and upload artifacts
|
- name: Create GitHub release and upload artifacts
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.build-linux.outputs.version }}
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build STT Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "stt-container/**"
|
||||||
|
- ".gitea/workflows/build-stt.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "stt-container/**"
|
||||||
|
- ".gitea/workflows/build-stt.yml"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: repo.anhonesthost.net
|
||||||
|
IMAGE_NAME: cybercovellc/triple-c/triple-c-stt
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-stt-container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
|
- name: Build and push STT container image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./stt-container
|
||||||
|
file: ./stt-container/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ gitea.event_name == 'push' }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
ghcr.io/shadowdao/triple-c-stt:latest
|
||||||
|
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -36,6 +36,13 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
@@ -46,5 +53,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||||
|
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
name: Cleanup Old Releases
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
keep_versions:
|
||||||
|
description: "Number of recent versions to keep (each version has 3 releases: Linux, macOS, Windows)"
|
||||||
|
required: true
|
||||||
|
default: "5"
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run - list what would be deleted without actually deleting"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- "true"
|
||||||
|
- "false"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Cleanup old releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
KEEP_VERSIONS: ${{ gitea.event.inputs.keep_versions }}
|
||||||
|
DRY_RUN: ${{ gitea.event.inputs.dry_run }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "==> Configuration"
|
||||||
|
echo " Keep versions: ${KEEP_VERSIONS}"
|
||||||
|
echo " Dry run: ${DRY_RUN}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Fetch all Gitea releases (paginated) ──
|
||||||
|
ALL_RELEASES="[]"
|
||||||
|
PAGE=1
|
||||||
|
while true; do
|
||||||
|
BATCH=$(curl -sf \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50&page=${PAGE}")
|
||||||
|
COUNT=$(echo "$BATCH" | jq 'length')
|
||||||
|
[ "$COUNT" -eq 0 ] && break
|
||||||
|
ALL_RELEASES=$(echo "$ALL_RELEASES" "$BATCH" | jq -s '.[0] + .[1]')
|
||||||
|
PAGE=$((PAGE + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(echo "$ALL_RELEASES" | jq 'length')
|
||||||
|
echo "==> Found ${TOTAL} total Gitea releases"
|
||||||
|
|
||||||
|
# ── Extract unique version numbers and sort them ──
|
||||||
|
# Tags are like: v0.2.26, v0.2.26-mac, v0.2.26-win, build-xxx
|
||||||
|
# Extract the base version (strip -mac, -win suffixes)
|
||||||
|
VERSIONS=$(echo "$ALL_RELEASES" | jq -r '.[].tag_name' \
|
||||||
|
| sed 's/-mac$//' | sed 's/-win$//' \
|
||||||
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
|
| sort -t. -k1,1V -k2,2n -k3,3n \
|
||||||
|
| uniq)
|
||||||
|
|
||||||
|
VERSION_COUNT=$(echo "$VERSIONS" | wc -l)
|
||||||
|
echo "==> Found ${VERSION_COUNT} unique versions"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Determine which versions to keep and which to delete ──
|
||||||
|
KEEP=$(echo "$VERSIONS" | tail -n "${KEEP_VERSIONS}")
|
||||||
|
DELETE=$(echo "$VERSIONS" | head -n -"${KEEP_VERSIONS}")
|
||||||
|
|
||||||
|
DELETE_COUNT=$(echo "$DELETE" | grep -c . || true)
|
||||||
|
if [ "$DELETE_COUNT" -eq 0 ]; then
|
||||||
|
echo "==> Nothing to clean up. Only ${VERSION_COUNT} versions exist, keeping ${KEEP_VERSIONS}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Keeping ${KEEP_VERSIONS} most recent versions:"
|
||||||
|
echo "$KEEP" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo "==> Will delete ${DELETE_COUNT} older versions ($(echo "$DELETE" | head -1) through $(echo "$DELETE" | tail -1)):"
|
||||||
|
echo "$DELETE" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Delete releases ──
|
||||||
|
DELETED_GITEA=0
|
||||||
|
DELETED_GITHUB=0
|
||||||
|
DELETED_TAGS=0
|
||||||
|
|
||||||
|
for VERSION in $DELETE; do
|
||||||
|
# Each version can have up to 3 releases: base, -mac, -win
|
||||||
|
for SUFFIX in "" "-mac" "-win"; do
|
||||||
|
TAG="${VERSION}${SUFFIX}"
|
||||||
|
|
||||||
|
# Find the Gitea release ID for this tag
|
||||||
|
RELEASE_ID=$(echo "$ALL_RELEASES" | jq -r --arg tag "$TAG" '.[] | select(.tag_name == $tag) | .id // empty')
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete Gitea release: ${TAG} (id: ${RELEASE_ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting Gitea release: ${TAG} (id: ${RELEASE_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}" || echo " Warning: failed to delete Gitea release ${TAG}"
|
||||||
|
DELETED_GITEA=$((DELETED_GITEA + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete the Gitea tag
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete Gitea tag: ${TAG}"
|
||||||
|
else
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null && DELETED_TAGS=$((DELETED_TAGS + 1)) || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Delete the unified GitHub release (single tag per version, no suffix)
|
||||||
|
if [ -n "$GH_PAT" ]; then
|
||||||
|
GH_RELEASE=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${VERSION}" 2>/dev/null || echo "{}")
|
||||||
|
GH_RELEASE_ID=$(echo "$GH_RELEASE" | jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -n "$GH_RELEASE_ID" ]; then
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" || echo " Warning: failed to delete GitHub release ${VERSION}"
|
||||||
|
DELETED_GITHUB=$((DELETED_GITHUB + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete the GitHub tag
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete GitHub tag: ${VERSION}"
|
||||||
|
else
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${VERSION}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Also clean up any legacy non-semver releases (e.g., build-xxx) ──
|
||||||
|
LEGACY_RELEASES=$(echo "$ALL_RELEASES" | jq -r '.[] | select(.tag_name | test("^v[0-9]") | not) | "\(.id) \(.tag_name)"')
|
||||||
|
LEGACY_COUNT=$(echo "$LEGACY_RELEASES" | grep -c . || true)
|
||||||
|
|
||||||
|
if [ "$LEGACY_COUNT" -gt 0 ]; then
|
||||||
|
echo "==> Found ${LEGACY_COUNT} legacy (non-versioned) releases to clean up:"
|
||||||
|
echo "$LEGACY_RELEASES" | while read -r ID TAG; do
|
||||||
|
[ -z "$ID" ] && continue
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would delete legacy release: ${TAG} (id: ${ID})"
|
||||||
|
else
|
||||||
|
echo " Deleting legacy release: ${TAG} (id: ${ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || echo " Warning: failed to delete ${TAG}"
|
||||||
|
# Delete the tag too
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null || true
|
||||||
|
DELETED_GITEA=$((DELETED_GITEA + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ──
|
||||||
|
echo "==> Cleanup complete"
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " Mode: DRY RUN (no changes made)"
|
||||||
|
echo " Would delete: ${DELETE_COUNT} versions (up to $((DELETE_COUNT * 3)) Gitea releases + GitHub releases)"
|
||||||
|
[ "$LEGACY_COUNT" -gt 0 ] && echo " Would also delete: ${LEGACY_COUNT} legacy releases"
|
||||||
|
else
|
||||||
|
echo " Gitea releases deleted: ${DELETED_GITEA}"
|
||||||
|
echo " GitHub releases deleted: ${DELETED_GITHUB}"
|
||||||
|
echo " Tags deleted: ${DELETED_TAGS}"
|
||||||
|
fi
|
||||||
@@ -56,34 +56,78 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
|
|
||||||
### Frontend Structure (`app/src/`)
|
### Frontend Structure (`app/src/`)
|
||||||
|
|
||||||
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
|
||||||
|
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
|
||||||
|
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
|
||||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
- **`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
|
- **`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/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||||
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||||
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
|
and the editors reused by Project Home
|
||||||
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
|
modals — see "UI conventions" below.
|
||||||
|
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
||||||
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||||
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
|
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||||
|
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
|
||||||
|
|
||||||
|
### UI conventions
|
||||||
|
|
||||||
|
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
|
||||||
|
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
|
||||||
|
previously had ~12 hand-rolled modals; they were consolidated deliberately.
|
||||||
|
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
|
||||||
|
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
|
||||||
|
Use `--text-disabled` rather than `disabled:opacity-50`.
|
||||||
|
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
|
||||||
|
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
|
||||||
|
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump.
|
||||||
|
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
|
||||||
|
|
||||||
### Backend Structure (`app/src-tauri/src/`)
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
|
- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`.
|
||||||
|
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
|
||||||
|
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||||
|
`auth_token_commands.rs`.
|
||||||
|
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
|
||||||
|
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
|
||||||
|
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
|
||||||
|
API via `socat`. Opt-in per project.
|
||||||
- **`docker/`** — Docker API layer using bollard:
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||||
|
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||||
- `image.rs` — Image build/pull with progress streaming
|
- `image.rs` — Image build/pull with progress streaming
|
||||||
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||||
|
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||||
|
users have migrated.
|
||||||
|
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||||
|
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||||
|
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||||
|
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
|
||||||
|
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). 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`
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
### Container (`container/`)
|
### 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
|
- **`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`
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
|
|
||||||
|
**Reset is the exception and it is destructive.** `rebuild_project_container` calls
|
||||||
|
`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`,
|
||||||
|
`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is
|
||||||
|
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||||
|
preserving credentials.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -91,7 +135,7 @@ Per-project, independently configured:
|
|||||||
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||||
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||||
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||||
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers
|
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
@@ -104,8 +148,18 @@ Per-project, independently configured:
|
|||||||
|
|
||||||
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
- 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 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
|
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
|
||||||
|
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
|
||||||
|
need an entry there — adding one is not required and none exists for any app command.
|
||||||
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
||||||
|
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
|
||||||
|
**label-based** — it does not diff the container's env. If a new setting affects the container's
|
||||||
|
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
||||||
|
and compare it there, or the change will silently not take effect until some unrelated setting
|
||||||
|
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
||||||
|
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
||||||
|
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||||
|
`models/project.rs` for anything that should default to true.
|
||||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# Triple-C Design & Product Review
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Version reviewed:** 0.3.0 · **Reviewer:** Fable 5
|
||||||
|
|
||||||
|
Scope: `app/src/` (App, layout, projects, settings, terminal, ui, store, index.css),
|
||||||
|
README/CLAUDE.md/TODO.md, the four repo screenshots, and `triple-c-app-logov2.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary verdict
|
||||||
|
|
||||||
|
The bones are good. The floating-panel layout reads clean, the GitHub-dark palette is
|
||||||
|
inoffensive, and terminal-as-centerpiece is correct for this product.
|
||||||
|
|
||||||
|
The two real problems are structural, and they are the same problem seen from two sides:
|
||||||
|
**the project — the app's actual unit of work — has no room to live.** Everything about a
|
||||||
|
project (backend auth, mounts, git identity, env vars, ports, Claude settings, file
|
||||||
|
manager) is stuffed into a ~280px sidebar card (`ProjectCard.tsx`, 1,257 lines) that
|
||||||
|
sprays out seven modals to compensate.
|
||||||
|
|
||||||
|
`screenshot_for_fix/project_config_run_off.png` is not a bug to patch. It is the
|
||||||
|
architecture reporting that the config does not fit where it lives. Fixing that one thing
|
||||||
|
also solves the modal pile, the density problems, *and* creates the surface where newer
|
||||||
|
Claude Code concepts belong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Visual & interaction design
|
||||||
|
|
||||||
|
### A1. Tokens: coherent but thin, with one real contrast failure
|
||||||
|
|
||||||
|
`index.css` is GitHub Primer dark, verbatim (`#0d1117 / #161b22 / #21262d / #30363d /
|
||||||
|
#8b949e / #58a6ff`). Defensible — familiar, calm, terminal-adjacent — but the token layer
|
||||||
|
stops at 11 variables. Roles the code is already faking ad hoc:
|
||||||
|
|
||||||
|
- **No elevation/overlay token.** Modals reuse `--bg-secondary`, so a modal over the
|
||||||
|
sidebar is the same color as the sidebar. Add `--bg-overlay: #1c2128` and
|
||||||
|
`--shadow-overlay`.
|
||||||
|
- **No muted-accent tokens.** The code hand-rolls `bg-yellow-500/20 text-yellow-400`,
|
||||||
|
`bg-blue-500/20 text-blue-400`, `--warning/15`, `--error/10`. Add `--accent-muted`,
|
||||||
|
`--warning-muted`, `--error-muted`, `--success-muted`. Those raw Tailwind palette colors
|
||||||
|
are the only two places the token system leaks.
|
||||||
|
- **Radius drift:** `rounded` (4px), `rounded-lg` (8px), plus hardcoded 3px/6px in help
|
||||||
|
styles. Pick two: 6px controls, 8px panels.
|
||||||
|
|
||||||
|
**Contrast bug (concrete):** white text on `--accent #58a6ff` is ~**2.5:1** — fails WCAG
|
||||||
|
AA. That is the primary button ("Add Project"), the "Update" pill, and more. Primer solves
|
||||||
|
this with two accents: keep `#58a6ff` as the *foreground/link* accent and add
|
||||||
|
`--accent-emphasis: #1f6feb` for filled buttons (white on `#1f6feb` ≈ 4.7:1).
|
||||||
|
|
||||||
|
Same story for `bg-[var(--success)] text-white` ON toggles — `#3fb950` + white ≈ **2.1:1**,
|
||||||
|
the worst offender in the app.
|
||||||
|
|
||||||
|
What passes: `--text-secondary #8b949e` on `#161b22` ≈ 5.8:1, fine even at 12px.
|
||||||
|
`--warning #d29922` ≈ 7:1. But `disabled:opacity-50` on secondary text drops to ~2.4:1 —
|
||||||
|
and since the entire config form is disabled while the container runs, **the most common
|
||||||
|
state of the form is illegible.** Use a dedicated `--text-disabled: #6e7681` instead of
|
||||||
|
opacity.
|
||||||
|
|
||||||
|
### A2. Type and density: everything is 12px
|
||||||
|
|
||||||
|
Roughly 90% of the UI is `text-xs`. Hierarchy is carried almost entirely by weight plus a
|
||||||
|
single `text-lg` modal title. Forms feel cramped rather than dense — density is
|
||||||
|
information per pixel, not small type.
|
||||||
|
|
||||||
|
Proposed scale with roles: **11px** uppercase section labels (already used, keep) ·
|
||||||
|
**12px** secondary/meta · **13px** default UI/body/form values · **14px** panel headers ·
|
||||||
|
**16px** view titles.
|
||||||
|
|
||||||
|
Path strings in mono are a nice identity touch — extend mono to all machine values (model
|
||||||
|
IDs, ports, digests), which the Bedrock/Ollama forms currently render in the UI face.
|
||||||
|
|
||||||
|
The outer chrome spends generously while content starves: `App.tsx` wraps everything in
|
||||||
|
`p-6 gap-4`, then the config form gets ~180px-wide inputs for AWS secret keys. Keep the
|
||||||
|
floating-island look; `p-3 gap-3` buys content ~24px horizontally and the terminal two
|
||||||
|
more rows.
|
||||||
|
|
||||||
|
### A3. The project card is three components wearing one div
|
||||||
|
|
||||||
|
`ProjectCard` is simultaneously a list row, a command strip, and the entire settings form.
|
||||||
|
|
||||||
|
- **Selection and disclosure are conflated.** Clicking a row both selects it and expands an
|
||||||
|
accordion in place, shoving the other projects down. The 06-28 screenshot shows 18
|
||||||
|
projects — this jank is daily.
|
||||||
|
- **Actions are unstyled text links.** `ActionButton` renders `text-xs px-2 py-0.5` colored
|
||||||
|
text with no border or background, so Start/Stop/Terminal/Shell/Files/Backup/Config/Remove
|
||||||
|
read as a wrapping line of links. Worse, **Remove (destructive, red) wraps directly next
|
||||||
|
to Config** with a ~20px hit target.
|
||||||
|
- **Double-click-to-rename** is undiscoverable and keyboard/touch-inaccessible.
|
||||||
|
- **27 hover-only `<Tooltip>` markers in ProjectCard alone.** When a form needs 27 tooltips,
|
||||||
|
the form is the problem.
|
||||||
|
|
||||||
|
### A4. Modals: eight is a pattern smell, and none are real dialogs
|
||||||
|
|
||||||
|
Hanging off ProjectCard: EnvVars, PortMappings, ClaudeInstructions, ClaudeCodeSettings,
|
||||||
|
ContainerProgress, FileManager, ConfirmRemove — plus AddProject, three reused from
|
||||||
|
SettingsPanel, and Update/ImageUpdate/Help from TopBar.
|
||||||
|
|
||||||
|
Each reimplements the overlay div, Escape handler, and click-outside logic by hand. **None
|
||||||
|
has `role="dialog"`, `aria-modal`, a focus trap, or focus restore** — zero hits for
|
||||||
|
`role=`, `aria-modal`, or `tabIndex` across `components/`.
|
||||||
|
|
||||||
|
The pattern is wrong not because modals are bad, but because these are not modal *tasks*.
|
||||||
|
Env vars, ports, instructions, and Claude settings are all "edit part of the project
|
||||||
|
config" — a detail view's job.
|
||||||
|
|
||||||
|
- Legitimately modal: **ConfirmRemove**, **AddProject**.
|
||||||
|
- **FileManager** wants to be a main-area tab, not a 42rem popup.
|
||||||
|
- **ContainerProgressModal actively hurts:** starting a container blocks the entire app
|
||||||
|
behind an overlay for an operation designed to be routine. Replace with inline row state
|
||||||
|
plus an error toast.
|
||||||
|
- Whatever survives should be one shared `<Modal>` primitive with focus trap + ARIA.
|
||||||
|
|
||||||
|
### A5. Keyboard and focus: currently unsupported
|
||||||
|
|
||||||
|
For a tool whose centerpiece is a keyboard-driven terminal, the chrome is mouse-only.
|
||||||
|
|
||||||
|
- Inputs use `focus:outline-none` with only a low-contrast border swap; **buttons have no
|
||||||
|
focus style at all** — tabbing through the sidebar is invisible.
|
||||||
|
- One-line fix: add `--focus-ring: #58a6ff` and
|
||||||
|
`:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }`
|
||||||
|
- No shortcuts for constant actions: `Ctrl+T` new terminal, `Ctrl+Tab`/`Ctrl+1..9` switch,
|
||||||
|
`Ctrl+W` close, `Ctrl+P` project switcher. The only shortcut in the app is the STT mic.
|
||||||
|
- Hit targets below 24px: tab close "×" (~14px), Tooltip "?" (14px), Browse "...". The
|
||||||
|
status bar is `h-6` yet hosts two interactive controls.
|
||||||
|
|
||||||
|
### A6. Status communication
|
||||||
|
|
||||||
|
Three disconnected dot systems (TopBar Docker/Image, per-project status, StatusBar counts),
|
||||||
|
all 8px and color-only.
|
||||||
|
|
||||||
|
- **Stopped (gray) and error (red) differ only by hue**, and Docker-unavailable renders the
|
||||||
|
same gray as Docker-still-being-checked (`dockerAvailable === null` and `false` both fall
|
||||||
|
through). An outage should be loud; unknown should pulse.
|
||||||
|
- Color-only encoding fails colorblind users. Add shape or text — `● Running`, `○ Stopped`,
|
||||||
|
`⚠ Error`. The words are already in the model.
|
||||||
|
- Raw `String(e)` errors dumped into a 12px card line; bollard errors are long. Errors need
|
||||||
|
a home: toast plus expandable detail.
|
||||||
|
- The TopBar tab strip is visually disconnected from the terminal it controls. Move tabs
|
||||||
|
onto the terminal panel's top edge so the active tab connects to its content.
|
||||||
|
|
||||||
|
### A7. Empty and first-run states
|
||||||
|
|
||||||
|
`WelcomeScreen` is three lines of gray text with no affordance — "Add a project from the
|
||||||
|
sidebar" *describes* a button instead of *being* one. This is also where brand could exist:
|
||||||
|
the orange sun-gear logo appears nowhere in the UI and shares no DNA with the blue-on-
|
||||||
|
graphite chrome.
|
||||||
|
|
||||||
|
Make it an onboarding checklist reusing state already tracked:
|
||||||
|
✓ Docker detected → ✓ Image pulled → **[ Add your first project ]** → open terminal.
|
||||||
|
The same pattern fixes the "image missing" case, today just a gray dot in the corner.
|
||||||
|
|
||||||
|
### A8. Dark-only: keep it
|
||||||
|
|
||||||
|
Right call. Terminal-first developer tool, xterm content is dark, audience expects it. The
|
||||||
|
tokens make a light theme cheap later. Don't spend on it now — but keep discipline that no
|
||||||
|
color bypasses the token layer.
|
||||||
|
|
||||||
|
### A9. Iconography
|
||||||
|
|
||||||
|
Mixed: hand-inlined Feather-style SVGs in the sidebar rail, text glyphs elsewhere ("×",
|
||||||
|
"?", "...", "+", "✓", "✕"). Adopt `lucide-react` — same stroke style already being
|
||||||
|
imitated, tree-shakeable — and replace the text glyphs. It also supplies the per-concept
|
||||||
|
icons Part B needs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — Information architecture & product concepts
|
||||||
|
|
||||||
|
### B1. The diagnosis
|
||||||
|
|
||||||
|
Current IA: `Projects | MCP | Settings` in a sidebar, terminal in main, project detail
|
||||||
|
crammed into the list.
|
||||||
|
|
||||||
|
Deleting the MCP tab was correct — but **the lesson matters more than the freed slot.
|
||||||
|
MCP died as a Triple-C feature because Claude Code absorbed it.** Hooks, skills, agents,
|
||||||
|
plugins, output styles, and statusline are all the same species: files under `.claude/`
|
||||||
|
that Claude Code manages natively with its own TUIs (`/agents`, `/hooks`, `/plugins`). If
|
||||||
|
Triple-C builds form editors for them, it loses the same race again and becomes exactly
|
||||||
|
what it should fear — a settings-file editor with a GUI skin.
|
||||||
|
|
||||||
|
What Claude Code *cannot* do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it.** The config volume, the workspace mounts, the lifecycle, the
|
||||||
|
scheduler already shipping in every image, and the fleet view across many projects.
|
||||||
|
|
||||||
|
> **Principle: Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Sessions, checkpoints, background tasks, scheduled tasks, capability inventory → surface
|
||||||
|
them, read from the volume, launch into the terminal. Hook/skill/agent *editing* →
|
||||||
|
deep-link into the terminal, don't rebuild.
|
||||||
|
|
||||||
|
### B2. Proposed IA: three nouns
|
||||||
|
|
||||||
|
**Project** (a sandboxed workspace) · **Session** (a resumable conversation) ·
|
||||||
|
**Library** (reusable capabilities pushed into projects). Everything is one of these, or
|
||||||
|
Settings.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar: ⌂ api-server │ ▣ api-server ✕ │ ▣ api (bash) ✕ │ ● ● ? │
|
||||||
|
├─────────────┬──────────────────────────────────────────────────────┤
|
||||||
|
│ ◤ Projects │ MAIN AREA — a tab strip of two tab kinds: │
|
||||||
|
│ ● api-serv │ ⌂ project-home tabs ▣ terminal tabs │
|
||||||
|
│ ○ blog │ │
|
||||||
|
│ ● data-pipe│ ⌂ api-server ● Running · 2h 14m │
|
||||||
|
│ … │ ┌─────────┬──────────┬────────────┬────────┐ │
|
||||||
|
│ ◧ Library │ │Overview │ Sessions │ Automation │ Config │ │
|
||||||
|
│ ⚙ Settings │ └─────────┴──────────┴────────────┴────────┘ │
|
||||||
|
├─────────────┴──────────────────────────────────────────────────────┤
|
||||||
|
│ StatusBar: 18 projects · 8 running · 4 terminals 🎤 ↓Jump │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sidebar** becomes a pure list plus nav rail. Rows carry name, path, status dot, and on
|
||||||
|
hover a play/stop and terminal button. Clicking opens (or focuses) that project's
|
||||||
|
**Project Home** tab. The freed MCP slot becomes **Library**.
|
||||||
|
- **Main area** hosts two tab kinds: terminals (as today) and project-home tabs, like VS
|
||||||
|
Code's Settings tab. The terminal stays the centerpiece; Project Home is one keystroke
|
||||||
|
away rather than a layer on top.
|
||||||
|
- **All seven config modals dissolve** into the Config tab, full-width, grouped:
|
||||||
|
*Workspace* (folders/mounts), *Model* (backend + auth), *Access* (git/SSH/env/ports),
|
||||||
|
*Runtime* (docker access, sandbox, permission mode, Mission Control). Room for visible
|
||||||
|
helper text kills most of the 27 tooltips. Save-on-blur stays but gains a visible
|
||||||
|
"Saved ✓ / Failed" indicator — today failures go only to `console.error`, which is
|
||||||
|
silent data loss.
|
||||||
|
|
||||||
|
#### Project Home — Overview tab
|
||||||
|
|
||||||
|
```
|
||||||
|
api-server ● Running · started 2h ago
|
||||||
|
[ Stop ] [ Open Claude Terminal ] [ Shell ] [ Files ] [⋯ menu]
|
||||||
|
|
||||||
|
Permission mode ( Plan ) ( Default ) ( Accept Edits ) (▮ Bypass ▮)
|
||||||
|
Sandbox ON — bubblewrap isolation Backend Anthropic
|
||||||
|
|
||||||
|
CAPABILITIES (read from container volume)
|
||||||
|
◆ Skills 7 ◆ Agents 3 ◆ Hooks 2 ◆ Plugins 1 ◆ Commands 5
|
||||||
|
└ click any → drawer listing names/descriptions,
|
||||||
|
[Manage in terminal] → opens claude with /agents etc.
|
||||||
|
|
||||||
|
RECENT SESSIONS SCHEDULED TASKS
|
||||||
|
"Refactor OAuth flow" 2h ago [Resume] nightly-review 0 3 * * *
|
||||||
|
"Fix flaky CI test" 1d ago [Resume] [2 notifications]
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. The four concepts worth building
|
||||||
|
|
||||||
|
**1. Sessions & Resume — the flagship.** The stop/start container model creates a problem
|
||||||
|
plain Claude Code doesn't have: stop a container, come back Tuesday, and "which
|
||||||
|
conversation was I in?" is buried in the volume. Read session metadata via `docker exec`
|
||||||
|
(the exec and tar plumbing already exists), list sessions with summary and age, and make
|
||||||
|
**[Resume]** open a terminal running `claude --resume <id>`. Closing a terminal tab today
|
||||||
|
silently abandons a session; it should say "Session saved — resume from Project Home."
|
||||||
|
This turns the biggest architectural quirk into the best feature.
|
||||||
|
|
||||||
|
Do **not** build a checkpoint browser. Mention rewind (`Esc Esc`) in Help and stop there.
|
||||||
|
|
||||||
|
**2. Library — the MCP tab's successor.** The pattern was already invented three times:
|
||||||
|
global MCP servers with per-project checkboxes, global Claude instructions, and Mission
|
||||||
|
Control's bundled skill install. Generalize it once: a Library of **skills, agents, and
|
||||||
|
slash commands** defined globally with per-project enable, synced into the container's
|
||||||
|
`.claude` volume by the entrypoint. Across many projects, "write a skill once, enable it in
|
||||||
|
twelve sandboxes" is genuinely differentiated. Keep the editor minimal — name plus markdown
|
||||||
|
textarea, or "import from folder." Not a structured form per frontmatter field.
|
||||||
|
|
||||||
|
**3. Permission mode as the hero control.** The whole pitch is "sandbox so you can safely
|
||||||
|
go fast," yet that pitch is expressed as a scary boolean buried in a config accordion.
|
||||||
|
Replace it with Claude Code's real vocabulary — a segmented control (**Plan / Default /
|
||||||
|
Accept Edits / Bypass**) on Overview, echoed as a badge on terminal tabs, with sandbox
|
||||||
|
state beside it. When sandbox is ON, Bypass loses its red paint ("contained by sandbox");
|
||||||
|
when sandbox is OFF *and* Bypass is on, that is when caution color earns its place. This
|
||||||
|
reframes the product's core value in the product's own UI.
|
||||||
|
|
||||||
|
**4. Automation tab.** `triple-c-scheduler` ships in every container with
|
||||||
|
add/list/logs/notifications — and its only UI is a CLAUDE.md paragraph telling Claude to
|
||||||
|
run it. Wrap it: task list (name, cron, last run, enabled), toggle/run-now/view-log, and a
|
||||||
|
notification badge on the project row. "Your nightly agent left you a note" is a reason to
|
||||||
|
open the app in the morning. Fleet-of-scheduled-agents management across projects is
|
||||||
|
something the Claude Code TUI does not offer.
|
||||||
|
|
||||||
|
**Explicitly skip:** status line builder, output-styles editor, hook *editors* (surface the
|
||||||
|
count, deep-link to the terminal), checkpoint browser, marketplace browser. Each is niche,
|
||||||
|
natively handled, or a settings-editor trap.
|
||||||
|
|
||||||
|
### B4. Coherence test
|
||||||
|
|
||||||
|
Every screen answers exactly one question:
|
||||||
|
|
||||||
|
| Screen | Question |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar | What projects exist and are they up? |
|
||||||
|
| Project Home | What can this sandbox do, and where did I leave off? |
|
||||||
|
| Terminal | Do the work. |
|
||||||
|
| Library | What capabilities do I reuse? |
|
||||||
|
| Settings | How does the host behave? |
|
||||||
|
|
||||||
|
Anything that doesn't answer one of those doesn't get a nav slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorities
|
||||||
|
|
||||||
|
### Tier 1 — high impact, cheap
|
||||||
|
|
||||||
|
1. `:focus-visible` ring and stop stripping outlines (one CSS rule + token). Add
|
||||||
|
`Ctrl+T` / `Ctrl+W` / `Ctrl+1..9` / `Ctrl+Tab`.
|
||||||
|
2. Contrast: `--accent-emphasis: #1f6feb` for filled buttons; kill white-on-`#3fb950`;
|
||||||
|
`--text-disabled` instead of `opacity-50`.
|
||||||
|
3. Real buttons for project actions; Remove into an overflow menu; primary action filled.
|
||||||
|
4. Inline start/stop progress and an error toast; delete `ContainerProgressModal`.
|
||||||
|
5. Status dots get labels or shapes; Docker-down turns red; null state pulses.
|
||||||
|
6. Welcome screen becomes an onboarding checklist with a real button, plus the logo.
|
||||||
|
7. One shared `<Modal>` with focus trap and ARIA for the modals that remain.
|
||||||
|
8. Permission-mode segmented control replacing the boolean.
|
||||||
|
9. `lucide-react` icons; move the tab strip onto the terminal panel.
|
||||||
|
|
||||||
|
### Tier 2 — high impact, expensive
|
||||||
|
|
||||||
|
1. **Project Home tabbed view** — the structural fix that dissolves the modal pile and the
|
||||||
|
1,257-line ProjectCard. The forms already exist; this is mostly moving and splitting.
|
||||||
|
2. **Sessions tab** with `claude --resume`.
|
||||||
|
3. **Library** — generalize global→per-project sync to skills/agents/commands.
|
||||||
|
4. **Automation tab** wrapping `triple-c-scheduler`, with notification badges.
|
||||||
|
|
||||||
|
### Tier 3 — skip
|
||||||
|
|
||||||
|
- Light theme (dark-only is right; tokens keep the door open).
|
||||||
|
- Editors for hooks, statusline, output styles; checkpoint browser; marketplace browser.
|
||||||
|
- Any new global sidebar tab beyond Library.
|
||||||
|
- Rebuilding MCP management in any form. Let the deletion be a lesson, not a vacancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**One sentence:** promote the project from a sidebar card to a first-class workspace view,
|
||||||
|
use the volume you already own to surface sessions/capabilities/automation instead of
|
||||||
|
building config editors, and spend a focused week on focus rings, contrast, and button
|
||||||
|
affordances — the visual layer needs sanding, not redesign.
|
||||||
+604
-194
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
# Mission Control Setup Instructions
|
||||||
|
|
||||||
|
Reference document for adding Flight Control methodology to any project.
|
||||||
|
|
||||||
|
## How Triple-C Installs Mission Control
|
||||||
|
|
||||||
|
When Mission Control is enabled for a project in Triple-C:
|
||||||
|
|
||||||
|
1. **Bundled files install**: The Mission Control files bundled with Triple-C are copied to `/home/claude/mission-control/` (persisted in the config volume)
|
||||||
|
2. **Skills install**: All skills from the bundled `.claude/skills/` are copied to `~/.claude/skills/` so Claude Code discovers them automatically as `/slash-commands`
|
||||||
|
3. **Workspace symlink**: `/workspace/mission-control/` symlinks to the installed copy for methodology doc access
|
||||||
|
4. **Global instructions**: Mission Control usage instructions are injected into `~/.claude/CLAUDE.md`
|
||||||
|
|
||||||
|
This happens automatically on every container start, so skill updates from new Triple-C releases are picked up on restart.
|
||||||
|
|
||||||
|
## Two pieces are needed per project:
|
||||||
|
|
||||||
|
1. **Global CLAUDE.md** — Handled automatically by Triple-C when Mission Control is enabled
|
||||||
|
2. **Project CLAUDE.md** — Add the Flight Operations section to each project's `CLAUDE.md`
|
||||||
|
|
||||||
|
Then run `/init-project` to create the `.flightops/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Global CLAUDE.md Instructions
|
||||||
|
|
||||||
|
These are **automatically injected by Triple-C** when Mission Control is enabled. For reference, the injected content is:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Mission Control
|
||||||
|
|
||||||
|
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
|
||||||
|
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
|
||||||
|
- The methodology docs and project registry live in `/workspace/mission-control/`
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
|
||||||
|
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
|
||||||
|
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
|
||||||
|
3. Use Mission Control skills for planning and execution
|
||||||
|
|
||||||
|
### Available Skills
|
||||||
|
|
||||||
|
| Skill | When to Use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/init-project` | Setting up a new project for Flight Control |
|
||||||
|
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
|
||||||
|
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
|
||||||
|
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
|
||||||
|
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
|
||||||
|
| `/flight-debrief` | Post-flight analysis after a flight lands |
|
||||||
|
| `/mission-debrief` | Post-mission retrospective after completion |
|
||||||
|
| `/daily-briefing` | Cross-project status report |
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
|
||||||
|
- **Planning skills produce artifacts only** — never modify source code directly
|
||||||
|
- **Phase gates require human confirmation** — missions before flights, flights before legs
|
||||||
|
- **Legs are immutable once in-flight** — create new ones instead of modifying
|
||||||
|
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
|
||||||
|
- **Artifacts live in the target project** — not in mission-control
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project CLAUDE.md Instructions
|
||||||
|
|
||||||
|
Add this section to each project's `CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Flight Operations
|
||||||
|
|
||||||
|
This project uses Flight Control (bundled with Triple-C) for structured development.
|
||||||
|
|
||||||
|
**Before any mission/flight/leg work, read these files in order:**
|
||||||
|
1. `.flightops/README.md` — What the flightops directory contains
|
||||||
|
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
|
||||||
|
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||||
|
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Initialize the Project
|
||||||
|
|
||||||
|
After adding the CLAUDE.md sections, run `/init-project` from mission-control to:
|
||||||
|
|
||||||
|
1. Create the `.flightops/` directory with methodology references
|
||||||
|
2. Configure the artifact system (files or Jira)
|
||||||
|
3. Set up agent crew definitions
|
||||||
|
4. Register the project in `/workspace/mission-control/projects.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Checklist for New Projects
|
||||||
|
|
||||||
|
- [ ] Enable Mission Control for the project in Triple-C (auto-installs skills to `~/.claude/skills/`)
|
||||||
|
- [ ] Add Flight Operations section to the project's `CLAUDE.md`
|
||||||
|
- [ ] Run `/init-project` from mission-control
|
||||||
|
- [ ] Add the project to `/workspace/mission-control/projects.md`
|
||||||
|
- [ ] Add `.flightops/` to the project's `.gitignore` (if artifacts should not be committed) or commit it (if they should)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Triple-C (Claude-Code-Container)
|
# Triple-C (Claude-Code-Container)
|
||||||
|
|
||||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
|
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -13,46 +13,165 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ TopBar (terminal tabs + Docker/Image status) │
|
│ TopBar (MainTabs strip + Docker/Image status + ?) │
|
||||||
├────────────┬────────────────────────────────────────┤
|
├────────────┬────────────────────────────────────────┤
|
||||||
│ Sidebar │ Main Content (terminal views) │
|
│ Sidebar │ Main Content │
|
||||||
│ (25% w, │ │
|
│ (25% w, │ · Project Home views, or │
|
||||||
│ responsive│ │
|
│ responsive│ · terminal views (xterm.js) │
|
||||||
│ min/max) │ │
|
│ min/max) │ │
|
||||||
├────────────┴────────────────────────────────────────┤
|
├────────────┴────────────────────────────────────────┤
|
||||||
│ StatusBar (project/terminal counts) │
|
│ StatusBar (project/terminal counts, STT, scroll) │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The main area is driven by **one ordered tab strip** (`components/layout/MainTabs.tsx`) holding
|
||||||
|
two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a terminal). There is no
|
||||||
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
|
thing is current at a time.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl+T` | New Claude terminal for the current project (no-op unless it is running) |
|
||||||
|
| `Ctrl+Shift+W` | Close the active tab |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
|
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||||
|
|
||||||
|
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||||
|
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||||
|
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||||
|
|
||||||
|
### Project Home
|
||||||
|
|
||||||
|
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||||
|
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
||||||
|
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
||||||
|
Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
|
| Tab | Contents |
|
||||||
|
|---|---|
|
||||||
|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
||||||
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
|
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
||||||
|
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||||
|
| **Files** | Browse, download and upload files inside the container |
|
||||||
|
|
||||||
|
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||||
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
|
progress modal.
|
||||||
|
|
||||||
|
### Permission Modes
|
||||||
|
|
||||||
|
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||||
|
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||||
|
|
||||||
|
| Mode | Serialized | CLI args passed to `claude` |
|
||||||
|
|---|---|---|
|
||||||
|
| **Plan** | `plan` | `--permission-mode plan` |
|
||||||
|
| **Default** | `default` | *(none)* |
|
||||||
|
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
|
||||||
|
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
|
||||||
|
|
||||||
|
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
|
||||||
|
the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing
|
||||||
|
the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it
|
||||||
|
was launched with.
|
||||||
|
|
||||||
|
Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via
|
||||||
|
`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint
|
||||||
|
snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it
|
||||||
|
back into flags for its headless `claude -p` run. Because it travels as container env, a mode change
|
||||||
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
|
forces that).
|
||||||
|
|
||||||
|
### Container Introspection (Capability Tiles)
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
|
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||||
|
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||||
|
|
||||||
|
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||||
|
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
|
||||||
|
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
|
||||||
|
browser's redirect to reach it. `auth_bridge/` closes that gap:
|
||||||
|
|
||||||
|
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
|
||||||
|
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
|
||||||
|
binds are deliberately ignored (that is the port-mappings feature's job).
|
||||||
|
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
|
||||||
|
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
|
||||||
|
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
|
||||||
|
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
|
||||||
|
IPs are not routable from the host on Docker Desktop.
|
||||||
|
- Ports already covered by the project's port mappings are skipped, and a host port that is already
|
||||||
|
in use is reported as a conflict rather than fought over.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
|
||||||
|
recreates the container. The poller stops on its own when the container stops.
|
||||||
|
|
||||||
|
**Security posture:** the host side binds loopback only. Everything reachable through it is an
|
||||||
|
unauthenticated service inside the container, so widening those addresses would publish container
|
||||||
|
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||||
|
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||||
|
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||||
|
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||||
|
anything resembling an `sk-ant-` secret.
|
||||||
|
|
||||||
|
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||||
|
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||||
|
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||||
|
|
||||||
|
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||||
|
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||||
|
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||||
|
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||||
|
it cleared.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
||||||
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
|
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||||
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
|
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||||
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||||
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||||
|
|
||||||
### Mounts
|
### Mounts
|
||||||
|
|
||||||
| Target in Container | Source | Type | Notes |
|
| Target in Container | Source | Type | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/workspace` | Project directory | Bind | Read-write |
|
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
|
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||||
|
removal, and by nothing else.
|
||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project can independently use one of:
|
Each project can independently use one of:
|
||||||
|
|
||||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
|
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||||
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
|
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||||
|
|
||||||
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
@@ -60,30 +179,34 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
|
|||||||
|
|
||||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
### MCP Server Architecture
|
|
||||||
|
|
||||||
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
|
|
||||||
|
|
||||||
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
|
|
||||||
|
|
||||||
| Mode | Where It Runs | How It Communicates |
|
|
||||||
|------|--------------|---------------------|
|
|
||||||
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
|
|
||||||
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
|
|
||||||
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
|
|
||||||
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
|
|
||||||
|
|
||||||
**Key behaviors**:
|
|
||||||
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
|
|
||||||
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
|
|
||||||
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
|
|
||||||
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
|
|
||||||
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
|
|
||||||
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
|
|
||||||
|
|
||||||
### Mission Control Integration
|
### Mission Control Integration
|
||||||
|
|
||||||
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
Optional per-project integration with Flight Control — an AI-first development methodology bundled with Triple-C. When enabled, the bundled files are installed into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
||||||
|
|
||||||
|
### Web Terminal (Remote Access)
|
||||||
|
|
||||||
|
Triple-C includes an optional web terminal server for accessing project terminals from tablets, phones, or other devices on the local network. When enabled in Settings, an axum HTTP+WebSocket server starts inside the Tauri process, serving a standalone xterm.js-based terminal UI.
|
||||||
|
|
||||||
|
- **URL**: `http://<LAN_IP>:7681?token=...` (port configurable)
|
||||||
|
- **Authentication**: Token-based (auto-generated, copyable from Settings)
|
||||||
|
- **Protocol**: JSON over WebSocket with base64-encoded terminal data
|
||||||
|
- **Features**: Project picker, multiple tabs (Claude + bash sessions), mobile-optimized input bar, scroll-to-bottom button
|
||||||
|
- **Session cleanup**: All terminal sessions are closed when the browser disconnects
|
||||||
|
|
||||||
|
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
|
||||||
|
|
||||||
|
### Speech-to-Text (Voice Mode)
|
||||||
|
|
||||||
|
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active.
|
||||||
|
|
||||||
|
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||||
|
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||||
|
- **Port**: Default `9876` (configurable)
|
||||||
|
- **Language**: Optional language hint for transcription
|
||||||
|
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||||
|
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||||
|
|
||||||
|
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||||
|
|
||||||
### Docker Socket Path
|
### Docker Socket Path
|
||||||
|
|
||||||
@@ -97,39 +220,67 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||||
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
|
||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||||
|
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||||
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
|
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
||||||
|
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||||
|
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||||
|
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||||
|
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||||
|
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||||
|
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
||||||
|
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
||||||
|
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||||
|
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||||
|
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||||
|
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||||
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
|
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
|
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
|
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
|
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||||
|
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||||
|
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||||
|
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||||
|
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||||
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, MCP servers, Mission Control) |
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
||||||
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
|
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
|
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||||
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||||
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
|
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||||
|
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
||||||
|
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||||
|
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||||
|
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
|
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||||
|
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||||
|
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||||
|
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
||||||
|
|
||||||
## CSS / Styling Notes
|
## CSS / Styling Notes
|
||||||
|
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
# Triple-C Roadmap — Claude Code Feature Parity
|
||||||
|
|
||||||
|
**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226
|
||||||
|
|
||||||
|
Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and
|
||||||
|
information architecture. This document covers *which Claude Code capabilities Triple-C
|
||||||
|
should surface, and why.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding principle
|
||||||
|
|
||||||
|
> **Triple-C shows state and launches things. Claude Code edits its own config.**
|
||||||
|
|
||||||
|
Triple-C's built-in MCP server management was removed in this cycle because Claude Code
|
||||||
|
absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`).
|
||||||
|
Hooks, skills, agents, plugins, output styles, and statusline are the same species: files
|
||||||
|
under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them
|
||||||
|
means losing the same race again.
|
||||||
|
|
||||||
|
What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and
|
||||||
|
what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled
|
||||||
|
scheduler, and the fleet view across many projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current coverage (v0.3.0)
|
||||||
|
|
||||||
|
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
|
||||||
|
|
||||||
|
| Key | Surfaced as |
|
||||||
|
|---|---|
|
||||||
|
| `tui` | TUI Mode select (`fullscreen`) |
|
||||||
|
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
||||||
|
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
||||||
|
| `focusMode` | Focus Mode toggle |
|
||||||
|
| `showThinkingSummaries` | Thinking Summaries toggle |
|
||||||
|
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||||
|
|
||||||
|
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||||
|
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||||
|
`CLAUDE_CODE_*` vars via the Env Vars modal.
|
||||||
|
|
||||||
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
|
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
|
||||||
|
file drag-drop, STT), the web terminal, and workspace backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap analysis
|
||||||
|
|
||||||
|
### Committed for this cycle
|
||||||
|
|
||||||
|
| # | Gap | Today | Plan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. |
|
||||||
|
| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume <id>`. |
|
||||||
|
| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. |
|
||||||
|
| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. |
|
||||||
|
| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. |
|
||||||
|
|
||||||
|
### Deliberately skipped
|
||||||
|
|
||||||
|
Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser ·
|
||||||
|
plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a
|
||||||
|
settings-editor trap. Surface counts and deep-link instead.
|
||||||
|
|
||||||
|
### Not yet scheduled
|
||||||
|
|
||||||
|
- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories`
|
||||||
|
- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`,
|
||||||
|
`excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS`
|
||||||
|
- Project-level `.claude/settings.json` vs user-level settings hierarchy
|
||||||
|
- A model picker. **Note:** the only model strings in the app today are stale placeholders
|
||||||
|
(`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`,
|
||||||
|
`qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not
|
||||||
|
dropdowns, but they should be refreshed to current model identifiers regardless.
|
||||||
|
- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a
|
||||||
|
user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale
|
||||||
|
on every container start. Worth deepening to `*` recursive merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication handoff
|
||||||
|
|
||||||
|
**Goal:** stop making users hand-copy an auth code into every container.
|
||||||
|
|
||||||
|
**Constraint discovered during research:** `claude login`'s callback server uses an
|
||||||
|
**ephemeral port** and its redirect URI is **not configurable** for the main login flow
|
||||||
|
(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design
|
||||||
|
that pre-assigns each container a fixed callback port and routes to it cannot work as
|
||||||
|
stated — there is no fixed port to route.
|
||||||
|
|
||||||
|
There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first,
|
||||||
|
so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
|
||||||
|
Two viable options:
|
||||||
|
|
||||||
|
### Option A — long-lived token injection (simple)
|
||||||
|
|
||||||
|
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
|
||||||
|
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
|
||||||
|
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
|
||||||
|
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
|
||||||
|
|
||||||
|
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
|
||||||
|
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
|
||||||
|
under a pty: its `redirect_uri` is Anthropic-hosted
|
||||||
|
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
|
||||||
|
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
|
||||||
|
is therefore mandatory — the flow cannot complete without one.
|
||||||
|
|
||||||
|
- No routing, no ports, no proxy.
|
||||||
|
- One auth event covers every project.
|
||||||
|
- Cost: small. Reuses existing keychain and env-injection plumbing.
|
||||||
|
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
|
||||||
|
token cannot drive Remote Control sessions or claude.ai connector fetches.
|
||||||
|
|
||||||
|
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
|
||||||
|
label, not a hash of the token. Labels are readable by anything that can run
|
||||||
|
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
|
||||||
|
token you could confirm it. A presence boolean would instead miss rotations and silently
|
||||||
|
leave containers on a stale token.
|
||||||
|
|
||||||
|
### Option B — the Auth Bridge (general loopback-callback bridge)
|
||||||
|
|
||||||
|
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
|
||||||
|
starting a temporary loopback listener and opening a browser at a URL that redirects back
|
||||||
|
to it — Concourse `fly login` (random loopback port serving `/auth/callback`),
|
||||||
|
`aws sso login`, and many others. Inside a container the host browser cannot reach that
|
||||||
|
listener, so login stalls.
|
||||||
|
|
||||||
|
Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge
|
||||||
|
**discovers** listeners instead:
|
||||||
|
|
||||||
|
1. While enabled for a running project, poll the container for loopback TCP listeners by
|
||||||
|
reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on
|
||||||
|
`ss`/`netstat`/`lsof`, which aren't guaranteed in the image.
|
||||||
|
2. For each newly-appeared loopback listener, bind **the same port on the host's
|
||||||
|
`127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN).
|
||||||
|
3. Proxy each accepted connection into the container over the Docker API via
|
||||||
|
`socat - TCP:127.0.0.1:<port>` (socat already ships in the image), reusing the existing
|
||||||
|
attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a
|
||||||
|
container IP keeps this working on Docker Desktop, where container IPs are not routable
|
||||||
|
from the host.
|
||||||
|
4. Fall back to `TCP6:[::1]:<port>` when the listener appeared only on IPv6 — on Linux,
|
||||||
|
Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only
|
||||||
|
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
|
||||||
|
5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or
|
||||||
|
the app exits. Ports already covered by the project's explicit port mappings are skipped;
|
||||||
|
host-side conflicts are reported rather than silently swallowed.
|
||||||
|
|
||||||
|
Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal
|
||||||
|
loopback services reachable from the host.
|
||||||
|
|
||||||
|
**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case
|
||||||
|
at a fraction of the cost — and **B** as the general mechanism covering every other CLI.
|
||||||
|
They compose: A means most users never trigger a browser login at all; B catches AWS SSO,
|
||||||
|
Concourse, and anything else that needs a real callback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing
|
||||||
|
migration for containers created against the old per-project Docker network.
|
||||||
|
|
||||||
|
**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix
|
||||||
|
below). Read-only introspection backend: sessions, capabilities, scheduler.
|
||||||
|
|
||||||
|
**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop
|
||||||
|
progress, status labels, onboarding welcome screen, shared accessible `<Modal>`.
|
||||||
|
|
||||||
|
**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed
|
||||||
|
main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and
|
||||||
|
splitting the 1,257-line `ProjectCard`.
|
||||||
|
|
||||||
|
**Phase 4 — authentication handoff.** Option A, then evaluate B.
|
||||||
|
|
||||||
|
**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into
|
||||||
|
the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs found during this review
|
||||||
|
|
||||||
|
1. **Scheduled tasks ignore the project's permission setting.**
|
||||||
|
`container/triple-c-task-runner:69` runs
|
||||||
|
`claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the
|
||||||
|
project's Full Permissions toggle. Being fixed as part of Phase 1.
|
||||||
|
|
||||||
|
2. **Docs claim Reset preserves credentials; it does not.**
|
||||||
|
`rebuild_project_container` calls `remove_project_volumes`, which deletes both
|
||||||
|
`triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}`
|
||||||
|
(holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that
|
||||||
|
OAuth tokens survive a Reset. Pre-existing; not yet corrected.
|
||||||
|
|
||||||
|
3. **An invalid cron expression silently unscheduled every task.** Found while adding
|
||||||
|
task creation to the Automation tab, and the most serious bug in this review.
|
||||||
|
`triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the
|
||||||
|
*entire* crontab and pipes it to `crontab`, which rejects the whole file if any single
|
||||||
|
line is malformed — with the error discarded by `2>/dev/null || true`. So one bad
|
||||||
|
schedule silently unscheduled every other task in the container, reporting success.
|
||||||
|
Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use
|
||||||
|
this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the
|
||||||
|
expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead
|
||||||
|
of swallowing it. The Rust `add_scheduled_task` command validates independently.
|
||||||
|
|
||||||
|
4. **Reset was destructive with no confirmation.** It deletes both volumes — the login,
|
||||||
|
installed skills, all session transcripts — from a single unconfirmed click, while the
|
||||||
|
comparably destructive Remove already confirmed. Now gated by a dialog that names each
|
||||||
|
loss. Fixed.
|
||||||
|
|
||||||
|
5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above.
|
||||||
|
|
||||||
|
6. **Stale model placeholders** — see "Not yet scheduled" above.
|
||||||
|
|
||||||
|
7. **Silent save failures.** Project config saves on blur; failures went only to
|
||||||
|
`console.error`. Fixed in Phase 3 — `useProjectSave` now renders a
|
||||||
|
Saved / Saving / Save failed indicator and raises a toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps left by Phase 2–3
|
||||||
|
|
||||||
|
- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit`
|
||||||
|
subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is
|
||||||
|
implemented as add-then-remove. The add runs first, so a rejected edit leaves the original
|
||||||
|
intact. The task gets a new id and its older logs stay under the old one; the editor says
|
||||||
|
so before saving.
|
||||||
|
- **`open_terminal_session` takes no command argument.** "Resume session" and
|
||||||
|
"Manage in terminal" therefore open a bash tab and *type* the command after a
|
||||||
|
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
|
||||||
|
slow container start. The fix is a `command: Option<String>` parameter on the
|
||||||
|
Tauri command so the exec launches the process directly.
|
||||||
|
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
|
||||||
|
with no start time, so Project Home records "running since" when the app *sees*
|
||||||
|
the transition. A container already running when the app launches shows
|
||||||
|
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
|
||||||
|
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
|
||||||
|
access in the build environment used for this cycle. The existing inline SVGs and
|
||||||
|
text glyphs remain.
|
||||||
|
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
|
||||||
|
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
|
||||||
|
the tabs in the TopBar; the diagram won. Worth revisiting.
|
||||||
|
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
|
||||||
|
`kill-word`, used constantly inside the terminal this app is built around;
|
||||||
|
intercepting it globally would break word-erase in every shell.
|
||||||
+263
-44
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with `--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode — `--dangerously-skip-permissions` — Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,6 +57,16 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
||||||
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
||||||
|
|
||||||
|
#### Terminal Layout & StatusBar Controls
|
||||||
|
|
||||||
|
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||||
|
|
||||||
|
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||||
|
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||||
|
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||||
|
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||||
|
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||||
|
|
||||||
### bollard (Docker API)
|
### bollard (Docker API)
|
||||||
|
|
||||||
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
||||||
@@ -100,16 +110,21 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
│ │ Project Management │◄─┤ ProjectsStore │ │
|
│ │ Project Management │◄─┤ ProjectsStore │ │
|
||||||
│ │ Settings UI │ │ bollard Docker Client │ │
|
│ │ Settings UI │ │ bollard Docker Client │ │
|
||||||
│ │ │ │ keyring Credential Mgr │ │
|
│ │ │ │ keyring Credential Mgr │ │
|
||||||
│ └───────────┬───────────┘ └────────────┬─────────────┘ │
|
│ └───────────┬───────────┘ │ Web Terminal Server │ │
|
||||||
|
│ │ └────────────┬─────────────┘ │
|
||||||
│ │ Tauri IPC (invoke/emit) │ │
|
│ │ Tauri IPC (invoke/emit) │ │
|
||||||
│ └───────────┬───────────────┘ │
|
│ └───────────┬───────────────┘ │
|
||||||
|
│ ▲ │
|
||||||
|
│ axum HTTP+WS│(port 7681) │
|
||||||
|
│ │ │
|
||||||
└──────────────────────────┼───────────────────────────────┘
|
└──────────────────────────┼───────────────────────────────┘
|
||||||
│ Docker Socket
|
│ Docker Socket
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ Docker Container (per project) │
|
│ Docker Container (per project) │
|
||||||
│ │
|
│ │
|
||||||
│ /workspace ←── bind mount ──► Host project directory │
|
│ /workspace/<name> ←─ bind mount ─► Host project folder │
|
||||||
|
│ /home/claude ←── named volume (home dir) │
|
||||||
│ /home/claude/.claude ←── named volume (persists config) │
|
│ /home/claude/.claude ←── named volume (persists config) │
|
||||||
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
|
||||||
│ /var/run/docker.sock ←── optional (sibling containers) │
|
│ /var/run/docker.sock ←── optional (sibling containers) │
|
||||||
@@ -129,6 +144,8 @@ The application uses two IPC mechanisms between the React frontend and Rust back
|
|||||||
|
|
||||||
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
|
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
|
||||||
|
|
||||||
|
**WebSocket Streaming** (Web Terminal): Used for remote terminal access from browsers on the local network. An axum HTTP+WebSocket server runs inside the Tauri process, sharing the same `ExecSessionManager` via `Arc`-wrapped stores. The WebSocket uses a JSON protocol with base64-encoded terminal data. Each browser connection can open multiple terminal sessions; all sessions are cleaned up when the WebSocket disconnects.
|
||||||
|
|
||||||
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
|
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
|
||||||
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
|
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
|
||||||
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
|
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
|
||||||
@@ -144,22 +161,143 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
|
|||||||
|
|
||||||
Containers follow a **stop/start** model, not create/destroy:
|
Containers follow a **stop/start** model, not create/destroy:
|
||||||
|
|
||||||
1. **First start**: A new container is created with bind mounts, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive.
|
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
|
||||||
2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
|
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
|
||||||
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
||||||
4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist.
|
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
|
||||||
5. **Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted.
|
5. **Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image.
|
||||||
|
|
||||||
The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets.
|
Two named volumes exist per project and they are the only ones it owns:
|
||||||
|
|
||||||
|
| Volume | Mount point | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `triple-c-home-{projectId}` | `/home/claude` | Home directory — `~/.claude.json`, `~/.local`, `~/.ssh`, `~/.aws` |
|
||||||
|
| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. |
|
||||||
|
|
||||||
|
`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from
|
||||||
|
exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal
|
||||||
|
passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project
|
||||||
|
removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills,
|
||||||
|
session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or
|
||||||
|
host keychain secrets.
|
||||||
|
|
||||||
|
### Permission Modes
|
||||||
|
|
||||||
|
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
|
||||||
|
boolean. It reaches Claude Code by two different routes:
|
||||||
|
|
||||||
|
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
|
||||||
|
|---|---|---|
|
||||||
|
| `Plan` | `--permission-mode plan` | `plan` |
|
||||||
|
| `Default` | *(no flag)* | `default` |
|
||||||
|
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
|
||||||
|
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
|
||||||
|
|
||||||
|
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
|
||||||
|
`None` from the legacy `full_permissions` flag, so records written before the change keep behaving
|
||||||
|
the same way.
|
||||||
|
|
||||||
|
**Interactive path.** `build_terminal_cmd()` evaluates `cli_args()` when a session is created, so
|
||||||
|
the flags are fixed for the life of that `claude` process. Changing the mode affects terminals
|
||||||
|
opened afterwards, not running ones. The same applies to `resume_session_command`, which builds
|
||||||
|
`claude <flags> --resume <id>` server-side.
|
||||||
|
|
||||||
|
**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as
|
||||||
|
`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted
|
||||||
|
variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the
|
||||||
|
value back to flags for its `claude -p` run. Container env can only change at create time, so
|
||||||
|
`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation
|
||||||
|
on the next start. A mode change therefore reaches new terminals immediately but the scheduler only
|
||||||
|
after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set.
|
||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project independently chooses one of two authentication methods:
|
Each project independently chooses one backend:
|
||||||
|
|
||||||
| Mode | How It Works | When to Use |
|
| Backend | How It Works | When to Use |
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
|
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
|
||||||
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
|
||||||
|
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to a placeholder. | Local models (best-effort) |
|
||||||
|
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, …). | Gateways and proxies (best-effort) |
|
||||||
|
|
||||||
|
### Shared Claude Authentication Token
|
||||||
|
|
||||||
|
`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container.
|
||||||
|
Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted
|
||||||
|
page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back
|
||||||
|
in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two
|
||||||
|
concurrent logins would race to overwrite each other's keychain entry) and times out after 15
|
||||||
|
minutes.
|
||||||
|
|
||||||
|
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||||
|
frontend, never written to a log, and no command accepts or returns it.
|
||||||
|
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||||
|
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||||
|
could still grow into a secret across a chunk boundary.
|
||||||
|
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||||
|
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||||
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
|
value baked into a snapshot image by `docker commit` is actively cleared.
|
||||||
|
- **Rotation** — a random UUID minted on each store is mirrored into the
|
||||||
|
`triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are
|
||||||
|
readable by anything that can run `docker inspect`, and a hash would be an offline verification
|
||||||
|
oracle. A label mismatch forces container recreation on the next start, which is when a container
|
||||||
|
picks up or loses the token.
|
||||||
|
|
||||||
|
### Auth Bridge
|
||||||
|
|
||||||
|
CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral
|
||||||
|
HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:<port>/…`
|
||||||
|
redirect. Run inside a container, that listener is unreachable from the host browser and nothing can
|
||||||
|
be pre-published at container-creation time. `auth_bridge/` bridges it at runtime:
|
||||||
|
|
||||||
|
- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every
|
||||||
|
two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`)
|
||||||
|
bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the
|
||||||
|
port-mappings feature's job.
|
||||||
|
- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node
|
||||||
|
resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The
|
||||||
|
socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are
|
||||||
|
treated as IPv4.
|
||||||
|
- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required,
|
||||||
|
`[::1]` is best-effort. **The host side binds loopback only, never a wildcard address** —
|
||||||
|
everything behind it is an unauthenticated in-container service that bound loopback precisely
|
||||||
|
because it expected to be unreachable.
|
||||||
|
- **Transport** — each accepted connection is proxied by an attached exec running
|
||||||
|
`socat - TCP:127.0.0.1:<port>`, because container IPs are not routable from the host under Docker
|
||||||
|
Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with
|
||||||
|
`tty: false` so socat's stderr is demultiplexed away from the proxied byte stream.
|
||||||
|
- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure
|
||||||
|
is recorded as a conflict and retried later rather than fought over.
|
||||||
|
- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely
|
||||||
|
host-side, so it deliberately has no container-recreation label. The poller stops itself when the
|
||||||
|
project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits
|
||||||
|
it so host ports are provably released.
|
||||||
|
|
||||||
|
### Container Introspection
|
||||||
|
|
||||||
|
`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in
|
||||||
|
a running container and returns counts and item lists for skills, agents, commands, hooks, plugins
|
||||||
|
and MCP servers, across user scope (`/home/claude/.claude`) and project scope
|
||||||
|
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with
|
||||||
|
`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros
|
||||||
|
rather than an error.
|
||||||
|
|
||||||
|
The script writes nothing. Claude Code owns this configuration and has its own tooling for it
|
||||||
|
(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than
|
||||||
|
rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands
|
||||||
|
(`list_scheduled_tasks`, `get_scheduled_task_log`, `set_scheduled_task_enabled`,
|
||||||
|
`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same
|
||||||
|
module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files.
|
||||||
|
|
||||||
|
### Main-Area Tab Model
|
||||||
|
|
||||||
|
The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds,
|
||||||
|
`home:<projectId>` and `term:<sessionId>`, rendered by `components/layout/MainTabs.tsx`.
|
||||||
|
`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project
|
||||||
|
Home tab and a terminal cannot both claim focus. Project configuration is a main-area view
|
||||||
|
(`components/projects/home/`), not a modal; the sidebar row is select-only.
|
||||||
|
|
||||||
### UID/GID Remapping
|
### UID/GID Remapping
|
||||||
|
|
||||||
@@ -189,10 +327,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
|
|||||||
| Data | Storage | Location |
|
| Data | Storage | Location |
|
||||||
|------|---------|----------|
|
|------|---------|----------|
|
||||||
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
|
||||||
| API keys | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
| API keys and per-project secrets | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
|
||||||
|
| Shared Claude token + rotation id | OS keychain | Separate service entries; never on disk, never in a label |
|
||||||
| App settings | Tauri plugin-store | App data directory |
|
| App settings | Tauri plugin-store | App data directory |
|
||||||
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
|
||||||
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
|
| Container home directory | Named Docker volume | `triple-c-home-{projectId}` |
|
||||||
|
| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset |
|
||||||
|
|
||||||
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
|
||||||
|
|
||||||
@@ -214,92 +354,159 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
|||||||
triple-c/
|
triple-c/
|
||||||
├── README.md # Architecture overview
|
├── README.md # Architecture overview
|
||||||
├── TECHNICAL.md # This document
|
├── TECHNICAL.md # This document
|
||||||
├── HOW-TO-USE.md # User guide
|
├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog)
|
||||||
├── BUILDING.md # Build instructions
|
├── BUILDING.md # Build instructions
|
||||||
├── CLAUDE.md # Claude Code instructions
|
├── CLAUDE.md # Claude Code instructions
|
||||||
|
├── DESIGN-REVIEW.md # UI/UX review notes
|
||||||
|
├── ROADMAP.md # Planned work
|
||||||
│
|
│
|
||||||
├── container/
|
├── container/ # Sandbox image
|
||||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||||
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
|
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection,
|
||||||
|
│ │ # scheduler env snapshot + crontab rebuild
|
||||||
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
||||||
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
||||||
│ ├── triple-c-scheduler # Bash-based cron task system
|
│ ├── triple-c-scheduler # Bash-based cron task system
|
||||||
│ └── triple-c-task-runner # Task execution runner for scheduler
|
│ ├── triple-c-task-runner # Cron entry point; permission mode → flags → `claude -p`
|
||||||
|
│ ├── triple-c-sso-refresh # AWS SSO session refresh helper
|
||||||
|
│ └── mission-control/ # Bundled Flight Control methodology (skills, docs, templates)
|
||||||
|
│
|
||||||
|
├── stt-container/ # Speech-to-text image
|
||||||
|
│ ├── Dockerfile # Faster Whisper (Python 3.11 + FastAPI)
|
||||||
|
│ └── server.py # POST /transcribe endpoint
|
||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||||
|
│ ├── build-app-preview.yml # Preview builds
|
||||||
│ ├── build.yml # Build container image (multi-arch)
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||||
│ └── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
|
│ └── cleanup-releases.yml # Prune old releases
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
├── vite.config.ts # Vite bundler config
|
├── vite.config.ts # Vite bundler config
|
||||||
|
├── vitest.config.ts # Vitest (jsdom) config
|
||||||
├── index.html # HTML entry point
|
├── index.html # HTML entry point
|
||||||
│
|
│
|
||||||
├── src/ # React frontend
|
├── src/ # React frontend
|
||||||
│ ├── main.tsx # React DOM root
|
│ ├── main.tsx # React DOM root
|
||||||
│ ├── App.tsx # Top-level layout
|
│ ├── App.tsx # Top-level layout + welcome screen
|
||||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
│ ├── index.css # CSS variables, dark theme, focus ring, scrollbars
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
|
│ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts)
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
|
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||||
|
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
│ │ ├── useFileManager.ts # File manager operations
|
│ │ ├── useFileManager.ts # File browser operations
|
||||||
│ │ ├── useMcpServers.ts # MCP server CRUD
|
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||||
|
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||||
|
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||||
│ │ ├── useProjects.ts # Project CRUD operations
|
│ │ ├── useProjects.ts # Project CRUD operations
|
||||||
|
│ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state
|
||||||
│ │ ├── useSettings.ts # App settings
|
│ │ ├── useSettings.ts # App settings
|
||||||
|
│ │ ├── useSTT.ts # Speech-to-text recording and container control
|
||||||
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
||||||
│ │ ├── useUpdates.ts # App update checking
|
│ │ ├── useUpdates.ts # App update checking
|
||||||
│ │ └── useVoice.ts # Voice mode audio capture
|
│ │ └── useVoice.ts # Voice mode audio capture
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||||
|
│ │ ├── urlDetector.ts # Long-URL reassembly for OAuth flows
|
||||||
|
│ │ ├── wav.ts # WAV encoding for STT
|
||||||
│ │ └── constants.ts # App-wide constants
|
│ │ └── constants.ts # App-wide constants
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
│ ├── DockerInstallDialog.tsx # First-run Docker setup
|
||||||
│ ├── mcp/ # McpPanel, McpServerCard
|
│ ├── layout/ # TopBar, MainTabs (the unified tab strip),
|
||||||
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
│ │ # Sidebar, StatusBar, HelpDialog
|
||||||
│ │ # FileManagerModal, ContainerProgressModal, modals
|
│ ├── projects/
|
||||||
|
│ │ ├── home/ # Project Home — the main-area project view
|
||||||
|
│ │ │ ├── ProjectHome.tsx # Header, actions, overflow menu, tab strip
|
||||||
|
│ │ │ ├── OverviewTab.tsx # Permission mode, summary, recent activity
|
||||||
|
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||||
|
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||||
|
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||||
|
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||||
|
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||||
|
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||||
|
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||||
|
│ │ │ # AccessSection, RuntimeSection
|
||||||
|
│ │ ├── ProjectRow.tsx # Select-only sidebar row
|
||||||
|
│ │ ├── ProjectList.tsx # Sidebar project list
|
||||||
|
│ │ ├── AddProjectDialog.tsx # New-project dialog
|
||||||
|
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
|
||||||
|
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
|
||||||
|
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
|
||||||
|
│ │ # ClaudeInstructions, ClaudeCodeSettings —
|
||||||
|
│ │ # editors reused by Project Home
|
||||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||||
│ │ # UpdateDialog
|
│ │ # OllamaSettings, OpenAiCompatibleSettings,
|
||||||
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
│ │ # SharedAuthSettings, ClaudeAuthModal,
|
||||||
|
│ │ # WebTerminalSettings, SttSettings,
|
||||||
|
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
|
||||||
|
│ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu,
|
||||||
|
│ │ # SttButton, UrlToast, trimSelection
|
||||||
|
│ └── ui/ # Shared primitives: Modal, Button, Toggle, Field,
|
||||||
|
│ # SegmentedControl, StatusIndicator, SaveIndicator,
|
||||||
|
│ # OverflowMenu, ToastHost, Tooltip, AccordionSection
|
||||||
│
|
│
|
||||||
└── src-tauri/ # Rust backend
|
└── src-tauri/ # Rust backend
|
||||||
├── Cargo.toml # Rust dependencies
|
├── Cargo.toml # Rust dependencies
|
||||||
├── tauri.conf.json # Tauri app configuration
|
├── tauri.conf.json # Tauri app configuration
|
||||||
|
├── build.rs # Tauri build script
|
||||||
├── capabilities/
|
├── capabilities/
|
||||||
│ └── default.json # Tauri v2 permission grants
|
│ └── default.json # Tauri v2 plugin permission grants
|
||||||
└── src/
|
└── src/
|
||||||
├── lib.rs # App builder, plugin + command registration
|
├── lib.rs # App builder, plugin + command registration
|
||||||
├── main.rs # Entry point
|
├── main.rs # Entry point
|
||||||
├── logging.rs # Log configuration
|
├── logging.rs # Log configuration
|
||||||
├── commands/ # Tauri command handlers
|
├── commands/ # Tauri command handlers
|
||||||
|
│ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge
|
||||||
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
│ ├── file_commands.rs # File manager (list/download/upload)
|
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||||
│ ├── mcp_commands.rs # MCP server CRUD
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── project_commands.rs # Start/stop/rebuild containers
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
│ ├── project_commands.rs # Start/stop/rebuild/backup containers
|
||||||
│ ├── settings_commands.rs # Settings CRUD
|
│ ├── settings_commands.rs # Settings CRUD
|
||||||
|
│ ├── stt_commands.rs # STT start/stop/transcribe
|
||||||
│ ├── terminal_commands.rs # Terminal I/O, resize
|
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||||
│ └── update_commands.rs # App update checking
|
│ ├── update_commands.rs # App update checking
|
||||||
|
│ └── web_terminal_commands.rs # Web terminal start/stop/status
|
||||||
|
├── auth_bridge/ # Host-side loopback callback bridge
|
||||||
|
│ ├── mod.rs # Per-project poller, status, lifecycle
|
||||||
|
│ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering
|
||||||
|
│ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API
|
||||||
|
├── web_terminal/ # Remote terminal access
|
||||||
|
│ ├── mod.rs # Module root
|
||||||
|
│ ├── server.rs # Axum HTTP+WS server lifecycle
|
||||||
|
│ ├── ws_handler.rs # WebSocket connection handler
|
||||||
|
│ └── terminal.html # Embedded xterm.js web UI
|
||||||
|
├── install_helper/ # Docker installation assistance
|
||||||
|
│ ├── mod.rs # Install orchestration
|
||||||
|
│ └── platform.rs # Per-OS install strategies
|
||||||
├── docker/ # Docker API layer
|
├── docker/ # Docker API layer
|
||||||
│ ├── client.rs # bollard singleton connection
|
│ ├── client.rs # bollard singleton connection
|
||||||
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
│ ├── container.rs # Create/start/stop/remove, labels, recreation checks,
|
||||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
│ │ # remove_project_volumes, snapshot commit
|
||||||
|
│ ├── exec.rs # create_attached_exec() — the single attached-exec path
|
||||||
│ ├── image.rs # Build from Dockerfile, pull from registry
|
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||||
│ └── network.rs # Per-project bridge networks for MCP
|
│ ├── stt.rs # Speech-to-text container lifecycle
|
||||||
|
│ └── legacy_cleanup.rs # Migration shim for the removed MCP feature
|
||||||
├── models/ # Data structures
|
├── models/ # Data structures
|
||||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
│ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, …
|
||||||
│ ├── mcp_server.rs # MCP server configuration
|
│ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal)
|
||||||
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
|
||||||
│ ├── container_config.rs # Image name resolution
|
│ ├── container_config.rs # Image name resolution
|
||||||
│ └── update_info.rs # Update metadata
|
│ └── update_info.rs # Update metadata
|
||||||
└── storage/ # Persistence
|
└── storage/ # Persistence
|
||||||
├── projects_store.rs # JSON file with atomic writes
|
├── projects_store.rs # JSON file with atomic writes
|
||||||
├── mcp_store.rs # MCP server persistence
|
|
||||||
├── settings_store.rs # App settings (Tauri plugin-store)
|
├── settings_store.rs # App settings (Tauri plugin-store)
|
||||||
└── secure.rs # OS keychain via keyring
|
└── secure.rs # OS keychain via keyring (secrets, shared token)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -323,6 +530,16 @@ triple-c/
|
|||||||
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
||||||
| `dirs` | 6.x | Cross-platform app data directory paths |
|
| `dirs` | 6.x | Cross-platform app data directory paths |
|
||||||
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
||||||
|
| `log` / `fern` | 0.4 / 0.7 | Date-based file logging |
|
||||||
|
| `include_dir` | 0.7 | Embeds the container build context in the binary |
|
||||||
|
| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads |
|
||||||
|
| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` |
|
||||||
|
| `sha2` | 0.10 | Settings fingerprints |
|
||||||
|
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
|
||||||
|
| `tower-http` | 0.6 | CORS middleware for web terminal |
|
||||||
|
| `base64` | 0.22 | Terminal data encoding over WebSocket |
|
||||||
|
| `rand` | 0.9 | Access token generation |
|
||||||
|
| `local-ip-address` | 0.6 | LAN IP detection for web terminal URL |
|
||||||
|
|
||||||
### JavaScript (Frontend)
|
### JavaScript (Frontend)
|
||||||
|
|
||||||
@@ -340,6 +557,8 @@ triple-c/
|
|||||||
| `zustand` | 5.x | Lightweight state management |
|
| `zustand` | 5.x | Lightweight state management |
|
||||||
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
| `tailwindcss` | 4.x | Utility-first CSS framework |
|
||||||
| `vite` | 6.x | Frontend build tool and dev server |
|
| `vite` | 6.x | Frontend build tool and dev server |
|
||||||
|
| `vitest` | 4.x | Test runner (jsdom environment) |
|
||||||
|
| `@testing-library/react` | 16.x | Component tests |
|
||||||
|
|
||||||
### Container Image
|
### Container Image
|
||||||
|
|
||||||
|
|||||||
Generated
+57
-57
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
@@ -1757,9 +1757,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1767,9 +1767,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli": {
|
"node_modules/@tauri-apps/cli": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.0.tgz",
|
||||||
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
|
"integrity": "sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1783,23 +1783,23 @@
|
|||||||
"url": "https://opencollective.com/tauri"
|
"url": "https://opencollective.com/tauri"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tauri-apps/cli-darwin-arm64": "2.10.0",
|
"@tauri-apps/cli-darwin-arm64": "2.11.0",
|
||||||
"@tauri-apps/cli-darwin-x64": "2.10.0",
|
"@tauri-apps/cli-darwin-x64": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
|
"@tauri-apps/cli-linux-arm64-gnu": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
|
"@tauri-apps/cli-linux-arm64-musl": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
|
"@tauri-apps/cli-linux-x64-gnu": "2.11.0",
|
||||||
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
|
"@tauri-apps/cli-linux-x64-musl": "2.11.0",
|
||||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
|
"@tauri-apps/cli-win32-arm64-msvc": "2.11.0",
|
||||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
|
"@tauri-apps/cli-win32-ia32-msvc": "2.11.0",
|
||||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
|
"@tauri-apps/cli-win32-x64-msvc": "2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.0.tgz",
|
||||||
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
|
"integrity": "sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1814,9 +1814,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.0.tgz",
|
||||||
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
|
"integrity": "sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1831,9 +1831,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.0.tgz",
|
||||||
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
|
"integrity": "sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1848,9 +1848,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.0.tgz",
|
||||||
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
|
"integrity": "sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1865,9 +1865,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.0.tgz",
|
||||||
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
|
"integrity": "sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1882,9 +1882,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.0.tgz",
|
||||||
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
|
"integrity": "sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1899,9 +1899,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.0.tgz",
|
||||||
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
|
"integrity": "sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1916,9 +1916,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.0.tgz",
|
||||||
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
|
"integrity": "sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1933,9 +1933,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.0.tgz",
|
||||||
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
|
"integrity": "sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1950,9 +1950,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.0.tgz",
|
||||||
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
|
"integrity": "sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1967,9 +1967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.0.tgz",
|
||||||
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
|
"integrity": "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1984,12 +1984,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.10.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
|
|||||||
Generated
+664
-129
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -28,9 +28,15 @@ dirs = "6"
|
|||||||
log = "0.4"
|
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"] }
|
include_dir = "0.7"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||||
iana-time-zone = "0.1"
|
iana-time-zone = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.9"
|
||||||
|
local-ip-address = "0.6"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -351,10 +351,10 @@
|
|||||||
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
|
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:app:default",
|
"const": "core:app:default",
|
||||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
|
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the app_hide command without any pre-configured scope.",
|
"description": "Enables the app_hide command without any pre-configured scope.",
|
||||||
@@ -428,6 +428,12 @@
|
|||||||
"const": "core:app:allow-set-dock-visibility",
|
"const": "core:app:allow-set-dock-visibility",
|
||||||
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:app:allow-supports-multiple-windows",
|
||||||
|
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the tauri_version command without any pre-configured scope.",
|
"description": "Enables the tauri_version command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -512,6 +518,12 @@
|
|||||||
"const": "core:app:deny-set-dock-visibility",
|
"const": "core:app:deny-set-dock-visibility",
|
||||||
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:app:deny-supports-multiple-windows",
|
||||||
|
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the tauri_version command without any pre-configured scope.",
|
"description": "Denies the tauri_version command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1035,10 +1047,10 @@
|
|||||||
"markdownDescription": "Denies the close command without any pre-configured scope."
|
"markdownDescription": "Denies the close command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
|
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:tray:default",
|
"const": "core:tray:default",
|
||||||
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
|
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the get_by_id command without any pre-configured scope.",
|
"description": "Enables the get_by_id command without any pre-configured scope.",
|
||||||
@@ -1070,6 +1082,12 @@
|
|||||||
"const": "core:tray:allow-set-icon-as-template",
|
"const": "core:tray:allow-set-icon-as-template",
|
||||||
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
|
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:tray:allow-set-icon-with-as-template",
|
||||||
|
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the set_menu command without any pre-configured scope.",
|
"description": "Enables the set_menu command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1136,6 +1154,12 @@
|
|||||||
"const": "core:tray:deny-set-icon-as-template",
|
"const": "core:tray:deny-set-icon-as-template",
|
||||||
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
|
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:tray:deny-set-icon-with-as-template",
|
||||||
|
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the set_menu command without any pre-configured scope.",
|
"description": "Denies the set_menu command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1395,10 +1419,16 @@
|
|||||||
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
|
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
|
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:default",
|
"const": "core:window:default",
|
||||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
|
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the activity_name command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:allow-activity-name",
|
||||||
|
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the available_monitors command without any pre-configured scope.",
|
"description": "Enables the available_monitors command without any pre-configured scope.",
|
||||||
@@ -1592,6 +1622,12 @@
|
|||||||
"const": "core:window:allow-scale-factor",
|
"const": "core:window:allow-scale-factor",
|
||||||
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
|
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the scene_identifier command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:allow-scene-identifier",
|
||||||
|
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
|
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1856,6 +1892,12 @@
|
|||||||
"const": "core:window:allow-unminimize",
|
"const": "core:window:allow-unminimize",
|
||||||
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
|
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the activity_name command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:deny-activity-name",
|
||||||
|
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the available_monitors command without any pre-configured scope.",
|
"description": "Denies the available_monitors command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2048,6 +2090,12 @@
|
|||||||
"const": "core:window:deny-scale-factor",
|
"const": "core:window:deny-scale-factor",
|
||||||
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
|
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the scene_identifier command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:deny-scene-identifier",
|
||||||
|
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
|
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2313,22 +2361,22 @@
|
|||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"const": "dialog:default",
|
||||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the ask command without any pre-configured scope.",
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"const": "dialog:allow-ask",
|
||||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the confirm command without any pre-configured scope.",
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"const": "dialog:allow-confirm",
|
||||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the message command without any pre-configured scope.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2397,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the ask command without any pre-configured scope.",
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"const": "dialog:deny-ask",
|
||||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the confirm command without any pre-configured scope.",
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"const": "dialog:deny-confirm",
|
||||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the message command without any pre-configured scope.",
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
|||||||
@@ -351,10 +351,10 @@
|
|||||||
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
|
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:app:default",
|
"const": "core:app:default",
|
||||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
|
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the app_hide command without any pre-configured scope.",
|
"description": "Enables the app_hide command without any pre-configured scope.",
|
||||||
@@ -428,6 +428,12 @@
|
|||||||
"const": "core:app:allow-set-dock-visibility",
|
"const": "core:app:allow-set-dock-visibility",
|
||||||
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:app:allow-supports-multiple-windows",
|
||||||
|
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the tauri_version command without any pre-configured scope.",
|
"description": "Enables the tauri_version command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -512,6 +518,12 @@
|
|||||||
"const": "core:app:deny-set-dock-visibility",
|
"const": "core:app:deny-set-dock-visibility",
|
||||||
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:app:deny-supports-multiple-windows",
|
||||||
|
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the tauri_version command without any pre-configured scope.",
|
"description": "Denies the tauri_version command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1035,10 +1047,10 @@
|
|||||||
"markdownDescription": "Denies the close command without any pre-configured scope."
|
"markdownDescription": "Denies the close command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
|
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:tray:default",
|
"const": "core:tray:default",
|
||||||
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
|
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the get_by_id command without any pre-configured scope.",
|
"description": "Enables the get_by_id command without any pre-configured scope.",
|
||||||
@@ -1070,6 +1082,12 @@
|
|||||||
"const": "core:tray:allow-set-icon-as-template",
|
"const": "core:tray:allow-set-icon-as-template",
|
||||||
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
|
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:tray:allow-set-icon-with-as-template",
|
||||||
|
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the set_menu command without any pre-configured scope.",
|
"description": "Enables the set_menu command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1136,6 +1154,12 @@
|
|||||||
"const": "core:tray:deny-set-icon-as-template",
|
"const": "core:tray:deny-set-icon-as-template",
|
||||||
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
|
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:tray:deny-set-icon-with-as-template",
|
||||||
|
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the set_menu command without any pre-configured scope.",
|
"description": "Denies the set_menu command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1395,10 +1419,16 @@
|
|||||||
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
|
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
|
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:default",
|
"const": "core:window:default",
|
||||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
|
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the activity_name command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:allow-activity-name",
|
||||||
|
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the available_monitors command without any pre-configured scope.",
|
"description": "Enables the available_monitors command without any pre-configured scope.",
|
||||||
@@ -1592,6 +1622,12 @@
|
|||||||
"const": "core:window:allow-scale-factor",
|
"const": "core:window:allow-scale-factor",
|
||||||
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
|
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the scene_identifier command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:allow-scene-identifier",
|
||||||
|
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
|
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1856,6 +1892,12 @@
|
|||||||
"const": "core:window:allow-unminimize",
|
"const": "core:window:allow-unminimize",
|
||||||
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
|
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the activity_name command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:deny-activity-name",
|
||||||
|
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the available_monitors command without any pre-configured scope.",
|
"description": "Denies the available_monitors command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2048,6 +2090,12 @@
|
|||||||
"const": "core:window:deny-scale-factor",
|
"const": "core:window:deny-scale-factor",
|
||||||
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
|
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the scene_identifier command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "core:window:deny-scene-identifier",
|
||||||
|
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
|
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2313,22 +2361,22 @@
|
|||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"const": "dialog:default",
|
||||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the ask command without any pre-configured scope.",
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"const": "dialog:allow-ask",
|
||||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the confirm command without any pre-configured scope.",
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"const": "dialog:allow-confirm",
|
||||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the message command without any pre-configured scope.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2397,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the ask command without any pre-configured scope.",
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"const": "dialog:deny-ask",
|
||||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the confirm command without any pre-configured scope.",
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"const": "dialog:deny-confirm",
|
||||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the message command without any pre-configured scope.",
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
|
||||||
|
//! container complete against the browser on the *host*.
|
||||||
|
//!
|
||||||
|
//! ## The problem
|
||||||
|
//!
|
||||||
|
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
|
||||||
|
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
|
||||||
|
//! then open a browser at a provider URL whose redirect points back to
|
||||||
|
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
|
||||||
|
//! is on the *container's* loopback, the browser is on the *host's*, and the
|
||||||
|
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
|
||||||
|
//! configurable, so nothing can be pre-published at container creation time.
|
||||||
|
//!
|
||||||
|
//! ## The mechanism
|
||||||
|
//!
|
||||||
|
//! While the bridge is enabled for a running project, poll the container every
|
||||||
|
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
|
||||||
|
//! that appears, bind the *same* port on the host's loopback and proxy each
|
||||||
|
//! accepted connection into the container over `docker exec … socat` (see
|
||||||
|
//! [`tunnel`]). When the in-container listener goes away, drop the host
|
||||||
|
//! listener. The host and container therefore agree on the port number, which is
|
||||||
|
//! the whole trick: the redirect URL the provider was given resolves correctly
|
||||||
|
//! on both sides.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle and teardown
|
||||||
|
//!
|
||||||
|
//! One poller task per project. It is the only thing that owns
|
||||||
|
//! [`PortForward`]s, and it always tears them down on its way out, so every way
|
||||||
|
//! the bridge can end funnels through the same code:
|
||||||
|
//!
|
||||||
|
//! | Trigger | Path |
|
||||||
|
//! |---|---|
|
||||||
|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||||
|
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||||
|
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||||
|
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||||
|
//!
|
||||||
|
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
|
||||||
|
//! released before it returns. As a backstop for any path that skips all of the
|
||||||
|
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
|
||||||
|
//! the accept loop, which drops the socket.
|
||||||
|
|
||||||
|
pub mod proc_net;
|
||||||
|
pub mod tunnel;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
|
use proc_net::PortFamily;
|
||||||
|
use tunnel::PortForward;
|
||||||
|
|
||||||
|
/// How often the container is polled for new/vanished loopback listeners.
|
||||||
|
/// Short enough that a login redirect isn't left waiting, cheap enough to run
|
||||||
|
/// continuously (one `cat` of two procfs files per tick).
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Emitted whenever the bridged-port set (or the conflict set) changes.
|
||||||
|
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
|
||||||
|
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC response models
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A port currently bound on the host loopback and forwarded into the container.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BridgedPort {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
|
pub bridged_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PortConflict {
|
||||||
|
pub port: u16,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AuthBridgeStatus {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub active_ports: Vec<BridgedPort>,
|
||||||
|
pub conflicts: Vec<PortConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeStatus {
|
||||||
|
fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
active_ports: Vec::new(),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manager
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Everything the poller owns for one project. Live ports and conflicts sit
|
||||||
|
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||||
|
/// disturbing the poller.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BridgeState {
|
||||||
|
forwards: BTreeMap<u16, PortForward>,
|
||||||
|
conflicts: BTreeMap<u16, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeState {
|
||||||
|
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
active_ports: self
|
||||||
|
.forwards
|
||||||
|
.values()
|
||||||
|
.map(|f| BridgedPort {
|
||||||
|
port: f.port,
|
||||||
|
family: f.family,
|
||||||
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
conflicts: self
|
||||||
|
.conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|(port, reason)| PortConflict {
|
||||||
|
port: *port,
|
||||||
|
reason: reason.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectBridge {
|
||||||
|
/// Distinguishes this poller from a later one for the same project, so a
|
||||||
|
/// poller that exits late can't remove its replacement's map entry.
|
||||||
|
epoch: u64,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
poller: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AuthBridgeManager {
|
||||||
|
bridges: BridgeMap,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling for `project_id`. Idempotent: a call while a live poller
|
||||||
|
/// already exists for the project is a no-op.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) {
|
||||||
|
let mut map = self.bridges.lock().await;
|
||||||
|
|
||||||
|
// A finished poller has already torn its ports down, so its entry is
|
||||||
|
// just a husk and can be replaced. A live one means we're already on.
|
||||||
|
if map
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|b| !b.poller.is_finished())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: starting for project {} (container {})",
|
||||||
|
project_id,
|
||||||
|
&container_id[..container_id.len().min(12)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let poller = tokio::spawn(poll_loop(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
epoch,
|
||||||
|
app,
|
||||||
|
store,
|
||||||
|
state.clone(),
|
||||||
|
self.bridges.clone(),
|
||||||
|
cancel_rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
project_id,
|
||||||
|
ProjectBridge {
|
||||||
|
epoch,
|
||||||
|
cancel: cancel_tx,
|
||||||
|
state,
|
||||||
|
poller,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the bridge for one project and wait until every host port it held
|
||||||
|
/// has been released.
|
||||||
|
pub async fn stop(&self, project_id: &str) {
|
||||||
|
// Remove under the lock, then release it before awaiting: the poller
|
||||||
|
// takes the same lock to deregister itself on exit.
|
||||||
|
let bridge = self.bridges.lock().await.remove(project_id);
|
||||||
|
if let Some(bridge) = bridge {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every bridge. Used on app exit.
|
||||||
|
pub async fn stop_all(&self) {
|
||||||
|
let bridges: Vec<(String, ProjectBridge)> =
|
||||||
|
self.bridges.lock().await.drain().collect();
|
||||||
|
for (project_id, bridge) in bridges {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status. `enabled` comes from the persisted project record, so a
|
||||||
|
/// project whose bridge is on but whose container is stopped still reports
|
||||||
|
/// `enabled: true` with no active ports.
|
||||||
|
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
let map = self.bridges.lock().await;
|
||||||
|
match map.get(project_id) {
|
||||||
|
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
|
||||||
|
None => AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
..AuthBridgeStatus::disabled()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Poller
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn poll_loop(
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
epoch: u64,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
bridges: BridgeMap,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
let mut exec_failures: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Stop conditions checked every tick, so the bridge winds itself down
|
||||||
|
// even when nothing calls `stop()` (container died, project deleted
|
||||||
|
// out from under us, flag flipped off by another path).
|
||||||
|
let project = match store.get(&project_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !project.auth_bridge_enabled {
|
||||||
|
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One exec per tick reads both procfs files.
|
||||||
|
let cmd = vec![
|
||||||
|
"cat".to_string(),
|
||||||
|
"/proc/net/tcp".to_string(),
|
||||||
|
"/proc/net/tcp6".to_string(),
|
||||||
|
];
|
||||||
|
// Cancellation races the exec, not just the sleep, so disabling the
|
||||||
|
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||||
|
let discovery = tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
res = exec_oneshot(&container_id, cmd) => res,
|
||||||
|
};
|
||||||
|
|
||||||
|
match discovery {
|
||||||
|
Ok(text) => {
|
||||||
|
exec_failures = 0;
|
||||||
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||||
|
let skip = skipped_ports(&project);
|
||||||
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
|
emit_status(&app, &project_id, &state, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
exec_failures += 1;
|
||||||
|
// Transient failures happen (container restarting, engine busy);
|
||||||
|
// only complain once per streak.
|
||||||
|
if exec_failures == 1 {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
_ = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown(&project_id, &state).await;
|
||||||
|
emit_status(
|
||||||
|
&app,
|
||||||
|
&project_id,
|
||||||
|
&state,
|
||||||
|
store
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|p| p.auth_bridge_enabled),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Deregister, unless a newer poller has already taken this project's slot.
|
||||||
|
let mut map = bridges.lock().await;
|
||||||
|
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
|
||||||
|
map.remove(&project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports Docker already handles for this project. A container port that is
|
||||||
|
/// explicitly published has a host-side path already, and the mapping's host
|
||||||
|
/// port is a binding we must not fight over.
|
||||||
|
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||||
|
project
|
||||||
|
.port_mappings
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the set of host listeners in line with what the container is currently
|
||||||
|
/// listening on. Returns whether anything the UI cares about changed.
|
||||||
|
async fn reconcile(
|
||||||
|
container_id: &str,
|
||||||
|
discovered: &BTreeMap<u16, PortFamily>,
|
||||||
|
skip: &HashSet<u16>,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
|
// Drop host listeners whose container-side counterpart vanished, became
|
||||||
|
// covered by an explicit port mapping, or changed address family (a family
|
||||||
|
// change alters the socat target, so it has to be rebound below).
|
||||||
|
let stale: Vec<u16> = st
|
||||||
|
.forwards
|
||||||
|
.iter()
|
||||||
|
.filter(|(port, forward)| match discovered.get(port) {
|
||||||
|
None => true,
|
||||||
|
Some(_) if skip.contains(port) => true,
|
||||||
|
Some(family) => *family != forward.family,
|
||||||
|
})
|
||||||
|
.map(|(port, _)| *port)
|
||||||
|
.collect();
|
||||||
|
for port in stale {
|
||||||
|
if let Some(mut forward) = st.forwards.remove(&port) {
|
||||||
|
forward.shutdown().await;
|
||||||
|
log::info!("Auth bridge: released host port {}", port);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget conflicts for ports that are no longer relevant.
|
||||||
|
let before = st.conflicts.len();
|
||||||
|
st.conflicts
|
||||||
|
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
|
||||||
|
changed |= st.conflicts.len() != before;
|
||||||
|
|
||||||
|
for (&port, &family) in discovered {
|
||||||
|
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||||
|
Ok(forward) => {
|
||||||
|
if st.conflicts.remove(&port).is_some() {
|
||||||
|
log::info!("Auth bridge: host port {} became available", port);
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
|
||||||
|
port,
|
||||||
|
family.socat_target(port),
|
||||||
|
family
|
||||||
|
);
|
||||||
|
st.forwards.insert(port, forward);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Conflict policy: never fight for a port. Something else on the
|
||||||
|
// host owns it — another project's bridge, or an unrelated
|
||||||
|
// process. Skip it, record why so the UI can say so, and retry
|
||||||
|
// on later ticks in case the owner releases it. Warn only on
|
||||||
|
// the transition so a long-lived conflict doesn't spam the log.
|
||||||
|
let reason = format!(
|
||||||
|
"Host port {} is already in use ({}); not bridged.",
|
||||||
|
port, e
|
||||||
|
);
|
||||||
|
if st.conflicts.get(&port) != Some(&reason) {
|
||||||
|
log::warn!("Auth bridge: {}", reason);
|
||||||
|
st.conflicts.insert(port, reason);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||||
|
/// return nothing is bound.
|
||||||
|
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
let forwards = std::mem::take(&mut st.forwards);
|
||||||
|
st.conflicts.clear();
|
||||||
|
let count = forwards.len();
|
||||||
|
for (_, mut forward) in forwards {
|
||||||
|
forward.shutdown().await;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: released {} host port(s) for project {}",
|
||||||
|
count,
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_status(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
let status = state.lock().await.snapshot(enabled);
|
||||||
|
let _ = app.emit(
|
||||||
|
AUTH_BRIDGE_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"status": status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
|
let mut p = Project::new(
|
||||||
|
"test".to_string(),
|
||||||
|
vec![ProjectPath {
|
||||||
|
host_path: "/tmp".to_string(),
|
||||||
|
mount_name: "tmp".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
p.port_mappings = mappings
|
||||||
|
.into_iter()
|
||||||
|
.map(|(host_port, container_port)| PortMapping {
|
||||||
|
host_port,
|
||||||
|
container_port,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ports_already_published_by_docker_are_skipped() {
|
||||||
|
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
|
// is already reachable, and the host port is Docker's binding.
|
||||||
|
assert!(skip.contains(&8080));
|
||||||
|
assert!(skip.contains(&8081));
|
||||||
|
assert!(!skip.contains(&34567));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_mappings_means_nothing_is_skipped() {
|
||||||
|
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
|
||||||
|
//! `/proc/net/tcp6` from inside the container.
|
||||||
|
//!
|
||||||
|
//! ## Why /proc and not `ss`
|
||||||
|
//!
|
||||||
|
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
|
||||||
|
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
|
||||||
|
//! and needs no package at all, so discovery works in the stock image and in any
|
||||||
|
//! snapshot derived from it.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! Both files are fixed-column text with a header line:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
|
||||||
|
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
|
||||||
|
//!
|
||||||
|
//! ## Hex and endianness
|
||||||
|
//!
|
||||||
|
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
|
||||||
|
//! the same way:
|
||||||
|
//!
|
||||||
|
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
|
||||||
|
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
|
||||||
|
//! order*, which is little-endian on every platform this app targets. So each
|
||||||
|
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
|
||||||
|
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
|
||||||
|
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
|
||||||
|
//!
|
||||||
|
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
|
||||||
|
//! digits), each converted independently, in order, to fill the 16 address
|
||||||
|
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
|
||||||
|
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
|
||||||
|
//!
|
||||||
|
//! ## What counts as loopback
|
||||||
|
//!
|
||||||
|
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
|
||||||
|
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
|
||||||
|
//! outside world — that is the port-mappings feature's job, not the auth
|
||||||
|
//! bridge's — so those rows are dropped.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The `st` column value for `TCP_LISTEN`.
|
||||||
|
const TCP_LISTEN: &str = "0A";
|
||||||
|
|
||||||
|
/// Which loopback address family (or families) a container-side listener was
|
||||||
|
/// found on. Determines the `socat` target address used to reach it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PortFamily {
|
||||||
|
/// Only `127.0.0.0/8`.
|
||||||
|
V4,
|
||||||
|
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
|
||||||
|
/// on Linux, so `claude login` frequently binds `::1` and nothing else
|
||||||
|
/// (anthropics/claude-code#44844).
|
||||||
|
V6,
|
||||||
|
/// Both — reachable either way; we use IPv4.
|
||||||
|
Dual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortFamily {
|
||||||
|
fn merge(self, other: PortFamily) -> PortFamily {
|
||||||
|
if self == other {
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
PortFamily::Dual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `socat` address that reaches this listener from inside the container.
|
||||||
|
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
|
||||||
|
/// (verified: connect gets ECONNREFUSED), hence the split.
|
||||||
|
pub fn socat_target(&self, port: u16) -> String {
|
||||||
|
match self {
|
||||||
|
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
|
||||||
|
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed LISTEN row that survived the loopback filter.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct LoopbackListener {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
|
||||||
|
/// the set of loopback ports being listened on, keyed by port with the families
|
||||||
|
/// merged (a port bound on both `127.0.0.1` and `::1` yields
|
||||||
|
/// [`PortFamily::Dual`]).
|
||||||
|
///
|
||||||
|
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
|
||||||
|
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
|
||||||
|
/// combined output — are silently ignored rather than failing the whole poll.
|
||||||
|
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
|
||||||
|
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
|
||||||
|
for listener in parse_listener_rows(text) {
|
||||||
|
ports
|
||||||
|
.entry(listener.port)
|
||||||
|
.and_modify(|f| *f = f.merge(listener.family))
|
||||||
|
.or_insert(listener.family);
|
||||||
|
}
|
||||||
|
ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row-level parse, before per-port family merging. Split out so tests can
|
||||||
|
/// assert on the individual rows.
|
||||||
|
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
|
||||||
|
text.lines().filter_map(parse_listener_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let _sl = fields.next()?;
|
||||||
|
let local_address = fields.next()?;
|
||||||
|
let _rem_address = fields.next()?;
|
||||||
|
let state = fields.next()?;
|
||||||
|
|
||||||
|
if state != TCP_LISTEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (addr_hex, port_hex) = local_address.split_once(':')?;
|
||||||
|
// The port is a straightforward big-endian hex u16 — no byte swapping.
|
||||||
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||||
|
if port == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match addr_hex.len() {
|
||||||
|
8 => {
|
||||||
|
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
|
||||||
|
addr.is_loopback().then_some(PortFamily::V4)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
|
||||||
|
let group = std::str::from_utf8(group).ok()?;
|
||||||
|
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
|
||||||
|
}
|
||||||
|
let addr = Ipv6Addr::from(octets);
|
||||||
|
// An IPv4-mapped row describes a v4 socket, so it is reachable at
|
||||||
|
// 127.0.0.1 and must be classified as v4, not v6.
|
||||||
|
match addr.to_ipv4_mapped() {
|
||||||
|
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
|
||||||
|
None => addr.is_loopback().then_some(PortFamily::V6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(LoopbackListener { port, family })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `%08X` procfs address word into its four address bytes in network
|
||||||
|
/// order. The kernel prints the word in host byte order, so the recovered bytes
|
||||||
|
/// are the little-endian expansion of the parsed integer.
|
||||||
|
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
|
||||||
|
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
|
||||||
|
/// with three listeners deliberately started:
|
||||||
|
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
|
||||||
|
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
|
||||||
|
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
|
||||||
|
const REAL_PROC_NET_TCP: &str = concat!(
|
||||||
|
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
|
||||||
|
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
|
||||||
|
/// the Node listener bound to `::1` only — the case that motivates the
|
||||||
|
/// TCP6 socat target.
|
||||||
|
const REAL_PROC_NET_TCP6: &str = concat!(
|
||||||
|
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||||
|
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
fn both_files() -> String {
|
||||||
|
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv4_loopback_row_with_little_endian_address() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
|
||||||
|
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 0x8707,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(rows[0].port, 34567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv6_loopback_row() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34568,
|
||||||
|
family: PortFamily::V6
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_wildcard_bind_addresses() {
|
||||||
|
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
|
||||||
|
// the port-mappings feature's territory.
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert!(!ports.contains_key(&34569));
|
||||||
|
|
||||||
|
// Same for the IPv6 wildcard and a non-loopback unicast address.
|
||||||
|
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(wildcard_v6).is_empty());
|
||||||
|
assert!(parse_listener_rows(lan_v4).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_both_files_concatenated_as_one_exec_output() {
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert_eq!(ports.len(), 2);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
|
||||||
|
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_families_for_a_dual_stack_port() {
|
||||||
|
let dual = format!(
|
||||||
|
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
|
||||||
|
both_files()
|
||||||
|
);
|
||||||
|
let ports = parse_loopback_listeners(&dual);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_mapped_loopback_is_reported_as_v4() {
|
||||||
|
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
|
||||||
|
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert_eq!(
|
||||||
|
parse_listener_rows(row),
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34567,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_listen_states() {
|
||||||
|
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
|
||||||
|
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(established).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_headers_and_garbage() {
|
||||||
|
assert!(parse_listener_rows("").is_empty());
|
||||||
|
assert!(parse_listener_rows(
|
||||||
|
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
// Truncated / malformed rows must not panic or be accepted.
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socat_target_matches_family() {
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::V4.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::Dual.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Host-side loopback listener for one bridged port, and the per-connection
|
||||||
|
//! tunnel that carries its bytes into the container.
|
||||||
|
//!
|
||||||
|
//! ## Why not connect to the container's IP
|
||||||
|
//!
|
||||||
|
//! Container IPs are not routable from the host on Docker Desktop (macOS and
|
||||||
|
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
|
||||||
|
//! transport. The Docker API is the only channel guaranteed to reach the
|
||||||
|
//! container from the host, so each accepted connection is carried by a
|
||||||
|
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
|
||||||
|
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||||
|
//!
|
||||||
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
|
//! interactive terminal sessions are built on.
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||||
|
|
||||||
|
use super::proc_net::PortFamily;
|
||||||
|
|
||||||
|
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||||
|
/// only needs to not be pathological.
|
||||||
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
|
/// child running.
|
||||||
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
|
|
||||||
|
impl Drop for AbortOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One host loopback port bound and proxied into the container.
|
||||||
|
///
|
||||||
|
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
|
||||||
|
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
|
||||||
|
/// releases the port *and* tears down every connection under it. [`Drop`] does
|
||||||
|
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
|
||||||
|
/// also awaiting the aborted task, which guarantees the socket is closed before
|
||||||
|
/// the caller proceeds (important when a port is rebound right after).
|
||||||
|
pub struct PortForward {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
pub bridged_at: String,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForward {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortForward {
|
||||||
|
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||||
|
///
|
||||||
|
/// The bind happens before the task is spawned, so an already-taken port is
|
||||||
|
/// reported to the caller as an error rather than disappearing into a
|
||||||
|
/// background task.
|
||||||
|
pub async fn bind(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
family: PortFamily,
|
||||||
|
) -> Result<Self, std::io::Error> {
|
||||||
|
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
|
||||||
|
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
|
||||||
|
// an unauthenticated service inside the container that deliberately
|
||||||
|
// bound loopback because it expected to be reachable from nowhere else.
|
||||||
|
// Binding a wildcard address here would publish container internals to
|
||||||
|
// every host on the LAN. Do not "fix" a connectivity problem by
|
||||||
|
// widening these addresses.
|
||||||
|
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
|
||||||
|
|
||||||
|
// Also take ::1 when it is available. Browsers and CLIs resolve
|
||||||
|
// `localhost` to either family, and the IPv6 answer is often tried
|
||||||
|
// first, so a v4-only host listener would miss those callbacks. This is
|
||||||
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||||
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||||
|
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => Some(l),
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||||
|
port,
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = family.socat_target(port);
|
||||||
|
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
family,
|
||||||
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting, drop the host socket, and abort every in-flight
|
||||||
|
/// connection. Awaits the aborted task so the port is provably released
|
||||||
|
/// when this returns.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||||
|
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||||
|
async fn accept_loop(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
target: String,
|
||||||
|
v4: TcpListener,
|
||||||
|
v6: Option<TcpListener>,
|
||||||
|
) {
|
||||||
|
let mut conns: JoinSet<()> = JoinSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
r = v4.accept() => r,
|
||||||
|
r = accept_optional(v6.as_ref()) => r,
|
||||||
|
// Reap finished tunnels so the JoinSet doesn't grow without bound.
|
||||||
|
// When the set is empty `join_next()` yields None, the pattern fails
|
||||||
|
// to match, and the branch simply drops out of the select.
|
||||||
|
Some(_) = conns.join_next() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, peer)) => {
|
||||||
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
conns.spawn(tunnel_connection(
|
||||||
|
container_id.clone(),
|
||||||
|
target.clone(),
|
||||||
|
stream,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `accept()` on an optional listener; never completes when there is none, so it
|
||||||
|
/// can sit in a `select!` arm unconditionally.
|
||||||
|
async fn accept_optional(
|
||||||
|
listener: Option<&TcpListener>,
|
||||||
|
) -> std::io::Result<(TcpStream, SocketAddr)> {
|
||||||
|
match listener {
|
||||||
|
Some(l) => l.accept().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry one accepted host connection into the container over `socat`.
|
||||||
|
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||||
|
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||||
|
|
||||||
|
let AttachedExec {
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
..
|
||||||
|
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
|
||||||
|
port,
|
||||||
|
target,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut host_rx, mut host_tx) = stream.into_split();
|
||||||
|
|
||||||
|
// Host → container. Runs as its own task so the container→host direction is
|
||||||
|
// never blocked behind a client that has stopped sending. Finishing this
|
||||||
|
// direction drops `input`, which closes the exec's stdin and lets socat see
|
||||||
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||||
|
let upstream = AbortOnDrop(tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
|
loop {
|
||||||
|
match host_rx.read(&mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
|
// stream ends, socat has exited and the connection is over.
|
||||||
|
while let Some(chunk) = output.next().await {
|
||||||
|
match chunk {
|
||||||
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
|
// diagnostics out of the proxied byte stream.
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if host_tx.write_all(&message).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: socat stderr for port {}: {}",
|
||||||
|
port,
|
||||||
|
String::from_utf8_lossy(&message).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = host_tx.shutdown().await;
|
||||||
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
|
drop(upstream);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! IPC surface for the auth bridge. The mechanism lives in
|
||||||
|
//! [`crate::auth_bridge`]; this file only translates between it and the
|
||||||
|
//! frontend, and keeps the persisted per-project flag in step.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::auth_bridge::AuthBridgeStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Turn the bridge on or off for a project and return the resulting status.
|
||||||
|
///
|
||||||
|
/// Enabling starts polling immediately when the container is already running;
|
||||||
|
/// otherwise the flag is simply persisted and `start_project_container` arms the
|
||||||
|
/// bridge on the next start. This is a host-side feature, so no container
|
||||||
|
/// recreation is involved either way.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auth_bridge_enabled(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, enabled)?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
if let Some(container_id) = project.container_id {
|
||||||
|
if crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Awaits the poller, so every host port is released before we return.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_bridge_status(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
let enabled = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.auth_bridge_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
//! Shared Claude Code authentication — one long-lived token for every project.
|
||||||
|
//!
|
||||||
|
//! ## Why
|
||||||
|
//!
|
||||||
|
//! Without this, every container is its own authentication island: each one
|
||||||
|
//! needs `claude login`, each one opens a browser flow, each one stores its own
|
||||||
|
//! credential in its own config volume. `claude setup-token` mints a single
|
||||||
|
//! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`,
|
||||||
|
//! so one authentication event can cover the whole fleet.
|
||||||
|
//!
|
||||||
|
//! ## How the token is obtained
|
||||||
|
//!
|
||||||
|
//! Observed directly against Claude Code 2.1.226, because the flow is not what
|
||||||
|
//! the design assumed. `claude setup-token` prints an authorization URL whose
|
||||||
|
//! `redirect_uri` is **Anthropic-hosted**
|
||||||
|
//! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a
|
||||||
|
//! loopback listener. After signing in, the user copies a code off that page
|
||||||
|
//! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**.
|
||||||
|
//! It then prints the token.
|
||||||
|
//!
|
||||||
|
//! Two consequences:
|
||||||
|
//!
|
||||||
|
//! * The flow needs a way to deliver the pasted code, hence
|
||||||
|
//! [`submit_claude_token_code`] and the stdin channel below. Without it the
|
||||||
|
//! command would simply sit at the prompt until it timed out.
|
||||||
|
//! * [`crate::auth_bridge`] is *not* required for this particular command,
|
||||||
|
//! since there is no container-local callback to reach. It is still enabled
|
||||||
|
//! for the duration (and restored afterwards) as designed: it costs nothing
|
||||||
|
//! here and keeps the flow working if a future CLI version, or the plain
|
||||||
|
//! `claude login` path, goes back to a loopback redirect.
|
||||||
|
//!
|
||||||
|
//! ## Handling of the token itself
|
||||||
|
//!
|
||||||
|
//! The token never reaches the frontend. It is parsed out of the command's
|
||||||
|
//! output, written straight to the OS keychain, and from then on only
|
||||||
|
//! [`crate::docker::container`] reads it, to inject the env var. Everything
|
||||||
|
//! streamed to the UI passes through [`SecretRedactor`] first, and no command
|
||||||
|
//! here returns the token or accepts it as an argument.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
|
||||||
|
use crate::storage::secure;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Milestones in the acquisition flow. Payload `{ project_id, message }`,
|
||||||
|
/// matching the `container-progress` convention.
|
||||||
|
const PROGRESS_EVENT: &str = "claude-token-progress";
|
||||||
|
|
||||||
|
/// Redacted output from `claude setup-token`, so the UI can show the user the
|
||||||
|
/// URL to visit. Payload `{ project_id, chunk }`.
|
||||||
|
const OUTPUT_EVENT: &str = "claude-token-output";
|
||||||
|
|
||||||
|
/// How long to wait for the whole flow. Generous: the user has to switch to a
|
||||||
|
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
|
||||||
|
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
/// Documented shape of a `setup-token` credential.
|
||||||
|
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
|
||||||
|
|
||||||
|
/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be
|
||||||
|
/// believed. Real tokens run to ~90 characters; this is set well below that but
|
||||||
|
/// far above anything prose would produce, so documentation-style decoys like
|
||||||
|
/// `sk-ant-oat01-...` or `sk-ant-oat01-<your-token>` are rejected.
|
||||||
|
const MIN_TOKEN_BODY: usize = 32;
|
||||||
|
|
||||||
|
/// Redaction is deliberately broader than extraction: anything shaped like an
|
||||||
|
/// Anthropic credential is masked on its way to the UI, not just `oat01` ones.
|
||||||
|
const SECRET_MARKER: &str = "sk-ant-";
|
||||||
|
const SECRET_PLACEHOLDER: &str = "sk-ant-<redacted>";
|
||||||
|
const MIN_SECRET_BODY: usize = 8;
|
||||||
|
|
||||||
|
/// Cap on how much text [`SecretRedactor`] will withhold waiting for a
|
||||||
|
/// candidate secret to end. Past this, it is not a token — release it (still
|
||||||
|
/// redacted) rather than swallow the UI's output.
|
||||||
|
const MAX_HOLDBACK: usize = 4096;
|
||||||
|
|
||||||
|
/// Cap on the retained transcript used for parsing. The token is printed at the
|
||||||
|
/// end, and a re-rendering TUI can repaint many times, so keeping the tail is
|
||||||
|
/// both sufficient and bounded.
|
||||||
|
const MAX_TRANSCRIPT: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// Characters that can appear in the body of an Anthropic credential.
|
||||||
|
fn is_token_byte(b: u8) -> bool {
|
||||||
|
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Token extraction
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Pull the long-lived token out of `claude setup-token`'s output.
|
||||||
|
///
|
||||||
|
/// Strict by construction, because the alternative to failing is storing
|
||||||
|
/// garbage that silently breaks every container:
|
||||||
|
/// * the value must carry the documented `sk-ant-oat01-` prefix;
|
||||||
|
/// * the prefix must not be glued to the tail of a longer word;
|
||||||
|
/// * at least [`MIN_TOKEN_BODY`] token characters must follow it.
|
||||||
|
///
|
||||||
|
/// The **last** match wins. The command narrates before it succeeds, and a TUI
|
||||||
|
/// may repaint the same frame repeatedly, so earlier matches are either prose
|
||||||
|
/// or superseded repaints of the same value.
|
||||||
|
pub fn parse_setup_token(output: &str) -> Option<String> {
|
||||||
|
let bytes = output.as_bytes();
|
||||||
|
let mut found = None;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
|
||||||
|
while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) {
|
||||||
|
let start = cursor + offset;
|
||||||
|
cursor = start + TOKEN_PREFIX.len();
|
||||||
|
|
||||||
|
// `xsk-ant-oat01-…` is not a token, it is a substring of something else.
|
||||||
|
if start > 0 && is_token_byte(bytes[start - 1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_start = start + TOKEN_PREFIX.len();
|
||||||
|
let mut end = body_start;
|
||||||
|
while end < bytes.len() && is_token_byte(bytes[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end - body_start < MIN_TOKEN_BODY {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = Some(output[start..end].to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Redaction
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Mask every *complete* credential in `text`.
|
||||||
|
fn redact_complete(text: &str) -> String {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut copied = 0usize;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
|
||||||
|
while let Some(offset) = text[cursor..].find(SECRET_MARKER) {
|
||||||
|
let start = cursor + offset;
|
||||||
|
cursor = start + SECRET_MARKER.len();
|
||||||
|
|
||||||
|
if start > 0 && is_token_byte(bytes[start - 1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let body_start = start + SECRET_MARKER.len();
|
||||||
|
let mut end = body_start;
|
||||||
|
while end < bytes.len() && is_token_byte(bytes[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end - body_start < MIN_SECRET_BODY {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&text[copied..start]);
|
||||||
|
out.push_str(SECRET_PLACEHOLDER);
|
||||||
|
copied = end;
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&text[copied..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the tail that might still grow into a credential begins. Everything
|
||||||
|
/// before this index is safe to emit; everything from it must be withheld until
|
||||||
|
/// more input arrives. Returns `text.len()` when nothing needs withholding.
|
||||||
|
fn holdback_index(text: &str) -> usize {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
|
||||||
|
// A credential already under way: the last marker with nothing but token
|
||||||
|
// characters after it. If the *last* marker fails that test, no earlier one
|
||||||
|
// can pass it either — the disqualifying character lies after them all.
|
||||||
|
if let Some(start) = text.rfind(SECRET_MARKER) {
|
||||||
|
let clean_start = start == 0 || !is_token_byte(bytes[start - 1]);
|
||||||
|
let body_all_token = bytes[start + SECRET_MARKER.len()..]
|
||||||
|
.iter()
|
||||||
|
.all(|b| is_token_byte(*b));
|
||||||
|
if clean_start && body_all_token {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: a marker truncated mid-way by the chunk boundary.
|
||||||
|
for len in (1..SECRET_MARKER.len()).rev() {
|
||||||
|
if text.len() >= len && text.is_char_boundary(text.len() - len)
|
||||||
|
&& &text[text.len() - len..] == &SECRET_MARKER[..len]
|
||||||
|
{
|
||||||
|
return text.len() - len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masks credentials out of a stream, tolerating a secret split across chunk
|
||||||
|
/// boundaries by withholding any tail that could still turn into one.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SecretRedactor {
|
||||||
|
pending: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretRedactor {
|
||||||
|
/// Absorb `chunk` and return the text that is now safe to show.
|
||||||
|
fn push(&mut self, chunk: &str) -> String {
|
||||||
|
self.pending.push_str(chunk);
|
||||||
|
|
||||||
|
let mut split = holdback_index(&self.pending);
|
||||||
|
if self.pending.len() - split > MAX_HOLDBACK {
|
||||||
|
split = self.pending.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
let emit = redact_complete(&self.pending[..split]);
|
||||||
|
self.pending.drain(..split);
|
||||||
|
emit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release whatever is still withheld. The stream is over, so a partial
|
||||||
|
/// credential can no longer grow — but it is still redacted on the way out.
|
||||||
|
fn flush(&mut self) -> String {
|
||||||
|
let out = redact_complete(&self.pending);
|
||||||
|
self.pending.clear();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Terminal control-sequence stripping
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Length in bytes of the UTF-8 character starting with `b`.
|
||||||
|
fn utf8_len(b: u8) -> usize {
|
||||||
|
if b < 0x80 {
|
||||||
|
1
|
||||||
|
} else if b >> 5 == 0b110 {
|
||||||
|
2
|
||||||
|
} else if b >> 4 == 0b1110 {
|
||||||
|
3
|
||||||
|
} else if b >> 3 == 0b11110 {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSI final bytes that move the cursor. Claude Code's TUI lays text out by
|
||||||
|
/// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting
|
||||||
|
/// these outright would weld neighbouring words together — which at best
|
||||||
|
/// garbles the URL the user has to read, and at worst welds a preceding word
|
||||||
|
/// onto the token and makes the parser reject it. They become a space instead:
|
||||||
|
/// a separator can never fabricate or destroy a match.
|
||||||
|
const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd";
|
||||||
|
|
||||||
|
/// Strip terminal control sequences from the front of `bytes`, stopping at the
|
||||||
|
/// first incomplete sequence or truncated character. Returns the clean text and
|
||||||
|
/// how many bytes were consumed.
|
||||||
|
fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) {
|
||||||
|
let mut out = String::with_capacity(bytes.len());
|
||||||
|
let mut i = 0usize;
|
||||||
|
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
0x1b => {
|
||||||
|
if i + 1 >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
match bytes[i + 1] {
|
||||||
|
// CSI: parameter/intermediate bytes, then a final 0x40..=0x7e.
|
||||||
|
b'[' => {
|
||||||
|
let mut j = i + 2;
|
||||||
|
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if CURSOR_MOVE_FINALS.contains(&bytes[j]) {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
// OSC: runs until BEL or ST (ESC \).
|
||||||
|
b']' => {
|
||||||
|
let mut j = i + 2;
|
||||||
|
loop {
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j] == 0x07 {
|
||||||
|
j += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if bytes[j] == 0x1b {
|
||||||
|
if j + 1 >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j + 1] == b'\\' {
|
||||||
|
j += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
// Two-byte escapes (charset selection, keypad mode, …).
|
||||||
|
_ => i += 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A repaint returns to column 0. Turn that into a line break so the
|
||||||
|
// old frame's trailing text cannot be glued onto the new frame's
|
||||||
|
// leading text — which could otherwise fabricate a "token". A run of
|
||||||
|
// CRs immediately before a LF is just the pty's ONLCR translation,
|
||||||
|
// so it collapses into that single LF rather than blank lines.
|
||||||
|
b'\r' => {
|
||||||
|
let mut j = i;
|
||||||
|
while j < bytes.len() && bytes[j] == b'\r' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j] != b'\n' {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
b'\n' => {
|
||||||
|
out.push('\n');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'\t' => {
|
||||||
|
out.push('\t');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
0x00..=0x1f | 0x7f => i += 1,
|
||||||
|
b => {
|
||||||
|
let len = utf8_len(b);
|
||||||
|
if i + len > bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) {
|
||||||
|
out.push_str(s);
|
||||||
|
}
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(out, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
|
||||||
|
/// trailing sequence over to the next chunk.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AnsiStripper {
|
||||||
|
carry: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiStripper {
|
||||||
|
fn push(&mut self, chunk: &[u8]) -> String {
|
||||||
|
self.carry.extend_from_slice(chunk);
|
||||||
|
let (out, consumed) = strip_ansi_prefix(&self.carry);
|
||||||
|
self.carry.drain(..consumed);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Commands
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`]
|
||||||
|
/// can answer the CLI's `Paste code here` prompt.
|
||||||
|
///
|
||||||
|
/// `Some` exactly while a flow is running, which doubles as the single-flight
|
||||||
|
/// guard: the token is global, so two concurrent logins would race to overwrite
|
||||||
|
/// each other's keychain entry and neither could tell which prompt it was
|
||||||
|
/// feeding.
|
||||||
|
static PENDING_INPUT: OnceLock<Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
|
||||||
|
PENDING_INPUT.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort channel for the in-flight flow, claimed and released in lockstep with
|
||||||
|
/// [`PENDING_INPUT`].
|
||||||
|
///
|
||||||
|
/// Without this the only exits are "finished" and "timed out", so a user who
|
||||||
|
/// closes the dialog would be locked out by the single-flight guard until
|
||||||
|
/// `SETUP_TIMEOUT` elapsed.
|
||||||
|
static CANCEL_TX: OnceLock<Mutex<Option<oneshot::Sender<()>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn cancel_slot() -> &'static Mutex<Option<oneshot::Sender<()>>> {
|
||||||
|
CANCEL_TX.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
serde_json::json!({ "project_id": project_id, "message": message }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
OUTPUT_EVENT,
|
||||||
|
serde_json::json!({ "project_id": project_id, "chunk": chunk }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell run inside the container.
|
||||||
|
///
|
||||||
|
/// * `stty` widens the pty before Claude Code starts, so its layout engine does
|
||||||
|
/// not wrap the token or the sign-in URL across lines. Docker's default exec
|
||||||
|
/// pty is 80 columns; both are longer than that. Setting it here rather than
|
||||||
|
/// via a post-start resize avoids racing the process's startup.
|
||||||
|
/// * The `unset` line strips inherited auth so `setup-token` runs against a
|
||||||
|
/// clean claude.ai login instead of warning about, or deferring to, whatever
|
||||||
|
/// credential the container is already configured with — including a shared
|
||||||
|
/// token from a previous run, which is likely the very thing being replaced.
|
||||||
|
const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 200 rows 50 2>/dev/null || true
|
||||||
|
unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \
|
||||||
|
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
exec claude setup-token"#;
|
||||||
|
|
||||||
|
/// Run `claude setup-token` in the container and return the token it printed.
|
||||||
|
/// Streams redacted output as it arrives and forwards anything arriving on
|
||||||
|
/// `input_rx` (the user's pasted code) to the command's stdin.
|
||||||
|
async fn run_setup_token(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
container_id: &str,
|
||||||
|
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
|
mut cancel_rx: oneshot::Receiver<()>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
|
||||||
|
// reads the pasted code in raw mode, which a plain pipe cannot provide.
|
||||||
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
} = create_attached_exec(
|
||||||
|
container_id,
|
||||||
|
vec![
|
||||||
|
"sh".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
SETUP_TOKEN_SCRIPT.to_string(),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut stripper = AnsiStripper::default();
|
||||||
|
let mut redactor = SecretRedactor::default();
|
||||||
|
let mut transcript = String::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Writing stdin and reading stdout are driven from the same loop: with
|
||||||
|
// a hijacked exec both halves ride one socket, and `input` must stay
|
||||||
|
// alive for the whole session anyway — dropping it early would tear the
|
||||||
|
// output stream down with it.
|
||||||
|
let next = tokio::select! {
|
||||||
|
// Cancellation wins the race so a user who gives up isn't held by
|
||||||
|
// the single-flight guard until the timeout. Dropping `input` and
|
||||||
|
// `output` on return tears the exec down with them.
|
||||||
|
_ = &mut cancel_rx => {
|
||||||
|
return Err(
|
||||||
|
"Authentication cancelled. No token was stored.".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(data) = input_rx.recv() => {
|
||||||
|
if let Err(e) = input.write_all(&data).await {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not send the code to `claude setup-token`: {}. No token was stored.",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = input.flush().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next = tokio::time::timeout_at(deadline, output.next()) => match next {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Timed out after {} minutes waiting for `claude setup-token` to finish. \
|
||||||
|
No token was stored.",
|
||||||
|
SETUP_TIMEOUT.as_secs() / 60
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = match next {
|
||||||
|
Some(Ok(frame)) => frame,
|
||||||
|
Some(Err(e)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Lost the connection to `claude setup-token`: {}. No token was stored.",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible = stripper.push(&frame.into_bytes());
|
||||||
|
if visible.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript.push_str(&visible);
|
||||||
|
if transcript.len() > MAX_TRANSCRIPT {
|
||||||
|
// Keep the tail: that is where the token lands.
|
||||||
|
let cut = transcript.len() - MAX_TRANSCRIPT / 2;
|
||||||
|
let cut = (cut..transcript.len())
|
||||||
|
.find(|i| transcript.is_char_boundary(*i))
|
||||||
|
.unwrap_or(transcript.len());
|
||||||
|
transcript.drain(..cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
let safe = redactor.push(&visible);
|
||||||
|
if !safe.is_empty() {
|
||||||
|
emit_output(app, project_id, &safe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tail = redactor.flush();
|
||||||
|
if !tail.is_empty() {
|
||||||
|
emit_output(app, project_id, &tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit_code = wait_for_exec_exit(&exec_id).await.unwrap_or(0);
|
||||||
|
if exit_code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"`claude setup-token` exited with status {}. No token was stored — \
|
||||||
|
see the command output above for what went wrong.",
|
||||||
|
exit_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_setup_token(&transcript).ok_or_else(|| {
|
||||||
|
"`claude setup-token` finished but printed no recognisable token. \
|
||||||
|
Nothing was stored. This usually means the login was cancelled, or the \
|
||||||
|
account has no Claude subscription (long-lived tokens require one)."
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a shared, long-lived Claude Code token by running `claude setup-token`
|
||||||
|
/// inside `project_id`'s container, and store it in the OS keychain.
|
||||||
|
///
|
||||||
|
/// The project only lends its container — a place to run the CLI that already
|
||||||
|
/// has Claude Code installed. The resulting token is global, and is used by
|
||||||
|
/// every Anthropic-backend project that has not opted out.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn acquire_claude_token(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Project '{}' has no container yet. Start it, then run authentication again.",
|
||||||
|
project.name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
return Err(format!(
|
||||||
|
"The container for '{}' is not running. Start it, then run authentication again.",
|
||||||
|
project.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the flow before touching anything else, so a second caller bounces
|
||||||
|
// off the guard rather than half-configuring the same project.
|
||||||
|
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
|
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
|
||||||
|
{
|
||||||
|
// Both slots are claimed under the input lock held first, and released
|
||||||
|
// in the same order below, so the guard and its abort channel can never
|
||||||
|
// disagree about whether a flow is live.
|
||||||
|
let mut slot = pending_input().lock().await;
|
||||||
|
if slot.is_some() {
|
||||||
|
return Err(
|
||||||
|
"A Claude authentication flow is already running. Finish or cancel it first."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*slot = Some(input_tx);
|
||||||
|
*cancel_slot().lock().await = Some(cancel_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bridge_was_enabled = project.auth_bridge_enabled;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
// See the module docs: 2.1.226's `setup-token` redirects to an
|
||||||
|
// Anthropic-hosted callback, so no container-local listener needs
|
||||||
|
// bridging. Enabled anyway, per design, to cover CLI versions and login
|
||||||
|
// paths that do use a loopback redirect. Temporary elevation — the
|
||||||
|
// prior setting is restored below whatever happens.
|
||||||
|
if !bridge_was_enabled {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, true)?;
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Auth bridge enabled for the duration of login.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Called unconditionally, and idempotent: the flag may already have
|
||||||
|
// been on while the poller was not running (e.g. enabled before start).
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
|
||||||
|
);
|
||||||
|
|
||||||
|
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Release the flow, then restore the bridge — both unconditionally, so a
|
||||||
|
// failed or cancelled login leaves nothing latched on.
|
||||||
|
*pending_input().lock().await = None;
|
||||||
|
*cancel_slot().lock().await = None;
|
||||||
|
if !bridge_was_enabled {
|
||||||
|
// Stop the poller first: it awaits teardown, so host ports are provably
|
||||||
|
// released before the flag goes back.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
if let Err(e) = state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, false)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to restore the auth bridge setting for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = result?;
|
||||||
|
secure::store_claude_oauth_token(&token)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Stored a shared Claude authentication token (acquired via project {})",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the `Paste code here if prompted >` prompt of a running
|
||||||
|
/// [`acquire_claude_token`] with the code shown after signing in.
|
||||||
|
///
|
||||||
|
/// Takes no project id: the flow is single-flight and the token is global, so
|
||||||
|
/// there is only ever one prompt waiting.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
|
||||||
|
let code = code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err("Enter the code shown after signing in.".to_string());
|
||||||
|
}
|
||||||
|
// The code goes to a TUI text input. A newline or escape embedded in it
|
||||||
|
// would submit early or drive the widget, so reject control characters
|
||||||
|
// outright rather than trying to sanitise them.
|
||||||
|
if code.chars().any(char::is_control) {
|
||||||
|
return Err("That code contains invalid characters. Copy it again and retry.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = pending_input().lock().await;
|
||||||
|
let sender = slot.as_ref().ok_or_else(|| {
|
||||||
|
"No Claude authentication flow is waiting for a code. Start authentication first."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut keystrokes = code.as_bytes().to_vec();
|
||||||
|
keystrokes.push(b'\r');
|
||||||
|
sender
|
||||||
|
.send(keystrokes)
|
||||||
|
.map_err(|_| "The authentication flow has already ended.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort an in-flight [`acquire_claude_token`].
|
||||||
|
///
|
||||||
|
/// Tears the `setup-token` exec down and releases the single-flight guard, so
|
||||||
|
/// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`.
|
||||||
|
/// A no-op when nothing is running, so closing the dialog twice is harmless.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cancel_claude_token() -> Result<(), String> {
|
||||||
|
let Some(sender) = cancel_slot().lock().await.take() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// `Err` only means the flow finished between the take and the send, which
|
||||||
|
// is exactly the outcome cancelling wanted.
|
||||||
|
let _ = sender.send(());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shared Claude token exists. Deliberately a boolean — no command
|
||||||
|
/// here ever hands the token itself to the frontend.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_claude_token() -> Result<bool, String> {
|
||||||
|
Ok(secure::has_claude_oauth_token())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the shared Claude token. Containers keep the injected value until
|
||||||
|
/// each is next started, at which point the rotation-id label mismatch forces a
|
||||||
|
/// recreation that blanks the env var.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_claude_token() -> Result<(), String> {
|
||||||
|
secure::delete_claude_oauth_token()?;
|
||||||
|
log::info!("Cleared the shared Claude authentication token");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A token-shaped value of realistic length.
|
||||||
|
fn token(seed: char) -> String {
|
||||||
|
format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_the_token_from_realistic_output() {
|
||||||
|
let tok = token('A');
|
||||||
|
let output = format!(
|
||||||
|
"Claude Code long-lived token setup\n\
|
||||||
|
Opening browser to https://claude.ai/oauth/authorize?code=true\n\
|
||||||
|
Login successful!\n\n\
|
||||||
|
Your token:\n{}\n\n\
|
||||||
|
Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_prose_decoys_and_still_finds_the_real_token() {
|
||||||
|
let tok = token('B');
|
||||||
|
let output = format!(
|
||||||
|
"Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\
|
||||||
|
or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-<your-token-here>\n\
|
||||||
|
Your token: {}\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decoy_on_its_own_yields_nothing() {
|
||||||
|
let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n";
|
||||||
|
assert_eq!(parse_setup_token(output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_match_returns_none_rather_than_guessing() {
|
||||||
|
assert_eq!(parse_setup_token(""), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_token("error: authentication cancelled by the user\n"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Right length, wrong product prefix.
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_matches_take_the_last() {
|
||||||
|
let old = token('D');
|
||||||
|
let new = token('E');
|
||||||
|
let output = format!(
|
||||||
|
"Replacing existing token {}\n...\nYour new token: {}\n",
|
||||||
|
old, new
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repainted_tui_frame_yields_the_same_token_once() {
|
||||||
|
let tok = token('F');
|
||||||
|
// Same frame drawn three times, as a TUI would.
|
||||||
|
let output = format!("Your token: {}\n", tok).repeat(3);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prefix_glued_to_a_longer_word_is_not_a_token() {
|
||||||
|
let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90));
|
||||||
|
assert_eq!(parse_setup_token(&output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_stops_at_the_first_non_token_character() {
|
||||||
|
let tok = token('H');
|
||||||
|
let output = format!("token=\"{}\", expires=2027-08-09\n", tok);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_masks_a_token_in_one_piece() {
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = r.push(&format!("Your token: {}\n", token('I')));
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert!(!seen.contains(TOKEN_PREFIX));
|
||||||
|
assert!(seen.contains(SECRET_PLACEHOLDER));
|
||||||
|
assert!(seen.contains("Your token: "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_survives_a_token_split_across_chunks() {
|
||||||
|
let tok = token('J');
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = String::new();
|
||||||
|
// Split mid-prefix and again mid-body — the worst case for a naive
|
||||||
|
// per-chunk regex.
|
||||||
|
seen.push_str(&r.push("Your token: sk-a"));
|
||||||
|
seen.push_str(&r.push(&tok[4..40]));
|
||||||
|
seen.push_str(&r.push(&tok[40..]));
|
||||||
|
seen.push_str(&r.push("\ndone\n"));
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen);
|
||||||
|
assert!(seen.contains(SECRET_PLACEHOLDER));
|
||||||
|
assert!(seen.ends_with("\ndone\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_leaves_ordinary_text_alone() {
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n");
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
"Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_recovers_the_token_from_a_styled_frame() {
|
||||||
|
let tok = token('K');
|
||||||
|
let framed = format!(
|
||||||
|
"\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(framed.as_bytes());
|
||||||
|
assert!(!visible.contains('\x1b'));
|
||||||
|
assert_eq!(parse_setup_token(&visible), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Code's TUI positions words with `ESC [ n G` instead of spaces
|
||||||
|
/// (verified against 2.1.226). Deleting those would weld words together.
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_turns_column_jumps_into_separators() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m");
|
||||||
|
assert_eq!(visible, "Welcome to Claude Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure this protects against: a column jump immediately before the
|
||||||
|
/// token would, if simply deleted, glue the preceding word onto the prefix
|
||||||
|
/// and make `parse_setup_token` reject a perfectly good token.
|
||||||
|
#[test]
|
||||||
|
fn a_column_jump_before_the_token_does_not_hide_it() {
|
||||||
|
let tok = token('L');
|
||||||
|
let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok);
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(framed.as_bytes());
|
||||||
|
// The leading jump is an indent, so it becomes a space too.
|
||||||
|
assert_eq!(visible, format!(" Token {}\n", tok));
|
||||||
|
assert_eq!(parse_setup_token(&visible), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pty with ONLCR emits `\r\r\n` at end of line; that is one break.
|
||||||
|
#[test]
|
||||||
|
fn carriage_return_runs_before_a_newline_collapse() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"one\r\r\ntwo\r\r\n");
|
||||||
|
assert_eq!(visible, "one\ntwo\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare CR is a repaint, and must still break the line so the old frame's
|
||||||
|
/// tail cannot be welded onto the new frame's head.
|
||||||
|
#[test]
|
||||||
|
fn a_bare_carriage_return_breaks_the_line() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new");
|
||||||
|
assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_removes_osc8_hyperlink_wrappers() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07");
|
||||||
|
assert_eq!(visible, "https://claude.com/x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_handles_a_sequence_split_across_chunks() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let mut visible = s.push(b"a\x1b[3");
|
||||||
|
visible.push_str(&s.push(b"1mb"));
|
||||||
|
assert_eq!(visible, "ab");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,23 +1,58 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::models::Project;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
/// Resolve AWS profile: project-level → global settings → "default".
|
||||||
pub async fn aws_sso_refresh(
|
pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String {
|
||||||
project_id: String,
|
project
|
||||||
state: State<'_, AppState>,
|
.bedrock_config
|
||||||
) -> Result<(), String> {
|
.as_ref()
|
||||||
let project = state.projects_store.get(&project_id)
|
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
||||||
|
|
||||||
let profile = project.bedrock_config.as_ref()
|
|
||||||
.and_then(|b| b.aws_profile.clone())
|
.and_then(|b| b.aws_profile.clone())
|
||||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
.or_else(|| global_profile.map(|s| s.to_string()))
|
||||||
.unwrap_or_else(|| "default".to_string());
|
.unwrap_or_else(|| "default".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the AWS session is valid for the given profile on the host.
|
||||||
|
/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid.
|
||||||
|
pub async fn check_sso_session(profile: &str) -> Result<bool, String> {
|
||||||
|
let output = tokio::process::Command::new("aws")
|
||||||
|
.args(["sts", "get-caller-identity", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?;
|
||||||
|
Ok(output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured).
|
||||||
|
pub async fn is_sso_profile(profile: &str) -> Result<bool, String> {
|
||||||
|
let check_start_url = tokio::process::Command::new("aws")
|
||||||
|
.args(["configure", "get", "sso_start_url", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(out) = check_start_url {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let check_session = tokio::process::Command::new("aws")
|
||||||
|
.args(["configure", "get", "sso_session", "--profile", profile])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(out) = check_session {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser).
|
||||||
|
pub async fn run_sso_login(profile: &str) -> Result<(), String> {
|
||||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||||
|
|
||||||
let status = tokio::process::Command::new("aws")
|
let status = tokio::process::Command::new("aws")
|
||||||
.args(["sso", "login", "--profile", &profile])
|
.args(["sso", "login", "--profile", profile])
|
||||||
.status()
|
.status()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||||
@@ -28,3 +63,19 @@ pub async fn aws_sso_refresh(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn aws_sso_refresh(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state.projects_store.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let profile = resolve_profile_for_project(
|
||||||
|
&project,
|
||||||
|
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
run_sso_login(&profile).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
|
||||||
|
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -151,6 +152,199 @@ pub async fn download_container_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
||||||
|
/// The archive contains:
|
||||||
|
/// - the workspace (default /workspace), minus regenerable build artifacts
|
||||||
|
/// (node_modules, target), under `workspace/`, and
|
||||||
|
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
||||||
|
/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native
|
||||||
|
/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
|
||||||
|
/// `.credentials.json`, so settings and skills set up via Claude Code
|
||||||
|
/// survive a Reset.
|
||||||
|
/// `.git` is kept in full so the backup faithfully preserves git history,
|
||||||
|
/// including unpushed commits. Build + gzip happen inside the container so a
|
||||||
|
/// large workspace isn't streamed in full. The container must be RUNNING (the
|
||||||
|
/// backup runs via `docker exec`). Returns the number of bytes written.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_container_backup(
|
||||||
|
project_id: String,
|
||||||
|
host_path: String,
|
||||||
|
container_path: Option<String>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// The backup runs inside the container via `docker exec`, which requires it
|
||||||
|
// to be running. Fail with a clear message rather than a raw Docker error.
|
||||||
|
let running = docker
|
||||||
|
.inspect_container(container_id, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|info| info.state)
|
||||||
|
.and_then(|s| s.running)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !running {
|
||||||
|
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||||
|
|
||||||
|
// Stage a sanitized home config, then tar+gzip workspace + staged config to
|
||||||
|
// stdout. mktemp/jq output go nowhere near stdout, so the only thing the
|
||||||
|
// exec emits on stdout is the archive itself. --ignore-failed-read keeps a
|
||||||
|
// transient unreadable file from aborting the whole backup. If jq can't
|
||||||
|
// parse ~/.claude.json we substitute an empty object — never the raw file —
|
||||||
|
// so secrets can't leak through the sanitization fallback.
|
||||||
|
// The `--transform` nests the workspace under `workspace/` (parallel to
|
||||||
|
// `home-claude/`) so an extracted archive has both clearly labeled instead
|
||||||
|
// of scattering the workspace files into the extraction dir. Rewriting the
|
||||||
|
// leading `.` (rather than `./`) also renames tar's root member from `./` to
|
||||||
|
// `workspace`, so the archive carries a proper `workspace/` dir entry rather
|
||||||
|
// than a bare `./` that would stamp the source root's mode/mtime onto the
|
||||||
|
// extraction directory. `flags=rh` rewrites regular member names AND
|
||||||
|
// hardlink target names (so an intra-workspace hardlink pair still resolves
|
||||||
|
// on extract) while leaving symlink targets untouched (rewriting those would
|
||||||
|
// corrupt relative/absolute links).
|
||||||
|
let script = r#"set -e
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
mkdir -p "$STAGE/home-claude"
|
||||||
|
if [ -f "$HOME/.claude.json" ]; then
|
||||||
|
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
|
||||||
|
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
|
||||||
|
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
|
||||||
|
printf '{}' > "$STAGE/home-claude/.claude.json"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -d "$HOME/.claude" ]; then
|
||||||
|
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
|
||||||
|
rm -f "$STAGE/home-claude/.claude/.credentials.json"
|
||||||
|
fi
|
||||||
|
tar czf - --ignore-failed-read \
|
||||||
|
--exclude='*/node_modules' --exclude='*/target' \
|
||||||
|
--transform='flags=rh;s,^\.,workspace,' \
|
||||||
|
-C "$TC_BACKUP_SRC" . \
|
||||||
|
-C "$STAGE" home-claude"#;
|
||||||
|
|
||||||
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
env: Some(vec![
|
||||||
|
"HOME=/home/claude".to_string(),
|
||||||
|
format!("TC_BACKUP_SRC={}", path),
|
||||||
|
]),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
|
||||||
|
|
||||||
|
let result = docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut output = match result {
|
||||||
|
StartExecResults::Attached { output, .. } => output,
|
||||||
|
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let file = tokio::fs::File::create(&host_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create backup file: {}", e))?;
|
||||||
|
let mut writer = tokio::io::BufWriter::new(file);
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut stderr_text = String::new();
|
||||||
|
let mut stream_err: Option<String> = None;
|
||||||
|
|
||||||
|
while let Some(msg) = output.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if let Err(e) = writer.write_all(&message).await {
|
||||||
|
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += message.len() as u64;
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
stream_err = Some(format!("Backup stream error: {}", e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stream_err.is_none() {
|
||||||
|
if let Err(e) = writer.flush().await {
|
||||||
|
stream_err = Some(format!("Failed to finalize backup file: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(writer);
|
||||||
|
|
||||||
|
// The tar pipeline can abort mid-stream (producing a truncated archive) and
|
||||||
|
// still have sent bytes, so a non-zero exit must be treated as failure even
|
||||||
|
// when `total > 0`. Poll until the exec actually reports finished so the
|
||||||
|
// exit code is reliably populated; if it can't be determined we fall back to
|
||||||
|
// the `total == 0` check below.
|
||||||
|
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||||
|
|
||||||
|
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"Backup command failed (exit {}){}",
|
||||||
|
exit_code.unwrap_or(-1),
|
||||||
|
if stderr_text.trim().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(": {}", stderr_text.trim())
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if stream_err.is_none() && total == 0 {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"Backup produced no data{}",
|
||||||
|
if stderr_text.trim().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(": {}", stderr_text.trim())
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = stream_err {
|
||||||
|
// Don't leave a partial/corrupt archive behind.
|
||||||
|
let _ = tokio::fs::remove_file(&host_path).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Wrote {} byte backup for project {} to {}",
|
||||||
|
total,
|
||||||
|
project_id,
|
||||||
|
host_path
|
||||||
|
);
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_file_to_container(
|
pub async fn upload_file_to_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const HELP_URL: &str =
|
||||||
|
"https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
|
||||||
|
|
||||||
|
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
||||||
|
|
||||||
|
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
|
||||||
|
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Return the help markdown content.
|
||||||
|
///
|
||||||
|
/// On the first call, tries to fetch the latest version from the gitea repo.
|
||||||
|
/// If that fails (network error, timeout, etc.), falls back to the version
|
||||||
|
/// embedded at compile time. The result is cached for the rest of the session.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_help_content() -> Result<String, String> {
|
||||||
|
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
|
||||||
|
let mut guard = mutex.lock().await;
|
||||||
|
|
||||||
|
if let Some(ref cached) = *guard {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match fetch_remote_help().await {
|
||||||
|
Ok(md) => {
|
||||||
|
log::info!("Loaded help content from remote repo");
|
||||||
|
md
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("Using embedded help content (remote fetch failed: {})", e);
|
||||||
|
EMBEDDED_HELP.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*guard = Some(content.clone());
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_remote_help() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(HELP_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Remote returned status {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", e))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::install_helper::{self, InstallOptions};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_install_options() -> Result<InstallOptions, String> {
|
||||||
|
Ok(install_helper::detect_install_options())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
install_helper::platform::run_install(&app_handle).await
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::models::McpServer;
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
|
|
||||||
Ok(state.mcp_store.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn add_mcp_server(
|
|
||||||
name: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<McpServer, String> {
|
|
||||||
let name = name.trim().to_string();
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err("MCP server name cannot be empty.".to_string());
|
|
||||||
}
|
|
||||||
let server = McpServer::new(name);
|
|
||||||
state.mcp_store.add(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_mcp_server(
|
|
||||||
server: McpServer,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<McpServer, String> {
|
|
||||||
state.mcp_store.update(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn remove_mcp_server(
|
|
||||||
server_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.mcp_store.remove(&server_id)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
pub mod auth_bridge_commands;
|
||||||
|
pub mod auth_token_commands;
|
||||||
pub mod aws_commands;
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
pub mod mcp_commands;
|
pub mod help_commands;
|
||||||
|
pub mod inspect_commands;
|
||||||
|
pub mod install_helper_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
pub mod web_terminal_commands;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
|
use crate::commands::aws_commands;
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -34,9 +35,9 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
|||||||
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref litellm) = project.litellm_config {
|
if let Some(ref oai_config) = project.openai_compatible_config {
|
||||||
if let Some(ref v) = litellm.api_key {
|
if let Some(ref v) = oai_config.api_key {
|
||||||
secure::store_project_secret(&project.id, "litellm-api-key", v)?;
|
secure::store_project_secret(&project.id, "openai-compatible-api-key", v)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -56,25 +57,12 @@ fn load_secrets_for_project(project: &mut Project) {
|
|||||||
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
}
|
}
|
||||||
if let Some(ref mut litellm) = project.litellm_config {
|
if let Some(ref mut oai_config) = project.openai_compatible_config {
|
||||||
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key")
|
oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key")
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve enabled MCP servers and filter to Docker-only ones.
|
|
||||||
fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec<McpServer>, Vec<McpServer>) {
|
|
||||||
let all_mcp_servers = state.mcp_store.list();
|
|
||||||
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
|
|
||||||
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
|
|
||||||
.collect();
|
|
||||||
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
|
|
||||||
.filter(|s| s.is_docker())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
(enabled_mcp, docker_mcp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||||
Ok(state.projects_store.list())
|
Ok(state.projects_store.list())
|
||||||
@@ -112,6 +100,10 @@ pub async fn remove_project(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Release any host loopback ports the auth bridge holds for this project
|
||||||
|
// before the container (and the project record) go away.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists
|
||||||
if let Some(ref 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 {
|
||||||
@@ -120,16 +112,10 @@ pub async fn remove_project(
|
|||||||
let _ = docker::remove_container(container_id).await;
|
let _ = docker::remove_container(container_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove MCP containers and network
|
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state);
|
// containers first, then the per-project network they were attached to.
|
||||||
if !docker_mcp.is_empty() {
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = docker::remove_project_network(&project.id).await {
|
|
||||||
log::warn!("Failed to remove project network for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the snapshot image + volumes
|
// Clean up the snapshot image + volumes
|
||||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||||
@@ -151,10 +137,35 @@ pub async fn remove_project(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_project(
|
pub async fn update_project(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
store_secrets_for_project(&project)?;
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.update(project)
|
let updated = state.projects_store.update(project)?;
|
||||||
|
|
||||||
|
// `auth_bridge_enabled` can arrive through this generic save as well as
|
||||||
|
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
||||||
|
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
||||||
|
// when nothing is running, so this is safe on every project save.
|
||||||
|
if updated.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = updated.container_id {
|
||||||
|
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
updated.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.auth_bridge.stop(&updated.id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -176,38 +187,109 @@ pub async fn start_project_container(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Resolve enabled MCP servers for this project
|
// Validate backend requirements
|
||||||
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
if project.backend == Backend::Bedrock {
|
||||||
|
|
||||||
// Validate auth mode requirements
|
|
||||||
if project.auth_mode == AuthMode::Bedrock {
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
|
||||||
// Region can come from per-project or global
|
// Region can come from per-project or global
|
||||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.auth_mode == AuthMode::Ollama {
|
if project.backend == Backend::Ollama {
|
||||||
let ollama = project.ollama_config.as_ref()
|
let ollama = project.ollama_config.as_ref()
|
||||||
.ok_or_else(|| "Ollama auth mode selected but no Ollama configuration found.".to_string())?;
|
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
|
||||||
if ollama.base_url.is_empty() {
|
if ollama.base_url.is_empty()
|
||||||
return Err("Ollama base URL is required.".to_string());
|
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||||
|
{
|
||||||
|
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.auth_mode == AuthMode::LiteLlm {
|
if project.backend == Backend::OpenAiCompatible {
|
||||||
let litellm = project.litellm_config.as_ref()
|
let oai_config = project.openai_compatible_config.as_ref()
|
||||||
.ok_or_else(|| "LiteLLM auth mode selected but no LiteLLM configuration found.".to_string())?;
|
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
||||||
if litellm.base_url.is_empty() {
|
if oai_config.base_url.is_empty()
|
||||||
return Err("LiteLLM base URL is required.".to_string());
|
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||||
|
{
|
||||||
|
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to starting
|
// Update status to starting
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
|
|
||||||
|
// Pre-validate AWS SSO session on the host for Bedrock Profile projects.
|
||||||
|
// If the session is expired, trigger `aws sso login` before starting the container
|
||||||
|
// so the entrypoint copies already-fresh credentials from the host mount.
|
||||||
|
if project.backend == Backend::Bedrock {
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
if bedrock.auth_method == BedrockAuthMethod::Profile {
|
||||||
|
let profile = aws_commands::resolve_profile_for_project(
|
||||||
|
&project,
|
||||||
|
settings.global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit_progress(&app_handle, &project_id, "Validating AWS session...");
|
||||||
|
|
||||||
|
let session_valid = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
aws_commands::check_sso_session(&profile),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match session_valid {
|
||||||
|
Ok(Ok(true)) => {
|
||||||
|
emit_progress(&app_handle, &project_id, "AWS session valid.");
|
||||||
|
}
|
||||||
|
Ok(Ok(false)) => {
|
||||||
|
// Session expired — check if this is an SSO profile
|
||||||
|
if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) {
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"AWS session expired. Starting SSO login (check your browser)...",
|
||||||
|
);
|
||||||
|
match aws_commands::run_sso_login(&profile).await {
|
||||||
|
Ok(()) => {
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"SSO login successful.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"SSO login failed for profile '{}': {} — continuing anyway",
|
||||||
|
profile,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"SSO login failed or cancelled. Continuing...",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"AWS session invalid for profile '{}' (not SSO). Continuing...",
|
||||||
|
profile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!("Failed to check AWS session: {} — continuing anyway", e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("AWS session check timed out — continuing anyway");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap container operations so that any failure resets status to Stopped.
|
// Wrap container operations so that any failure resets status to Stopped.
|
||||||
let result: Result<String, String> = async {
|
let result: Result<String, String> = async {
|
||||||
// Ensure image exists
|
// Ensure image exists
|
||||||
@@ -225,48 +307,21 @@ 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();
|
||||||
|
|
||||||
// Set up Docker network and MCP containers if needed
|
|
||||||
let network_name = if !docker_mcp.is_empty() {
|
|
||||||
// Pull any missing MCP Docker images before starting containers
|
|
||||||
for server in &docker_mcp {
|
|
||||||
if let Some(ref image) = server.docker_image {
|
|
||||||
if !docker::image_exists(image).await.unwrap_or(false) {
|
|
||||||
emit_progress(
|
|
||||||
&app_handle,
|
|
||||||
&project_id,
|
|
||||||
&format!("Pulling MCP image for '{}'...", server.name),
|
|
||||||
);
|
|
||||||
let image_clone = image.clone();
|
|
||||||
let app_clone = app_handle.clone();
|
|
||||||
let pid_clone = project_id.clone();
|
|
||||||
let sname = server.name.clone();
|
|
||||||
docker::pull_image(&image_clone, move |msg| {
|
|
||||||
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
|
|
||||||
}).await.map_err(|e| {
|
|
||||||
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
|
||||||
let net = docker::ensure_project_network(&project.id).await?;
|
|
||||||
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
|
||||||
docker::start_mcp_containers(&docker_mcp, &net).await?;
|
|
||||||
Some(net)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
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? {
|
||||||
// Check if config changed — if so, snapshot + recreate
|
// Check if config changed — if so, snapshot + recreate
|
||||||
let needs_recreate = docker::container_needs_recreation(
|
let needs_recreate = docker::container_needs_recreation(
|
||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
|
&settings.global_aws,
|
||||||
|
&settings.global_ollama,
|
||||||
|
&settings.global_openai_compatible,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
settings.global_claude_code_settings.as_ref(),
|
||||||
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.default_git_user_name.as_deref(),
|
||||||
|
settings.default_git_user_email.as_deref(),
|
||||||
).await.unwrap_or(false);
|
).await.unwrap_or(false);
|
||||||
|
|
||||||
if needs_recreate {
|
if needs_recreate {
|
||||||
@@ -280,6 +335,12 @@ pub async fn start_project_container(
|
|||||||
let _ = docker::stop_container(&existing_id).await;
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
|
|
||||||
|
// Legacy MCP cleanup: the old container may have been attached to
|
||||||
|
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||||
|
// that network now, before the replacement is created without it.
|
||||||
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
|
|
||||||
// Create from snapshot image (preserves system-level changes)
|
// Create from snapshot image (preserves system-level changes)
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
@@ -294,11 +355,15 @@ pub async fn start_project_container(
|
|||||||
&create_image,
|
&create_image,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
&settings.global_ollama,
|
||||||
|
&settings.global_openai_compatible,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
settings.global_claude_code_settings.as_ref(),
|
||||||
network_name.as_deref(),
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.default_git_user_name.as_deref(),
|
||||||
|
settings.default_git_user_email.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
@@ -327,17 +392,28 @@ pub async fn start_project_container(
|
|||||||
&create_image,
|
&create_image,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
&settings.global_ollama,
|
||||||
|
&settings.global_openai_compatible,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
&enabled_mcp,
|
settings.global_claude_code_settings.as_ref(),
|
||||||
network_name.as_deref(),
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.default_git_user_name.as_deref(),
|
||||||
|
settings.default_git_user_email.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||||
|
// so rotated keys are picked up without a full container recreation, and
|
||||||
|
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||||
|
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||||
|
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(container_id)
|
Ok(container_id)
|
||||||
}.await;
|
}.await;
|
||||||
|
|
||||||
@@ -353,6 +429,20 @@ pub async fn start_project_container(
|
|||||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
||||||
|
|
||||||
|
// Arm the auth bridge if this project opted in. Purely host-side, so it
|
||||||
|
// happens after the container is up and never affects the start itself.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
project.container_id = Some(container_id);
|
project.container_id = Some(container_id);
|
||||||
project.status = ProjectStatus::Running;
|
project.status = ProjectStatus::Running;
|
||||||
Ok(project)
|
Ok(project)
|
||||||
@@ -371,6 +461,9 @@ pub async fn stop_project_container(
|
|||||||
|
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
|
|
||||||
|
// Drop host listeners first: they only make sense while the container runs.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||||
@@ -381,15 +474,6 @@ pub async fn stop_project_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop MCP containers (best-effort)
|
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
|
||||||
if !docker_mcp.is_empty() {
|
|
||||||
emit_progress(&app_handle, &project_id, "Stopping MCP containers...");
|
|
||||||
if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await {
|
|
||||||
log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -405,6 +489,10 @@ pub async fn rebuild_project_container(
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
// The bridge is bound to the container that is about to be destroyed;
|
||||||
|
// `start_project_container` below re-arms it against the new one.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// Remove existing container
|
// Remove existing container
|
||||||
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;
|
||||||
@@ -413,14 +501,6 @@ 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 MCP containers before rebuild
|
|
||||||
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
|
||||||
if !docker_mcp.is_empty() {
|
|
||||||
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
|
|
||||||
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove snapshot image + volumes so Reset creates from the clean base image
|
// Remove snapshot image + volumes so Reset creates from the clean base image
|
||||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||||
@@ -439,6 +519,7 @@ pub async fn rebuild_project_container(
|
|||||||
/// to Stopped.
|
/// to Stopped.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reconcile_project_statuses(
|
pub async fn reconcile_project_statuses(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Project>, String> {
|
) -> Result<Vec<Project>, String> {
|
||||||
let projects = state.projects_store.list();
|
let projects = state.projects_store.list();
|
||||||
@@ -460,6 +541,22 @@ pub async fn reconcile_project_statuses(
|
|||||||
project.name,
|
project.name,
|
||||||
project.id
|
project.id
|
||||||
);
|
);
|
||||||
|
// The app may have restarted while the container kept running; the
|
||||||
|
// bridge lives in this process, so re-arm it here. `start` is
|
||||||
|
// idempotent, so a bridge that is already polling is untouched.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = project.container_id {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Project '{}' ({}) container is not running — setting to Stopped",
|
"Project '{}' ({}) container is not running — setting to Stopped",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::docker::stt;
|
||||||
|
use crate::models::app_settings::SttStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_stt_status(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
stt::get_stt_status(&settings.stt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_stt(state: State<'_, AppState>) -> Result<SttStatus, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
stt::ensure_stt_running(&settings.stt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_stt() -> Result<(), String> {
|
||||||
|
stt::stop_stt_container().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
stt::build_stt_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("stt-build-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pull_stt_image(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
stt::pull_stt_image(move |msg| {
|
||||||
|
let _ = app_handle.emit("stt-pull-progress", &msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn transcribe_audio(
|
||||||
|
audio_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
if !settings.stt.enabled {
|
||||||
|
return Err("STT is not enabled".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("http://127.0.0.1:{}/transcribe", settings.stt.port);
|
||||||
|
|
||||||
|
let file_part = reqwest::multipart::Part::bytes(audio_data)
|
||||||
|
.file_name("recording.wav")
|
||||||
|
.mime_str("audio/wav")
|
||||||
|
.map_err(|e| format!("Failed to create multipart: {}", e))?;
|
||||||
|
|
||||||
|
let mut form = reqwest::multipart::Form::new().part("file", file_part);
|
||||||
|
|
||||||
|
if let Some(ref lang) = settings.stt.language {
|
||||||
|
form = form.text("language", lang.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.is_connect() {
|
||||||
|
"STT container is not running. Start it from Settings.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Transcription request failed: {}", e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Transcription failed ({}): {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
|
||||||
|
|
||||||
|
result["text"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "No text in transcription response".to_string())
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
use crate::commands::aws_commands;
|
||||||
|
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Build the command to run in the container terminal.
|
/// Build the command to run in the container terminal.
|
||||||
@@ -8,31 +9,47 @@ use crate::AppState;
|
|||||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||||
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
&& project
|
&& project
|
||||||
.bedrock_config
|
.bedrock_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
return vec![
|
let mut cmd = vec!["claude".to_string()];
|
||||||
"claude".to_string(),
|
cmd.extend(permission_args);
|
||||||
"--dangerously-skip-permissions".to_string(),
|
if let Some(name) = session_name {
|
||||||
];
|
if !name.is_empty() {
|
||||||
|
cmd.push("-n".to_string());
|
||||||
|
cmd.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve AWS profile: project-level → global settings → "default"
|
let profile = aws_commands::resolve_profile_for_project(
|
||||||
let profile = project
|
project,
|
||||||
.bedrock_config
|
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
.as_ref()
|
);
|
||||||
.and_then(|b| b.aws_profile.clone())
|
|
||||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
|
||||||
.unwrap_or_else(|| "default".to_string());
|
|
||||||
|
|
||||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
// then exec's into claude.
|
// then exec's into claude.
|
||||||
|
let name_flag = session_name
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||||
|
.unwrap_or_default();
|
||||||
|
// The args are interpolated into a shell script string, so single-quote
|
||||||
|
// each one (same escaping style as name_flag above).
|
||||||
|
let permission_flags: String = permission_args
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
echo "Validating AWS session for profile '{profile}'..."
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
@@ -58,9 +75,10 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
exec claude --dangerously-skip-permissions
|
{claude_cmd}
|
||||||
"#,
|
"#,
|
||||||
profile = profile
|
profile = profile,
|
||||||
|
claude_cmd = claude_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
@@ -75,6 +93,7 @@ pub async fn open_terminal_session(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
session_type: Option<String>,
|
session_type: Option<String>,
|
||||||
|
session_name: Option<String>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -90,7 +109,7 @@ pub async fn open_terminal_session(
|
|||||||
|
|
||||||
let cmd = match session_type.as_deref() {
|
let cmd = match session_type.as_deref() {
|
||||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
_ => build_terminal_cmd(&project, &state),
|
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
@@ -166,6 +185,55 @@ pub async fn paste_image_to_terminal(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a host file (e.g. dragged onto the terminal) into the container so
|
||||||
|
/// Claude Code can read it, and return the in-container path. Mirrors the
|
||||||
|
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
|
||||||
|
/// original name. Returns an error for paths that aren't readable regular files
|
||||||
|
/// (e.g. a dropped directory).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_host_file_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
host_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
|
if meta.is_dir() {
|
||||||
|
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
|
// tar before upload, so cap the size of a dropped file.
|
||||||
|
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
||||||
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||||
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = std::path::Path::new(&host_path)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "dropped-file".to_string());
|
||||||
|
|
||||||
|
// Ensure the destination directory exists rather than relying on Docker's
|
||||||
|
// archive extractor to create the parent for the uploaded tar entry.
|
||||||
|
crate::docker::exec::exec_oneshot(
|
||||||
|
&container_id,
|
||||||
|
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let file_name = format!("triple-c-drops/{}", base);
|
||||||
|
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_audio_bridge(
|
pub async fn start_audio_bridge(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
|
use serde::Deserialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::docker;
|
||||||
|
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
const RELEASES_URL: &str =
|
const RELEASES_URL: &str =
|
||||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
"https://api.github.com/repos/shadowdao/triple-c/releases";
|
||||||
|
|
||||||
|
/// GHCR container-registry API base (OCI distribution spec).
|
||||||
|
const REGISTRY_API_BASE: &str =
|
||||||
|
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
|
||||||
|
|
||||||
|
/// GHCR token endpoint for anonymous pull access.
|
||||||
|
const GHCR_TOKEN_URL: &str =
|
||||||
|
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
@@ -15,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
let releases: Vec<GiteaRelease> = client
|
let releases: Vec<GitHubRelease> = client
|
||||||
.get(RELEASES_URL)
|
.get(RELEASES_URL)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
||||||
@@ -26,30 +40,34 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION");
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
let is_windows = cfg!(target_os = "windows");
|
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
||||||
|
|
||||||
// Filter releases by platform tag suffix
|
// Determine platform-specific asset extensions
|
||||||
let platform_releases: Vec<&GiteaRelease> = releases
|
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
|
||||||
|
&[".msi", ".exe"]
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
&[".dmg", ".app.tar.gz"]
|
||||||
|
} else {
|
||||||
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter releases that have at least one asset matching the current platform
|
||||||
|
let platform_releases: Vec<&GitHubRelease> = releases
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
if is_windows {
|
r.assets.iter().any(|a| {
|
||||||
r.tag_name.ends_with("-win")
|
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||||
} else {
|
})
|
||||||
!r.tag_name.ends_with("-win")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Find the latest release with a higher patch version
|
// Find the latest release with a higher semver version
|
||||||
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
|
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||||
let current_patch = parse_patch_version(current_version).unwrap_or(0);
|
|
||||||
|
|
||||||
let mut best: Option<(&GiteaRelease, u32)> = None;
|
|
||||||
for release in &platform_releases {
|
for release in &platform_releases {
|
||||||
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
|
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||||
if patch > current_patch {
|
if ver > current_semver {
|
||||||
if best.is_none() || patch > best.unwrap().1 {
|
if best.is_none() || ver > best.unwrap().1 {
|
||||||
best = Some((release, patch));
|
best = Some((release, ver));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
|
|
||||||
match best {
|
match best {
|
||||||
Some((release, _)) => {
|
Some((release, _)) => {
|
||||||
|
// Only include assets matching the current platform
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||||
|
})
|
||||||
.map(|a| ReleaseAsset {
|
.map(|a| ReleaseAsset {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
browser_download_url: a.browser_download_url.clone(),
|
browser_download_url: a.browser_download_url.clone(),
|
||||||
@@ -67,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Reconstruct version string from tag
|
|
||||||
let version = extract_version_from_tag(&release.tag_name)
|
let version = extract_version_from_tag(&release.tag_name)
|
||||||
.unwrap_or_else(|| release.tag_name.clone());
|
.unwrap_or_else(|| release.tag_name.clone());
|
||||||
|
|
||||||
@@ -84,34 +105,152 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a semver string like "0.1.5" -> 5
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
fn parse_patch_version(version: &str) -> Option<u32> {
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
let clean = version.trim_start_matches('v');
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
let parts: Vec<&str> = clean.split('.').collect();
|
||||||
if parts.len() >= 3 {
|
if parts.len() >= 3 {
|
||||||
parts[2].parse().ok()
|
let major = parts[0].parse().ok()?;
|
||||||
|
let minor = parts[1].parse().ok()?;
|
||||||
|
let patch = parts[2].parse().ok()?;
|
||||||
|
Some((major, minor, patch))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
|
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
|
||||||
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
|
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let clean = tag.trim_start_matches('v');
|
||||||
// Remove platform suffix
|
parse_semver(clean)
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
|
||||||
parse_patch_version(clean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
|
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
|
||||||
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
||||||
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
Some(format!("{}.{}.{}", major, minor, patch))
|
||||||
// Validate it looks like a version
|
}
|
||||||
let parts: Vec<&str> = clean.split('.').collect();
|
|
||||||
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
|
/// Check whether a newer container image is available in the registry.
|
||||||
Some(clean.to_string())
|
///
|
||||||
} else {
|
/// Compares the local image digest with the remote registry digest using the
|
||||||
None
|
/// Docker Registry HTTP API v2. Only applies when the image source is
|
||||||
|
/// "registry" (the default); for local builds or custom images we cannot
|
||||||
|
/// meaningfully check for remote updates.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_image_update(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<ImageUpdateInfo>, String> {
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
|
||||||
|
// Only check for registry images
|
||||||
|
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_name =
|
||||||
|
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
|
// 1. Get local image digest via Docker
|
||||||
|
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
||||||
|
|
||||||
|
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
|
||||||
|
let remote_digest = fetch_remote_digest("latest").await?;
|
||||||
|
|
||||||
|
// No remote digest available — nothing to compare
|
||||||
|
let remote_digest = match remote_digest {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If local digest matches remote, no update
|
||||||
|
if let Some(ref local) = local_digest {
|
||||||
|
if *local == remote_digest {
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// There's a difference (or no local image at all)
|
||||||
|
Ok(Some(ImageUpdateInfo {
|
||||||
|
remote_digest,
|
||||||
|
local_digest,
|
||||||
|
remote_updated_at: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
|
||||||
|
///
|
||||||
|
/// GHCR requires authentication even for public images, so we first obtain an
|
||||||
|
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
|
||||||
|
/// and read the `Docker-Content-Digest` header.
|
||||||
|
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
// 1. Obtain anonymous bearer token from GHCR
|
||||||
|
let token = match fetch_ghcr_token(&client).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to obtain GHCR token: {}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. HEAD the manifest with the token
|
||||||
|
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.head(&url)
|
||||||
|
.header(
|
||||||
|
"Accept",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
||||||
|
)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) => {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
log::warn!(
|
||||||
|
"Registry returned status {} when checking image digest",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// The digest is returned in the Docker-Content-Digest header
|
||||||
|
if let Some(digest) = resp.headers().get("docker-content-digest") {
|
||||||
|
if let Ok(val) = digest.to_str() {
|
||||||
|
return Ok(Some(val.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to check registry for image update: {}", e);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch an anonymous bearer token from GHCR for pulling public images.
|
||||||
|
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp: TokenResponse = client
|
||||||
|
.get(GHCR_TOKEN_URL)
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("GHCR token request failed: {}", e))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(resp.token)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::web_terminal::WebTerminalServer;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WebTerminalInfo {
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub access_token: String,
|
||||||
|
pub local_ip: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_local_ip() -> Option<String> {
|
||||||
|
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
|
||||||
|
let local_ip = get_local_ip();
|
||||||
|
let url = if running {
|
||||||
|
local_ip
|
||||||
|
.as_ref()
|
||||||
|
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
WebTerminalInfo {
|
||||||
|
running,
|
||||||
|
port,
|
||||||
|
access_token: token.to_string(),
|
||||||
|
local_ip,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if server_guard.is_some() {
|
||||||
|
return Err("Web terminal server is already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
|
||||||
|
// Auto-generate token if not set
|
||||||
|
if settings.web_terminal.access_token.is_none() {
|
||||||
|
settings.web_terminal.access_token = Some(generate_token());
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let port = settings.web_terminal.port;
|
||||||
|
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
port,
|
||||||
|
token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
*server_guard = Some(server);
|
||||||
|
|
||||||
|
// Mark as enabled in settings
|
||||||
|
if !settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(true, port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as disabled in settings
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = false;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let server_guard = state.web_terminal_server.lock().await;
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let running = server_guard.is_some();
|
||||||
|
Ok(build_info(running, settings.web_terminal.port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
// Stop current server if running
|
||||||
|
{
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token and save
|
||||||
|
let new_token = generate_token();
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
settings.web_terminal.access_token = Some(new_token.clone());
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
|
// Restart if was enabled
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
settings.web_terminal.port,
|
||||||
|
new_token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
*server_guard = Some(server);
|
||||||
|
return Ok(build_info(true, settings.web_terminal.port, &new_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(false, settings.web_terminal.port, &new_token))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,78 @@
|
|||||||
use bollard::container::UploadToContainerOptions;
|
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||||
|
/// attached — the raw duplex halves, before any policy about what to do with
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// This is the single place in the codebase that knows how to open an attached
|
||||||
|
/// exec. Both consumers are built on it:
|
||||||
|
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||||
|
/// which pump bytes through mpsc channels and a callback.
|
||||||
|
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||||
|
/// straight between a host TCP socket and these halves.
|
||||||
|
///
|
||||||
|
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||||
|
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||||
|
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||||
|
/// spliced into the proxied byte stream.
|
||||||
|
pub struct AttachedExec {
|
||||||
|
pub exec_id: String,
|
||||||
|
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||||
|
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||||
|
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||||
|
/// this app opens.
|
||||||
|
pub async fn create_attached_exec(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdin: Some(true),
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
tty: Some(tty),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
working_dir: Some("/workspace".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let exec_id = exec.id.clone();
|
||||||
|
|
||||||
|
match docker
|
||||||
|
.start_exec(&exec_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?
|
||||||
|
{
|
||||||
|
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
}),
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
@@ -80,37 +145,15 @@ impl ExecSessionManager {
|
|||||||
where
|
where
|
||||||
F: Fn(Vec<u8>) + Send + 'static,
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
{
|
{
|
||||||
let docker = get_docker()?;
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
let exec = docker
|
mut output,
|
||||||
.create_exec(
|
mut input,
|
||||||
container_id,
|
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||||
CreateExecOptions {
|
|
||||||
attach_stdin: Some(true),
|
|
||||||
attach_stdout: Some(true),
|
|
||||||
attach_stderr: Some(true),
|
|
||||||
tty: Some(tty),
|
|
||||||
cmd: Some(cmd),
|
|
||||||
user: Some("claude".to_string()),
|
|
||||||
working_dir: Some("/workspace".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
|
||||||
|
|
||||||
let exec_id = exec.id.clone();
|
|
||||||
|
|
||||||
let result = docker
|
|
||||||
.start_exec(&exec_id, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
|
||||||
|
|
||||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
match result {
|
|
||||||
StartExecResults::Attached { mut output, mut input } => {
|
|
||||||
// Output reader task
|
// Output reader task
|
||||||
let session_id_clone = session_id.to_string();
|
let session_id_clone = session_id.to_string();
|
||||||
let shutdown_tx_clone = shutdown_tx.clone();
|
let shutdown_tx_clone = shutdown_tx.clone();
|
||||||
@@ -151,11 +194,6 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
StartExecResults::Detached => {
|
|
||||||
return Err("Exec started in detached mode".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
@@ -279,8 +317,90 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||||
|
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||||
|
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||||
|
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||||
|
/// a size check and the read can't desync the header and corrupt the archive.
|
||||||
|
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||||
|
pub async fn upload_host_file_to_container(
|
||||||
|
container_id: &str,
|
||||||
|
host_path: &str,
|
||||||
|
dest_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let host_path = host_path.to_string();
|
||||||
|
let dest_name = dest_name.to_string();
|
||||||
|
let dest_for_blk = dest_name.clone();
|
||||||
|
|
||||||
|
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
|
let data = std::fs::read(&host_path)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, &dest_for_blk, &data[..])
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(tar_buf)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
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/{}", dest_name))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||||
|
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||||
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
|
/// exposed via `ps`.
|
||||||
|
///
|
||||||
|
/// NOTE: the command's exit code is NOT checked — callers that need to know
|
||||||
|
/// whether the command succeeded should use `exec_oneshot_env_status`.
|
||||||
|
pub async fn exec_oneshot_env(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
exec_oneshot_env_status(container_id, cmd, env)
|
||||||
|
.await
|
||||||
|
.map(|(output, _exit_code)| output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
|
||||||
|
/// success). The returned string contains both stdout and stderr, interleaved
|
||||||
|
/// in arrival order, which is useful for surfacing failure detail.
|
||||||
|
pub async fn exec_oneshot_env_status(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -290,6 +410,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
user: Some("claude".to_string()),
|
user: Some("claude".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -302,17 +423,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut combined = String::new();
|
||||||
match result {
|
match result {
|
||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
let mut stdout = String::new();
|
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(stdout)
|
|
||||||
}
|
}
|
||||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||||
|
// final exit_code populated yet, so poll until the exec reports finished.
|
||||||
|
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok((combined, exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||||
|
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||||
|
/// doesn't report finished within ~1s — which shouldn't happen once its output
|
||||||
|
/// stream has drained).
|
||||||
|
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||||
|
let docker = get_docker().ok()?;
|
||||||
|
for _ in 0..40 {
|
||||||
|
match docker.inspect_exec(exec_id).await {
|
||||||
|
Ok(info) => {
|
||||||
|
if info.running != Some(true) {
|
||||||
|
// Finished: use the reported code (default 0 if somehow absent).
|
||||||
|
return Some(info.exit_code.unwrap_or(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
|
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
|
||||||
use bollard::models::ImageSummary;
|
use bollard::models::ImageSummary;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use include_dir::{include_dir, Dir};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ 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 SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||||
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||||
|
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
|
||||||
|
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
|
||||||
|
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
|
||||||
|
|
||||||
|
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
|
||||||
|
|
||||||
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()?;
|
||||||
@@ -31,6 +37,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
|||||||
Ok(!images.is_empty())
|
Ok(!images.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
|
||||||
|
/// or None if the image doesn't exist locally or has no repo digests.
|
||||||
|
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"reference".to_string(),
|
||||||
|
vec![image_name.to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let images: Vec<ImageSummary> = docker
|
||||||
|
.list_images(Some(ListImagesOptions {
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(img) = images.first() {
|
||||||
|
// RepoDigests contains entries like "registry/repo@sha256:abc..."
|
||||||
|
if let Some(digest_str) = img.repo_digests.first() {
|
||||||
|
// Extract the sha256:... part after '@'
|
||||||
|
if let Some(pos) = digest_str.find('@') {
|
||||||
|
return Ok(Some(digest_str[pos + 1..].to_string()));
|
||||||
|
}
|
||||||
|
return Ok(Some(digest_str.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: Fn(String) + Send + 'static,
|
F: Fn(String) + Send + 'static,
|
||||||
@@ -118,38 +156,48 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_file_to_archive(
|
||||||
|
archive: &mut tar::Builder<&mut Vec<u8>>,
|
||||||
|
path: &str,
|
||||||
|
content: &[u8],
|
||||||
|
mode: u32,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_mode(mode);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_embedded_dir(
|
||||||
|
archive: &mut tar::Builder<&mut Vec<u8>>,
|
||||||
|
dir: &Dir,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
for file in dir.files() {
|
||||||
|
let path = format!("{}/{}", prefix, file.path().display());
|
||||||
|
append_file_to_archive(archive, &path, file.contents(), 0o644)?;
|
||||||
|
}
|
||||||
|
for subdir in dir.dirs() {
|
||||||
|
append_embedded_dir(archive, subdir, prefix)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
{
|
{
|
||||||
let mut archive = tar::Builder::new(&mut buf);
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
append_file_to_archive(&mut archive, "Dockerfile", DOCKERFILE.as_bytes(), 0o644)?;
|
||||||
let mut header = tar::Header::new_gnu();
|
append_file_to_archive(&mut archive, "entrypoint.sh", ENTRYPOINT.as_bytes(), 0o755)?;
|
||||||
header.set_size(dockerfile_bytes.len() as u64);
|
append_file_to_archive(&mut archive, "triple-c-scheduler", SCHEDULER.as_bytes(), 0o755)?;
|
||||||
header.set_mode(0o644);
|
append_file_to_archive(&mut archive, "triple-c-task-runner", TASK_RUNNER.as_bytes(), 0o755)?;
|
||||||
header.set_cksum();
|
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
|
||||||
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
|
||||||
|
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
|
||||||
|
|
||||||
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
|
||||||
let mut header = tar::Header::new_gnu();
|
|
||||||
header.set_size(entrypoint_bytes.len() as u64);
|
|
||||||
header.set_mode(0o755);
|
|
||||||
header.set_cksum();
|
|
||||||
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()?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! One-release migration shim for the removed built-in MCP feature.
|
||||||
|
//!
|
||||||
|
//! Older releases created a per-project user-defined bridge network
|
||||||
|
//! (`triple-c-net-<projectId>`) plus one container per Docker-backed MCP
|
||||||
|
//! server, and attached the project container to that network. Now that MCP
|
||||||
|
//! support is gone, those leftovers have to be torn down — a container whose
|
||||||
|
//! `NetworkMode` names a network that no longer exists refuses to start, so
|
||||||
|
//! the cleanup is paired with a forced container recreation (see
|
||||||
|
//! `container_needs_recreation`).
|
||||||
|
//!
|
||||||
|
//! Everything here is best-effort: failures are logged and never abort the
|
||||||
|
//! caller, and absent resources are a silent no-op. This module can be deleted
|
||||||
|
//! a release after all users have migrated.
|
||||||
|
|
||||||
|
use bollard::container::{ListContainersOptions, RemoveContainerOptions};
|
||||||
|
use bollard::network::InspectNetworkOptions;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// Network name used by the old MCP implementation for a project.
|
||||||
|
fn legacy_network_name(project_id: &str) -> String {
|
||||||
|
format!("triple-c-net-{}", project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-remove every leftover MCP server container.
|
||||||
|
///
|
||||||
|
/// Matched by the `triple-c.mcp-server` label rather than by name, so
|
||||||
|
/// containers survive even if the MCP server definitions they came from are
|
||||||
|
/// already gone from storage. Best-effort: errors are logged and skipped.
|
||||||
|
pub async fn remove_legacy_mcp_containers(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy MCP container cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"label".to_string(),
|
||||||
|
vec!["triple-c.mcp-server".to_string()],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = match docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to list legacy MCP containers: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
let Some(id) = container.id else { continue };
|
||||||
|
match docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::info!("Removed legacy MCP container {}", id),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the old per-project Docker network, disconnecting any remaining
|
||||||
|
/// members first (a network with attached endpoints cannot be deleted).
|
||||||
|
///
|
||||||
|
/// Silent no-op when the network does not exist. Best-effort: errors are
|
||||||
|
/// logged and never propagated.
|
||||||
|
pub async fn remove_legacy_project_network(project_id: &str) {
|
||||||
|
let docker = match get_docker() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping legacy network cleanup for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let network_name = legacy_network_name(project_id);
|
||||||
|
|
||||||
|
// Inspect to discover connected containers; absence means nothing to do.
|
||||||
|
let info = match docker
|
||||||
|
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("Legacy network {} not present, nothing to do", network_name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(containers) = info.containers {
|
||||||
|
for container_id in containers.into_keys() {
|
||||||
|
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
|
||||||
|
container: container_id.clone(),
|
||||||
|
force: true,
|
||||||
|
};
|
||||||
|
if let Err(e) = docker
|
||||||
|
.disconnect_network(&network_name, disconnect_opts)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to disconnect container {} from legacy network {}: {}",
|
||||||
|
container_id,
|
||||||
|
network_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match docker.remove_network(&network_name).await {
|
||||||
|
Ok(_) => log::info!("Removed legacy Docker network {}", network_name),
|
||||||
|
Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ pub mod client;
|
|||||||
pub mod container;
|
pub mod container;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod exec;
|
pub mod exec;
|
||||||
pub mod network;
|
pub mod legacy_cleanup;
|
||||||
|
pub mod stt;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use stt::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use client::*;
|
pub use client::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -13,4 +16,4 @@ pub use image::*;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use network::*;
|
pub use legacy_cleanup::*;
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
use bollard::network::{CreateNetworkOptions, InspectNetworkOptions};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::client::get_docker;
|
|
||||||
|
|
||||||
/// Network name for a project's MCP containers.
|
|
||||||
fn project_network_name(project_id: &str) -> String {
|
|
||||||
format!("triple-c-net-{}", project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure a Docker bridge network exists for the project.
|
|
||||||
/// Returns the network name.
|
|
||||||
pub async fn ensure_project_network(project_id: &str) -> Result<String, String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
let network_name = project_network_name(project_id);
|
|
||||||
|
|
||||||
// Check if network already exists
|
|
||||||
match docker
|
|
||||||
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
log::debug!("Network {} already exists", network_name);
|
|
||||||
return Ok(network_name);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Network doesn't exist, create it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = CreateNetworkOptions {
|
|
||||||
name: network_name.clone(),
|
|
||||||
driver: "bridge".to_string(),
|
|
||||||
labels: HashMap::from([
|
|
||||||
("triple-c.managed".to_string(), "true".to_string()),
|
|
||||||
("triple-c.project-id".to_string(), project_id.to_string()),
|
|
||||||
]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.create_network(options)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to create network {}: {}", network_name, e))?;
|
|
||||||
|
|
||||||
log::info!("Created Docker network {}", network_name);
|
|
||||||
Ok(network_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect a container to the project network.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn connect_container_to_network(
|
|
||||||
container_id: &str,
|
|
||||||
network_name: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
|
|
||||||
let config = bollard::network::ConnectNetworkOptions {
|
|
||||||
container: container_id.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.connect_network(network_name, config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"Failed to connect container {} to network {}: {}",
|
|
||||||
container_id, network_name, e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Connected container {} to network {}",
|
|
||||||
container_id,
|
|
||||||
network_name
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the project network (best-effort). Disconnects all containers first.
|
|
||||||
pub async fn remove_project_network(project_id: &str) -> Result<(), String> {
|
|
||||||
let docker = get_docker()?;
|
|
||||||
let network_name = project_network_name(project_id);
|
|
||||||
|
|
||||||
// Inspect to get connected containers
|
|
||||||
let info = match docker
|
|
||||||
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(info) => info,
|
|
||||||
Err(_) => {
|
|
||||||
log::debug!(
|
|
||||||
"Network {} not found, nothing to remove",
|
|
||||||
network_name
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disconnect all containers
|
|
||||||
if let Some(containers) = info.containers {
|
|
||||||
for (container_id, _) in containers {
|
|
||||||
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
|
|
||||||
container: container_id.clone(),
|
|
||||||
force: true,
|
|
||||||
};
|
|
||||||
if let Err(e) = docker
|
|
||||||
.disconnect_network(&network_name, disconnect_opts)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!(
|
|
||||||
"Failed to disconnect container {} from network {}: {}",
|
|
||||||
container_id,
|
|
||||||
network_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the network
|
|
||||||
match docker.remove_network(&network_name).await {
|
|
||||||
Ok(_) => log::info!("Removed Docker network {}", network_name),
|
|
||||||
Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
use bollard::container::{
|
||||||
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
|
StartContainerOptions, StopContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::image::BuildImageOptions;
|
||||||
|
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::client::get_docker;
|
||||||
|
use crate::models::app_settings::{SttSettings, SttStatus};
|
||||||
|
|
||||||
|
const STT_CONTAINER_NAME: &str = "triple-c-stt";
|
||||||
|
const STT_MODEL_VOLUME: &str = "triple-c-stt-model-cache";
|
||||||
|
const STT_REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-stt:latest";
|
||||||
|
const STT_LOCAL_IMAGE: &str = "triple-c-stt:latest";
|
||||||
|
const STT_DOCKERFILE: &str = include_str!("../../../../stt-container/Dockerfile");
|
||||||
|
const STT_SERVER: &str = include_str!("../../../../stt-container/server.py");
|
||||||
|
|
||||||
|
pub async fn get_stt_status(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||||
|
let image_exists = super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false)
|
||||||
|
|| super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false);
|
||||||
|
|
||||||
|
let (container_exists, running, model) = match find_stt_container().await? {
|
||||||
|
Some((_, state, env_model)) => (true, state == "running", env_model),
|
||||||
|
None => (false, false, settings.model.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SttStatus {
|
||||||
|
container_exists,
|
||||||
|
running,
|
||||||
|
port: settings.port,
|
||||||
|
model,
|
||||||
|
image_exists,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_stt_container() -> Result<Option<(String, String, String)>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
|
"name".to_string(),
|
||||||
|
vec![format!("/{}", STT_CONTAINER_NAME)],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let containers = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(container) = containers.first() {
|
||||||
|
let id = container.id.clone().unwrap_or_default();
|
||||||
|
let state = container.state.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract WHISPER_MODEL from container env
|
||||||
|
let model = container
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.get("triple-c.stt.model"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "tiny".to_string());
|
||||||
|
|
||||||
|
return Ok(Some((id, state, model)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_stt_container(settings: &SttSettings) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Try local image first, fall back to registry
|
||||||
|
let image = if super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false) {
|
||||||
|
STT_LOCAL_IMAGE.to_string()
|
||||||
|
} else if super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false) {
|
||||||
|
STT_REGISTRY_IMAGE.to_string()
|
||||||
|
} else {
|
||||||
|
return Err("STT image not found. Please build or pull the image first.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let port_binding = PortBinding {
|
||||||
|
host_ip: Some("127.0.0.1".to_string()),
|
||||||
|
host_port: Some(settings.port.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut port_bindings = HashMap::new();
|
||||||
|
port_bindings.insert(
|
||||||
|
"9876/tcp".to_string(),
|
||||||
|
Some(vec![port_binding]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let host_config = HostConfig {
|
||||||
|
port_bindings: Some(port_bindings),
|
||||||
|
mounts: Some(vec![Mount {
|
||||||
|
target: Some("/root/.cache/huggingface".to_string()),
|
||||||
|
source: Some(STT_MODEL_VOLUME.to_string()),
|
||||||
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.stt.model".to_string(),
|
||||||
|
settings.model.clone(),
|
||||||
|
);
|
||||||
|
labels.insert(
|
||||||
|
"triple-c.stt.port".to_string(),
|
||||||
|
settings.port.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
image: Some(image),
|
||||||
|
env: Some(vec![format!("WHISPER_MODEL={}", settings.model)]),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
labels: Some(labels),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CreateContainerOptions {
|
||||||
|
name: STT_CONTAINER_NAME,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = docker
|
||||||
|
.create_container(Some(options), config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create STT container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_stt_running(settings: &SttSettings) -> Result<SttStatus, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Check if container exists and if settings match
|
||||||
|
if let Some((id, state, model)) = find_stt_container().await? {
|
||||||
|
let needs_recreate = model != settings.model;
|
||||||
|
|
||||||
|
if needs_recreate {
|
||||||
|
// Settings changed, recreate
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(&id, None::<StopContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||||
|
}
|
||||||
|
docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to remove STT container: {}", e))?;
|
||||||
|
} else if state == "running" {
|
||||||
|
return get_stt_status(settings).await;
|
||||||
|
} else {
|
||||||
|
// Container exists but stopped, start it
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||||
|
return get_stt_status(settings).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and start new container
|
||||||
|
let id = create_stt_container(settings).await?;
|
||||||
|
docker
|
||||||
|
.start_container(&id, None::<StartContainerOptions<String>>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start STT container: {}", e))?;
|
||||||
|
|
||||||
|
get_stt_status(settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_stt_container() -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
if let Some((id, state, _)) = find_stt_container().await? {
|
||||||
|
if state == "running" {
|
||||||
|
docker
|
||||||
|
.stop_container(&id, None::<StopContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
super::image::pull_image(STT_REGISTRY_IMAGE, on_progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_stt_image<F>(on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let tar_bytes = create_stt_build_context()
|
||||||
|
.map_err(|e| format!("Failed to create STT build context: {}", e))?;
|
||||||
|
|
||||||
|
let options = BuildImageOptions {
|
||||||
|
t: STT_LOCAL_IMAGE,
|
||||||
|
rm: true,
|
||||||
|
forcerm: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||||
|
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(output) => {
|
||||||
|
if let Some(stream) = output.stream {
|
||||||
|
on_progress(stream);
|
||||||
|
}
|
||||||
|
if let Some(error) = output.error {
|
||||||
|
return Err(format!("Build error: {}", error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_stt_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
|
let mut dockerfile_header = tar::Header::new_gnu();
|
||||||
|
dockerfile_header.set_size(STT_DOCKERFILE.len() as u64);
|
||||||
|
dockerfile_header.set_mode(0o644);
|
||||||
|
dockerfile_header.set_cksum();
|
||||||
|
archive.append_data(&mut dockerfile_header, "Dockerfile", STT_DOCKERFILE.as_bytes())?;
|
||||||
|
|
||||||
|
let mut server_header = tar::Header::new_gnu();
|
||||||
|
server_header.set_size(STT_SERVER.len() as u64);
|
||||||
|
server_header.set_mode(0o644);
|
||||||
|
server_header.set_cksum();
|
||||||
|
archive.append_data(&mut server_header, "server.py", STT_SERVER.as_bytes())?;
|
||||||
|
|
||||||
|
archive.finish()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = buf.flush();
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
|
||||||
|
// installed on the host and, when missing, offering to install it for the user.
|
||||||
|
//
|
||||||
|
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
|
||||||
|
// Windows. On every platform we also surface an official documentation URL so
|
||||||
|
// users without a recognised package manager can install manually.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod platform;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InstallOptions {
|
||||||
|
/// "linux" | "macos" | "windows" | "unknown"
|
||||||
|
pub os: String,
|
||||||
|
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
|
||||||
|
pub product_name: String,
|
||||||
|
/// Whether we can kick off a one-click install with what's on this machine.
|
||||||
|
pub can_auto_install: bool,
|
||||||
|
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
|
||||||
|
pub auto_install_method: Option<String>,
|
||||||
|
/// If auto-install isn't possible, a human-readable reason to show the user.
|
||||||
|
pub auto_install_blocker: Option<String>,
|
||||||
|
/// Official documentation URL for manual install.
|
||||||
|
pub docs_url: String,
|
||||||
|
/// Ordered manual install steps (plain text lines).
|
||||||
|
pub manual_steps: Vec<String>,
|
||||||
|
/// Notes to display after a successful auto-install (e.g. log out/back in).
|
||||||
|
pub post_install_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_install_options() -> InstallOptions {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
platform::linux_options()
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
platform::macos_options()
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
platform::windows_options()
|
||||||
|
} else {
|
||||||
|
InstallOptions {
|
||||||
|
os: "unknown".into(),
|
||||||
|
product_name: "Docker".into(),
|
||||||
|
can_auto_install: false,
|
||||||
|
auto_install_method: None,
|
||||||
|
auto_install_blocker: Some("Unsupported operating system".into()),
|
||||||
|
docs_url: "https://docs.docker.com/get-docker/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Visit the Docker documentation and follow the install guide for your OS.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::InstallOptions;
|
||||||
|
|
||||||
|
const PROGRESS_EVENT: &str = "docker-install-progress";
|
||||||
|
|
||||||
|
fn which(cmd: &str) -> bool {
|
||||||
|
find_on_path(cmd).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search PATH for an executable, plus a handful of well-known locations that
|
||||||
|
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
|
||||||
|
fn find_on_path(cmd: &str) -> Option<PathBuf> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let extra: &[&str] = &[
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/bin",
|
||||||
|
];
|
||||||
|
#[cfg(windows)]
|
||||||
|
let extra: &[&str] = &[];
|
||||||
|
|
||||||
|
if let Ok(path) = std::env::var("PATH") {
|
||||||
|
let sep = if cfg!(windows) { ';' } else { ':' };
|
||||||
|
for dir in path.split(sep).chain(extra.iter().copied()) {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
for ext in ["exe", "cmd", "bat"] {
|
||||||
|
let mut with_ext = candidate.clone();
|
||||||
|
with_ext.set_extension(ext);
|
||||||
|
if with_ext.is_file() {
|
||||||
|
return Some(with_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in extra {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
let app_out = app.clone();
|
||||||
|
let out_task = tokio::spawn(async move {
|
||||||
|
if let Some(out) = stdout {
|
||||||
|
let mut lines = BufReader::new(out).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_out.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_err = app.clone();
|
||||||
|
let err_task = tokio::spawn(async move {
|
||||||
|
if let Some(err) = stderr {
|
||||||
|
let mut lines = BufReader::new(err).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_err.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("install process failed: {}", e))?;
|
||||||
|
let _ = out_task.await;
|
||||||
|
let _ = err_task.await;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"installer exited with status {}",
|
||||||
|
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Linux ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn linux_options() -> InstallOptions {
|
||||||
|
let has_pkexec = which("pkexec");
|
||||||
|
let has_curl = which("curl");
|
||||||
|
|
||||||
|
let (can_auto, blocker) = match (has_pkexec, has_curl) {
|
||||||
|
(true, true) => (true, None),
|
||||||
|
(false, _) => (
|
||||||
|
false,
|
||||||
|
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
(_, false) => (
|
||||||
|
false,
|
||||||
|
Some("curl not found — install curl or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
InstallOptions {
|
||||||
|
os: "linux".into(),
|
||||||
|
product_name: "Docker Engine".into(),
|
||||||
|
can_auto_install: can_auto,
|
||||||
|
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
|
||||||
|
auto_install_blocker: blocker,
|
||||||
|
docs_url: "https://docs.docker.com/engine/install/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Open a terminal.".into(),
|
||||||
|
"Run: curl -fsSL https://get.docker.com | sh".into(),
|
||||||
|
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
|
||||||
|
"Log out and log back in for group changes to take effect.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
|
||||||
|
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
// Grab the current username so pkexec (which runs as root) can add the
|
||||||
|
// original invoking user to the docker group.
|
||||||
|
let invoking_user = std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("LOGNAME"))
|
||||||
|
.map_err(|_| "could not determine invoking username".to_string())?;
|
||||||
|
|
||||||
|
// Write a self-contained installer script to a temp file. Running the
|
||||||
|
// Docker convenience script then appending the user to the docker group
|
||||||
|
// and enabling the service.
|
||||||
|
let script = format!(
|
||||||
|
r#"#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "[triple-c] Downloading Docker install script..."
|
||||||
|
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Running Docker install script (may take a few minutes)..."
|
||||||
|
sh /tmp/triple-c-get-docker.sh
|
||||||
|
rm -f /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Adding {user} to docker group..."
|
||||||
|
usermod -aG docker "{user}" || true
|
||||||
|
echo "[triple-c] Enabling docker service..."
|
||||||
|
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
|
||||||
|
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
|
||||||
|
"#,
|
||||||
|
user = invoking_user
|
||||||
|
);
|
||||||
|
|
||||||
|
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
|
||||||
|
tokio::fs::write(&script_path, script)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to write install script: {}", e))?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Requesting administrator privileges via pkexec..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
let child = Command::new("pkexec")
|
||||||
|
.arg("sh")
|
||||||
|
.arg(&script_path)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
|
||||||
|
|
||||||
|
let result = stream(app, child).await;
|
||||||
|
let _ = tokio::fs::remove_file(&script_path).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── macOS ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn macos_options() -> InstallOptions {
|
||||||
|
let has_brew = which("brew");
|
||||||
|
InstallOptions {
|
||||||
|
os: "macos".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_brew,
|
||||||
|
auto_install_method: if has_brew { Some("brew".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_brew {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Homebrew not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .dmg from the official site.".into(),
|
||||||
|
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker socket is available, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
|
||||||
|
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let brew = find_on_path("brew")
|
||||||
|
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Running: {} install --cask rancher", brew.display()),
|
||||||
|
);
|
||||||
|
let child = Command::new(&brew)
|
||||||
|
.args(["install", "--cask", "rancher"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch brew: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Windows ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn windows_options() -> InstallOptions {
|
||||||
|
let has_winget = which("winget");
|
||||||
|
InstallOptions {
|
||||||
|
os: "windows".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_winget,
|
||||||
|
auto_install_method: if has_winget { Some("winget".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_winget {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("winget not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .msi from the official site.".into(),
|
||||||
|
"Run the installer and accept the WSL2 prompts if asked.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker engine is running, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
|
||||||
|
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
|
||||||
|
);
|
||||||
|
let child = Command::new("winget")
|
||||||
|
.args([
|
||||||
|
"install",
|
||||||
|
"--id",
|
||||||
|
"SUSE.RancherDesktop",
|
||||||
|
"-e",
|
||||||
|
"--accept-package-agreements",
|
||||||
|
"--accept-source-agreements",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch winget: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
run_linux_install(app).await
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
run_macos_install(app).await
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
run_windows_install(app).await
|
||||||
|
} else {
|
||||||
|
Err("auto-install is not supported on this OS".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-24
@@ -1,46 +1,53 @@
|
|||||||
|
mod auth_bridge;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod install_helper;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
pub mod web_terminal;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use auth_bridge::AuthBridgeManager;
|
||||||
use docker::exec::ExecSessionManager;
|
use docker::exec::ExecSessionManager;
|
||||||
use storage::projects_store::ProjectsStore;
|
use storage::projects_store::ProjectsStore;
|
||||||
use storage::settings_store::SettingsStore;
|
use storage::settings_store::SettingsStore;
|
||||||
use storage::mcp_store::McpStore;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use web_terminal::WebTerminalServer;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
pub settings_store: SettingsStore,
|
pub settings_store: Arc<SettingsStore>,
|
||||||
pub mcp_store: McpStore,
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
pub exec_manager: ExecSessionManager,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
logging::init();
|
logging::init();
|
||||||
|
|
||||||
let projects_store = match ProjectsStore::new() {
|
let projects_store = Arc::new(match ProjectsStore::new() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to initialize projects store: {}", e);
|
log::error!("Failed to initialize projects store: {}", e);
|
||||||
panic!("Failed to initialize projects store: {}", e);
|
panic!("Failed to initialize projects store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
let settings_store = match SettingsStore::new() {
|
let settings_store = Arc::new(match SettingsStore::new() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to initialize settings store: {}", e);
|
log::error!("Failed to initialize settings store: {}", e);
|
||||||
panic!("Failed to initialize settings store: {}", e);
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
let mcp_store = match McpStore::new() {
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||||
Ok(s) => s,
|
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to initialize MCP store: {}", e);
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||||
panic!("Failed to initialize MCP store: {}", e);
|
let projects_store_setup = projects_store.clone();
|
||||||
}
|
let settings_store_setup = settings_store.clone();
|
||||||
};
|
let exec_manager_setup = exec_manager.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
@@ -49,10 +56,11 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
mcp_store,
|
exec_manager,
|
||||||
exec_manager: ExecSessionManager::new(),
|
auth_bridge,
|
||||||
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
Ok(icon) => {
|
Ok(icon) => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -63,13 +71,78 @@ pub fn run() {
|
|||||||
log::error!("Failed to load window icon: {}", e);
|
log::error!("Failed to load window icon: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-start web terminal server if enabled in settings
|
||||||
|
let settings = settings_store_setup.get();
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
if let Some(token) = &settings.web_terminal.access_token {
|
||||||
|
let token = token.clone();
|
||||||
|
let port = settings.web_terminal.port;
|
||||||
|
let exec_mgr = exec_manager_setup.clone();
|
||||||
|
let proj_store = projects_store_setup.clone();
|
||||||
|
let set_store = settings_store_setup.clone();
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let web_server_mutex = state.web_terminal_server.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match WebTerminalServer::start(
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
exec_mgr,
|
||||||
|
proj_store,
|
||||||
|
set_store,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(server) => {
|
||||||
|
let mut guard = web_server_mutex.lock().await;
|
||||||
|
*guard = Some(server);
|
||||||
|
log::info!("Web terminal auto-started on port {}", port);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to auto-start web terminal: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start STT container if enabled in settings
|
||||||
|
if settings.stt.enabled {
|
||||||
|
let stt_settings = settings.stt.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match docker::stt::ensure_stt_running(&stt_settings).await {
|
||||||
|
Ok(status) => {
|
||||||
|
if status.running {
|
||||||
|
log::info!("STT container auto-started on port {}", stt_settings.port);
|
||||||
|
} else {
|
||||||
|
log::warn!("STT auto-start: container not running after ensure_stt_running");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to auto-start STT container: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
let state = window.state::<AppState>();
|
let state = window.state::<AppState>();
|
||||||
tauri::async_runtime::block_on(async {
|
tauri::async_runtime::block_on(async {
|
||||||
|
// Stop web terminal server
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
// Stop STT container
|
||||||
|
let _ = docker::stt::stop_stt_container().await;
|
||||||
|
// Close all exec sessions
|
||||||
state.exec_manager.close_all_sessions().await;
|
state.exec_manager.close_all_sessions().await;
|
||||||
|
// Release every host loopback port held by the auth bridge
|
||||||
|
state.auth_bridge.stop_all().await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -89,6 +162,15 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
commands::project_commands::reconcile_project_statuses,
|
||||||
|
// Auth bridge
|
||||||
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||||
|
commands::auth_bridge_commands::get_auth_bridge_status,
|
||||||
|
// Shared Claude Code auth token
|
||||||
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
|
commands::auth_token_commands::cancel_claude_token,
|
||||||
|
commands::auth_token_commands::has_claude_token,
|
||||||
|
commands::auth_token_commands::clear_claude_token,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
@@ -102,23 +184,51 @@ pub fn run() {
|
|||||||
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,
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
|
commands::terminal_commands::upload_host_file_to_terminal,
|
||||||
commands::terminal_commands::start_audio_bridge,
|
commands::terminal_commands::start_audio_bridge,
|
||||||
commands::terminal_commands::send_audio_data,
|
commands::terminal_commands::send_audio_data,
|
||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
// Files
|
// Files
|
||||||
commands::file_commands::list_container_files,
|
commands::file_commands::list_container_files,
|
||||||
commands::file_commands::download_container_file,
|
commands::file_commands::download_container_file,
|
||||||
|
commands::file_commands::download_container_backup,
|
||||||
commands::file_commands::upload_file_to_container,
|
commands::file_commands::upload_file_to_container,
|
||||||
// MCP
|
|
||||||
commands::mcp_commands::list_mcp_servers,
|
|
||||||
commands::mcp_commands::add_mcp_server,
|
|
||||||
commands::mcp_commands::update_mcp_server,
|
|
||||||
commands::mcp_commands::remove_mcp_server,
|
|
||||||
// AWS
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// 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,
|
||||||
|
commands::update_commands::check_image_update,
|
||||||
|
// Help
|
||||||
|
commands::help_commands::get_help_content,
|
||||||
|
// Install helper
|
||||||
|
commands::install_helper_commands::detect_install_options,
|
||||||
|
commands::install_helper_commands::run_docker_install,
|
||||||
|
// Web Terminal
|
||||||
|
commands::web_terminal_commands::start_web_terminal,
|
||||||
|
commands::web_terminal_commands::stop_web_terminal,
|
||||||
|
commands::web_terminal_commands::get_web_terminal_status,
|
||||||
|
commands::web_terminal_commands::regenerate_web_terminal_token,
|
||||||
|
// STT
|
||||||
|
commands::stt_commands::get_stt_status,
|
||||||
|
commands::stt_commands::start_stt,
|
||||||
|
commands::stt_commands::stop_stt,
|
||||||
|
commands::stt_commands::build_stt_image,
|
||||||
|
commands::stt_commands::pull_stt_image,
|
||||||
|
commands::stt_commands::transcribe_audio,
|
||||||
|
// Container introspection (sessions / capabilities / scheduler)
|
||||||
|
commands::inspect_commands::list_claude_sessions,
|
||||||
|
commands::inspect_commands::resume_session_command,
|
||||||
|
commands::inspect_commands::list_container_capabilities,
|
||||||
|
commands::inspect_commands::list_scheduled_tasks,
|
||||||
|
commands::inspect_commands::add_scheduled_task,
|
||||||
|
commands::inspect_commands::update_scheduled_task,
|
||||||
|
commands::inspect_commands::get_scheduled_task_log,
|
||||||
|
commands::inspect_commands::set_scheduled_task_enabled,
|
||||||
|
commands::inspect_commands::run_scheduled_task_now,
|
||||||
|
commands::inspect_commands::remove_scheduled_task,
|
||||||
|
commands::inspect_commands::get_scheduler_notifications,
|
||||||
|
commands::inspect_commands::clear_scheduler_notifications,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::project::EnvVar;
|
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
@@ -32,6 +32,8 @@ pub struct GlobalAwsSettings {
|
|||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aws_region: Option<String>,
|
pub aws_region: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GlobalAwsSettings {
|
impl Default for GlobalAwsSettings {
|
||||||
@@ -40,10 +42,27 @@ impl Default for GlobalAwsSettings {
|
|||||||
aws_config_path: None,
|
aws_config_path: None,
|
||||||
aws_profile: None,
|
aws_profile: None,
|
||||||
aws_region: None,
|
aws_region: None,
|
||||||
|
default_model_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GlobalOllamaSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GlobalOpenAiCompatibleSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_model_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -60,6 +79,10 @@ pub struct AppSettings {
|
|||||||
pub custom_image_name: Option<String>,
|
pub custom_image_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_ollama: GlobalOllamaSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
|
||||||
#[serde(default = "default_global_instructions")]
|
#[serde(default = "default_global_instructions")]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -72,6 +95,78 @@ pub struct AppSettings {
|
|||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_microphone: Option<String>,
|
pub default_microphone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dismissed_image_digest: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_terminal: WebTerminalSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stt: SttSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stt_model() -> String {
|
||||||
|
"tiny".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stt_port() -> u16 {
|
||||||
|
9876
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SttSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_stt_model")]
|
||||||
|
pub model: String,
|
||||||
|
#[serde(default = "default_stt_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SttSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
model: default_stt_model(),
|
||||||
|
port: 9876,
|
||||||
|
language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SttStatus {
|
||||||
|
pub container_exists: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub model: String,
|
||||||
|
pub image_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_web_terminal_port() -> u16 {
|
||||||
|
7681
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebTerminalSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_web_terminal_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebTerminalSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
port: 7681,
|
||||||
|
access_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -84,12 +179,18 @@ impl Default for AppSettings {
|
|||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
|
global_ollama: GlobalOllamaSettings::default(),
|
||||||
|
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
|
||||||
global_claude_instructions: default_global_instructions(),
|
global_claude_instructions: default_global_instructions(),
|
||||||
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,
|
timezone: None,
|
||||||
default_microphone: None,
|
default_microphone: None,
|
||||||
|
dismissed_image_digest: None,
|
||||||
|
web_terminal: WebTerminalSettings::default(),
|
||||||
|
stt: SttSettings::default(),
|
||||||
|
global_claude_code_settings: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub struct ContainerInfo {
|
|||||||
|
|
||||||
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
||||||
pub const IMAGE_TAG: &str = "latest";
|
pub const IMAGE_TAG: &str = "latest";
|
||||||
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
|
||||||
|
|
||||||
pub fn local_build_image_name() -> String {
|
pub fn local_build_image_name() -> String {
|
||||||
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum McpTransportType {
|
|
||||||
Stdio,
|
|
||||||
#[serde(alias = "sse")]
|
|
||||||
Http,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for McpTransportType {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Stdio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServer {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub transport_type: McpTransportType,
|
|
||||||
pub command: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub args: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub env: HashMap<String, String>,
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub headers: HashMap<String, String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub docker_image: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub container_port: Option<u16>,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpServer {
|
|
||||||
pub fn new(name: String) -> Self {
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
Self {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
name,
|
|
||||||
transport_type: McpTransportType::default(),
|
|
||||||
command: None,
|
|
||||||
args: Vec::new(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
url: None,
|
|
||||||
headers: HashMap::new(),
|
|
||||||
docker_image: None,
|
|
||||||
container_port: None,
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_docker(&self) -> bool {
|
|
||||||
self.docker_image.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mcp_container_name(&self) -> String {
|
|
||||||
format!("triple-c-mcp-{}", self.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn effective_container_port(&self) -> u16 {
|
|
||||||
self.container_port.unwrap_or(3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,8 @@ pub mod project;
|
|||||||
pub mod container_config;
|
pub mod container_config;
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
pub mod mcp_server;
|
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
pub use mcp_server::*;
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -24,6 +26,92 @@ fn default_protocol() -> String {
|
|||||||
"tcp".to_string()
|
"tcp".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_full_permissions() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `use_shared_auth_token` defaults to **on**: once the user has run
|
||||||
|
/// `claude setup-token` once, every existing Anthropic-backend project should
|
||||||
|
/// pick the token up without being edited one by one. Projects deliberately
|
||||||
|
/// pinned to their own `claude login` identity opt out.
|
||||||
|
fn default_use_shared_auth_token() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much autonomy Claude Code is granted inside the container.
|
||||||
|
///
|
||||||
|
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||||
|
/// the single definition of that mapping and must be used by every call site.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum PermissionMode {
|
||||||
|
/// Read-only planning mode.
|
||||||
|
Plan,
|
||||||
|
/// Claude Code's own default behavior (prompts for permission).
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
/// Auto-accept file edits, prompt for everything else.
|
||||||
|
AcceptEdits,
|
||||||
|
/// Skip all permission prompts.
|
||||||
|
Bypass,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionMode {
|
||||||
|
/// The CLI flags this mode adds to a `claude` invocation.
|
||||||
|
/// Defined once here so every call site stays in sync.
|
||||||
|
pub fn cli_args(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()],
|
||||||
|
PermissionMode::Default => Vec::new(),
|
||||||
|
PermissionMode::AcceptEdits => {
|
||||||
|
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
|
||||||
|
}
|
||||||
|
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var.
|
||||||
|
/// Matches the serde `camelCase` representation.
|
||||||
|
pub fn as_env_value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PermissionMode::Plan => "plan",
|
||||||
|
PermissionMode::Default => "default",
|
||||||
|
PermissionMode::AcceptEdits => "acceptEdits",
|
||||||
|
PermissionMode::Bypass => "bypass",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct ClaudeCodeSettings {
|
||||||
|
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
||||||
|
#[serde(default)]
|
||||||
|
pub tui_mode: Option<String>,
|
||||||
|
/// Effort level: None = default, Some("low"|"medium"|"high")
|
||||||
|
#[serde(default)]
|
||||||
|
pub effort: Option<String>,
|
||||||
|
/// Disable auto-scroll in fullscreen TUI mode
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_scroll_disabled: bool,
|
||||||
|
/// Enable focus mode (collapsed tool output)
|
||||||
|
#[serde(default)]
|
||||||
|
pub focus_mode: bool,
|
||||||
|
/// Show thinking summaries in responses
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_thinking_summaries: bool,
|
||||||
|
/// Enable session recap when returning to a session
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_session_recap: bool,
|
||||||
|
/// Strip credentials from subprocess environments
|
||||||
|
#[serde(default)]
|
||||||
|
pub env_scrub: bool,
|
||||||
|
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt_caching_1h: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -31,13 +119,44 @@ pub struct Project {
|
|||||||
pub paths: Vec<ProjectPath>,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
#[serde(alias = "auth_mode")]
|
||||||
|
pub backend: Backend,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub ollama_config: Option<OllamaConfig>,
|
pub ollama_config: Option<OllamaConfig>,
|
||||||
pub litellm_config: Option<LiteLlmConfig>,
|
#[serde(alias = "litellm_config")]
|
||||||
|
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sandbox_mode_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
pub mission_control_enabled: bool,
|
||||||
|
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||||
|
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||||
|
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||||
|
/// Purely host-side — it deliberately has no container-recreation label,
|
||||||
|
/// because toggling it changes nothing about the container itself.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_bridge_enabled: bool,
|
||||||
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
|
/// [`Backend::Anthropic`] and a token has actually been stored.
|
||||||
|
///
|
||||||
|
/// Defaults to **true** so a single `setup-token` run covers every project;
|
||||||
|
/// turn it off to pin a project to the identity it logged in with inside
|
||||||
|
/// its own container.
|
||||||
|
#[serde(default = "default_use_shared_auth_token")]
|
||||||
|
pub use_shared_auth_token: bool,
|
||||||
|
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
||||||
|
/// because it is the value already stored in users' `projects.json`; it is
|
||||||
|
/// the fallback in `effective_permission_mode()` so old projects keep
|
||||||
|
/// behaving identically without a data migration.
|
||||||
|
#[serde(default = "default_full_permissions")]
|
||||||
|
pub full_permissions: bool,
|
||||||
|
/// Per-project permission mode. `None` means "not set yet" → fall back to
|
||||||
|
/// the legacy `full_permissions` flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission_mode: Option<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
#[serde(skip_serializing, default)]
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
@@ -50,7 +169,10 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled_mcp_servers: Vec<String>,
|
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
/// User-defined display names for terminal tabs, keyed by session id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub renamed_session_names: HashMap<String, String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -65,23 +187,24 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// persisted in the config volume)
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// - `Ollama`: Local or remote Ollama server
|
||||||
|
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum Backend {
|
||||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
#[serde(alias = "login", alias = "api_key")]
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
Anthropic,
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
Ollama,
|
Ollama,
|
||||||
#[serde(alias = "litellm")]
|
#[serde(alias = "lite_llm", alias = "litellm")]
|
||||||
LiteLlm,
|
OpenAiCompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for Backend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Anthropic
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
@@ -118,6 +241,10 @@ pub struct BedrockConfig {
|
|||||||
pub aws_bearer_token: Option<String>,
|
pub aws_bearer_token: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub disable_prompt_caching: bool,
|
pub disable_prompt_caching: bool,
|
||||||
|
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
|
||||||
|
/// (e.g. "priority"). Empty/None means leave unset.
|
||||||
|
#[serde(default)]
|
||||||
|
pub service_tier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ollama configuration for a project.
|
/// Ollama configuration for a project.
|
||||||
@@ -130,13 +257,14 @@ pub struct OllamaConfig {
|
|||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LiteLLM gateway configuration for a project.
|
/// OpenAI Compatible endpoint configuration for a project.
|
||||||
/// LiteLLM translates Anthropic API calls to 100+ model providers.
|
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
|
||||||
|
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LiteLlmConfig {
|
pub struct OpenAiCompatibleConfig {
|
||||||
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com")
|
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// API key for the LiteLLM proxy
|
/// API key for the OpenAI-compatible endpoint
|
||||||
#[serde(skip_serializing, default)]
|
#[serde(skip_serializing, default)]
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
/// Optional model override
|
/// Optional model override
|
||||||
@@ -152,12 +280,17 @@ impl Project {
|
|||||||
paths,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
backend: Backend::default(),
|
||||||
bedrock_config: None,
|
bedrock_config: None,
|
||||||
ollama_config: None,
|
ollama_config: None,
|
||||||
litellm_config: None,
|
openai_compatible_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
@@ -165,12 +298,24 @@ impl Project {
|
|||||||
custom_env_vars: Vec::new(),
|
custom_env_vars: Vec::new(),
|
||||||
port_mappings: Vec::new(),
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
enabled_mcp_servers: Vec::new(),
|
claude_code_settings: None,
|
||||||
|
renamed_session_names: HashMap::new(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The permission mode to actually use for this project.
|
||||||
|
/// Falls back to the legacy `full_permissions` boolean when the newer
|
||||||
|
/// `permission_mode` field has never been set.
|
||||||
|
pub fn effective_permission_mode(&self) -> PermissionMode {
|
||||||
|
self.permission_mode.unwrap_or(if self.full_permissions {
|
||||||
|
PermissionMode::Bypass
|
||||||
|
} else {
|
||||||
|
PermissionMode::Default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,20 +18,31 @@ pub struct ReleaseAsset {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gitea API release response (internal).
|
/// GitHub API release response (internal).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct GiteaRelease {
|
pub struct GitHubRelease {
|
||||||
pub tag_name: String,
|
pub tag_name: String,
|
||||||
pub html_url: String,
|
pub html_url: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub assets: Vec<GiteaAsset>,
|
pub assets: Vec<GitHubAsset>,
|
||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gitea API asset response (internal).
|
/// GitHub API asset response (internal).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct GiteaAsset {
|
pub struct GitHubAsset {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub browser_download_url: String,
|
pub browser_download_url: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Info returned to the frontend about an available container image update.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageUpdateInfo {
|
||||||
|
/// The remote digest (e.g. sha256:abc...)
|
||||||
|
pub remote_digest: String,
|
||||||
|
/// The local digest, if available
|
||||||
|
pub local_digest: Option<String>,
|
||||||
|
/// When the remote image was last updated (if known)
|
||||||
|
pub remote_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::models::McpServer;
|
|
||||||
|
|
||||||
pub struct McpStore {
|
|
||||||
servers: Mutex<Vec<McpServer>>,
|
|
||||||
file_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpStore {
|
|
||||||
pub fn new() -> Result<Self, String> {
|
|
||||||
let data_dir = dirs::data_dir()
|
|
||||||
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
|
||||||
.join("triple-c");
|
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
|
||||||
|
|
||||||
let file_path = data_dir.join("mcp_servers.json");
|
|
||||||
|
|
||||||
let servers = if file_path.exists() {
|
|
||||||
match fs::read_to_string(&file_path) {
|
|
||||||
Ok(data) => {
|
|
||||||
match serde_json::from_str::<Vec<McpServer>>(&data) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e);
|
|
||||||
let backup = file_path.with_extension("json.bak");
|
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted mcp_servers.json: {}", be);
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to read mcp_servers.json: {}", e);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
servers: Mutex::new(servers),
|
|
||||||
file_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<McpServer>> {
|
|
||||||
self.servers.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save(&self, servers: &[McpServer]) -> Result<(), String> {
|
|
||||||
let data = serde_json::to_string_pretty(servers)
|
|
||||||
.map_err(|e| format!("Failed to serialize MCP servers: {}", e))?;
|
|
||||||
|
|
||||||
// Atomic write: write to temp file, then rename
|
|
||||||
let tmp_path = self.file_path.with_extension("json.tmp");
|
|
||||||
fs::write(&tmp_path, data)
|
|
||||||
.map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?;
|
|
||||||
fs::rename(&tmp_path, &self.file_path)
|
|
||||||
.map_err(|e| format!("Failed to rename MCP servers file: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<McpServer> {
|
|
||||||
self.lock().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<McpServer> {
|
|
||||||
self.lock().iter().find(|s| s.id == id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
let cloned = server.clone();
|
|
||||||
servers.push(server);
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(cloned)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) {
|
|
||||||
*s = updated.clone();
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(updated)
|
|
||||||
} else {
|
|
||||||
Err(format!("MCP server {} not found", updated.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&self, id: &str) -> Result<(), String> {
|
|
||||||
let mut servers = self.lock();
|
|
||||||
let initial_len = servers.len();
|
|
||||||
servers.retain(|s| s.id != id);
|
|
||||||
if servers.len() == initial_len {
|
|
||||||
return Err(format!("MCP server {} not found", id));
|
|
||||||
}
|
|
||||||
self.save(&servers)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
pub mod mcp_store;
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use projects_store::*;
|
pub use projects_store::*;
|
||||||
@@ -9,5 +8,3 @@ pub use projects_store::*;
|
|||||||
pub use secure::*;
|
pub use secure::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use settings_store::*;
|
pub use settings_store::*;
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use mcp_store::*;
|
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ impl ProjectsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Granular setter for the auth bridge opt-in, so toggling it can't clobber
|
||||||
|
/// concurrent edits to the rest of the project record.
|
||||||
|
pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||||
|
let mut projects = self.lock();
|
||||||
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
p.auth_bridge_enabled = enabled;
|
||||||
|
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
self.save(&projects)?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Project {} not found", project_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||||
let mut projects = self.lock();
|
let mut projects = self.lock();
|
||||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
|
//! OS keychain access, via the `keyring` crate.
|
||||||
|
//!
|
||||||
|
//! Two kinds of secret live here:
|
||||||
|
//! * **per-project** secrets (git token, AWS keys, …), keyed by project id;
|
||||||
|
//! * the **shared Claude Code OAuth token**, which is global — one
|
||||||
|
//! `claude setup-token` run authenticates every Anthropic-backend project.
|
||||||
|
//!
|
||||||
|
//! Nothing in this module ever logs a secret or folds one into an error string.
|
||||||
|
|
||||||
|
/// Keychain service for the single, global Claude Code OAuth token minted by
|
||||||
|
/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||||
|
const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token";
|
||||||
|
|
||||||
|
/// Keychain service for the token's **rotation id** — a fresh random value
|
||||||
|
/// written every time the token is stored.
|
||||||
|
///
|
||||||
|
/// Container recreation is driven off Docker labels, which anything on the host
|
||||||
|
/// can read with `docker inspect`. The token itself must obviously not go in a
|
||||||
|
/// label, and neither should a bare hash of it: a hash is a verification oracle
|
||||||
|
/// (holding a candidate token, you could confirm it). This id is not derived
|
||||||
|
/// from the token at all — it is unrelated random data that merely *changes*
|
||||||
|
/// whenever the token does, which is exactly (and only) what change detection
|
||||||
|
/// needs.
|
||||||
|
const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version";
|
||||||
|
|
||||||
|
/// Fixed account name used for every triple-c keychain entry.
|
||||||
|
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
@@ -43,3 +71,88 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shared Claude Code OAuth token (global, not per project)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a single-value keychain entry. `Ok(None)` when the entry is absent.
|
||||||
|
/// The error text names the entry, never its value.
|
||||||
|
fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("Failed to retrieve {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a keychain entry, treating "wasn't there" as success.
|
||||||
|
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the shared Claude Code OAuth token, replacing any previous one, and
|
||||||
|
/// mint a fresh rotation id so containers holding the old token are flagged for
|
||||||
|
/// recreation. Blank input is rejected rather than silently stored.
|
||||||
|
pub fn store_claude_oauth_token(token: &str) -> Result<(), String> {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty Claude authentication token.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(token)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?;
|
||||||
|
|
||||||
|
// Rotation id second: if this fails the token is still usable, and the
|
||||||
|
// stale id only costs one extra container recreation later.
|
||||||
|
let version = uuid::Uuid::new_v4().to_string();
|
||||||
|
let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
version_entry
|
||||||
|
.set_password(&version)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the shared Claude Code OAuth token, if one has been stored.
|
||||||
|
pub fn get_claude_oauth_token() -> Result<Option<String>, String> {
|
||||||
|
read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rotation id of the currently stored token. Opaque random data — safe to
|
||||||
|
/// put in a Docker label, unlike the token or any hash of it.
|
||||||
|
pub fn get_claude_oauth_token_version() -> Result<Option<String>, String> {
|
||||||
|
read_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shared Claude Code OAuth token is currently stored. A keychain
|
||||||
|
/// failure is reported as "no token" rather than surfacing as an error, so the
|
||||||
|
/// UI degrades to the un-authenticated state instead of breaking.
|
||||||
|
pub fn has_claude_oauth_token() -> bool {
|
||||||
|
matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the shared Claude Code OAuth token and its rotation id. Both are
|
||||||
|
/// attempted even if the first fails, so a partial failure cannot strand the
|
||||||
|
/// token behind a deleted id.
|
||||||
|
pub fn delete_claude_oauth_token() -> Result<(), String> {
|
||||||
|
let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token");
|
||||||
|
let version_result = delete_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
);
|
||||||
|
token_result.and(version_result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod server;
|
||||||
|
mod ws_handler;
|
||||||
|
|
||||||
|
pub use server::WebTerminalServer;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Query, State as AxumState, WebSocketUpgrade};
|
||||||
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
|
use crate::docker::exec::ExecSessionManager;
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
use crate::storage::settings_store::SettingsStore;
|
||||||
|
|
||||||
|
use super::ws_handler;
|
||||||
|
|
||||||
|
/// Shared state passed to all axum handlers.
|
||||||
|
pub struct WebTerminalState {
|
||||||
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
|
pub settings_store: Arc<SettingsStore>,
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the lifecycle of the axum HTTP+WS server.
|
||||||
|
pub struct WebTerminalServer {
|
||||||
|
shutdown_tx: watch::Sender<()>,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TokenQuery {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebTerminalServer {
|
||||||
|
/// Start the web terminal server on the given port.
|
||||||
|
pub async fn start(
|
||||||
|
port: u16,
|
||||||
|
access_token: String,
|
||||||
|
exec_manager: Arc<ExecSessionManager>,
|
||||||
|
projects_store: Arc<ProjectsStore>,
|
||||||
|
settings_store: Arc<SettingsStore>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||||
|
|
||||||
|
let shared_state = Arc::new(WebTerminalState {
|
||||||
|
exec_manager,
|
||||||
|
projects_store,
|
||||||
|
settings_store,
|
||||||
|
access_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(serve_html))
|
||||||
|
.route("/ws", get(ws_upgrade))
|
||||||
|
.route("/api/projects", get(list_projects))
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.with_state(shared_state);
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{}", port);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?;
|
||||||
|
|
||||||
|
log::info!("Web terminal server listening on {}", addr);
|
||||||
|
|
||||||
|
let mut shutdown_rx_clone = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = shutdown_rx_clone.changed().await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::error!("Web terminal server error: {}", e);
|
||||||
|
});
|
||||||
|
log::info!("Web terminal server shut down");
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self { shutdown_tx, port })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the server gracefully.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
log::info!("Stopping web terminal server on port {}", self.port);
|
||||||
|
let _ = self.shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the embedded HTML page.
|
||||||
|
async fn serve_html() -> Html<&'static str> {
|
||||||
|
Html(include_str!("terminal.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate token from query params.
|
||||||
|
fn validate_token(state: &WebTerminalState, token: &Option<String>) -> bool {
|
||||||
|
match token {
|
||||||
|
Some(t) => t == &state.access_token,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket upgrade handler.
|
||||||
|
async fn ws_upgrade(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||||
|
Query(query): Query<TokenQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !validate_token(&state, &query.token) {
|
||||||
|
return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response();
|
||||||
|
}
|
||||||
|
ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List running projects (REST endpoint).
|
||||||
|
async fn list_projects(
|
||||||
|
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||||
|
Query(query): Query<TokenQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !validate_token(&state, &query.token) {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({"error": "Invalid token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects = state.projects_store.list();
|
||||||
|
let infos: Vec<ProjectInfo> = projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ProjectInfo {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
status: serde_json::to_value(&p.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
axum::Json(infos).into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>Triple-C Web Terminal</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1b26;
|
||||||
|
--bg-secondary: #24283b;
|
||||||
|
--bg-tertiary: #2f3347;
|
||||||
|
--text-primary: #c0caf5;
|
||||||
|
--text-secondary: #565f89;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
--accent-hover: #89b4fa;
|
||||||
|
--border: #3b3f57;
|
||||||
|
--success: #9ece6a;
|
||||||
|
--warning: #e0af68;
|
||||||
|
--error: #f7768e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh; /* dynamic viewport height — shrinks when mobile keyboard opens */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top Bar ─────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--error);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.connected { background: var(--success); }
|
||||||
|
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
select, button {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus, button:focus { outline: none; border-color: var(--accent); }
|
||||||
|
button:hover { background: var(--border); }
|
||||||
|
button:active { background: var(--accent); color: var(--bg-primary); }
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab Bar ─────────────────────────────── */
|
||||||
|
.tabbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
min-height: 32px;
|
||||||
|
position: sticky;
|
||||||
|
top: 42px; /* below .topbar min-height */
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-primary); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
min-width: unset;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.tab-close:hover { background: var(--error); color: white; }
|
||||||
|
|
||||||
|
/* ── Terminal Area ───────────────────────── */
|
||||||
|
.terminal-area {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
padding: 4px 4px 16px 4px;
|
||||||
|
}
|
||||||
|
.terminal-container.active { display: block; }
|
||||||
|
|
||||||
|
/* ── Input Bar (mobile/tablet) ──────────── */
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 16px; /* prevents iOS zoom on focus */
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.input-bar input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-bar .key-btn {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll-to-bottom FAB ──────────────── */
|
||||||
|
.scroll-bottom-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 16px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
min-width: unset;
|
||||||
|
min-height: unset;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||||
|
.scroll-bottom-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
/* ── Empty State ─────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ───────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">Triple-C</span>
|
||||||
|
<span class="status-dot" id="statusDot"></span>
|
||||||
|
<select id="projectSelect" style="flex:1; max-width:240px;">
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
|
||||||
|
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="tabbar" id="tabbar"></div>
|
||||||
|
|
||||||
|
<!-- Terminal Area -->
|
||||||
|
<div class="terminal-area" id="terminalArea">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div>Select a project and open a terminal session</div>
|
||||||
|
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||||
|
</div>
|
||||||
|
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Bar for mobile/tablet -->
|
||||||
|
<div class="input-bar" id="inputBar">
|
||||||
|
<input type="text" id="mobileInput" placeholder="Type here..."
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
|
enterkeyhint="send" inputmode="text">
|
||||||
|
<button class="key-btn" id="btnEnter">Enter</button>
|
||||||
|
<button class="key-btn" id="btnTab">Tab</button>
|
||||||
|
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const TOKEN = params.get('token') || '';
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
|
||||||
|
let activeSessionId = null;
|
||||||
|
|
||||||
|
// ── DOM refs ───────────────────────────────
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const projectSelect = document.getElementById('projectSelect');
|
||||||
|
const btnClaude = document.getElementById('btnClaude');
|
||||||
|
const btnBash = document.getElementById('btnBash');
|
||||||
|
const tabbar = document.getElementById('tabbar');
|
||||||
|
const terminalArea = document.getElementById('terminalArea');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const mobileInput = document.getElementById('mobileInput');
|
||||||
|
const btnEnter = document.getElementById('btnEnter');
|
||||||
|
const btnTab = document.getElementById('btnTab');
|
||||||
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
|
|
||||||
|
// ── WebSocket ──────────────────────────────
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
statusDot.className = 'status-dot connected';
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
send({ type: 'list_projects' });
|
||||||
|
// Start keepalive
|
||||||
|
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
statusDot.className = 'status-dot reconnecting';
|
||||||
|
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handling ───────────────────────
|
||||||
|
function handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'projects':
|
||||||
|
updateProjectList(msg.projects);
|
||||||
|
break;
|
||||||
|
case 'opened':
|
||||||
|
onSessionOpened(msg.session_id, msg.project_name);
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
onSessionExit(msg.session_id);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
console.error('Server error:', msg.message);
|
||||||
|
// Show in active terminal if available
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'pong':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProjectList(projects) {
|
||||||
|
const current = projectSelect.value;
|
||||||
|
projectSelect.innerHTML = '<option value="">Select project...</option>';
|
||||||
|
projects.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = `${p.name} (${p.status})`;
|
||||||
|
opt.disabled = p.status !== 'running';
|
||||||
|
projectSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Restore selection if still valid
|
||||||
|
if (current) projectSelect.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session management ─────────────────────
|
||||||
|
let pendingSessionType = null;
|
||||||
|
|
||||||
|
function openSession(type) {
|
||||||
|
const projectId = projectSelect.value;
|
||||||
|
if (!projectId) {
|
||||||
|
alert('Please select a running project first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSessionType = type;
|
||||||
|
send({
|
||||||
|
type: 'open',
|
||||||
|
project_id: projectId,
|
||||||
|
session_type: type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionOpened(sessionId, projectName) {
|
||||||
|
const sessionType = pendingSessionType || 'claude';
|
||||||
|
pendingSessionType = null;
|
||||||
|
|
||||||
|
// Create terminal
|
||||||
|
const term = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: '#1a1b26',
|
||||||
|
foreground: '#c0caf5',
|
||||||
|
cursor: '#c0caf5',
|
||||||
|
selectionBackground: '#33467c',
|
||||||
|
black: '#15161e',
|
||||||
|
red: '#f7768e',
|
||||||
|
green: '#9ece6a',
|
||||||
|
yellow: '#e0af68',
|
||||||
|
blue: '#7aa2f7',
|
||||||
|
magenta: '#bb9af7',
|
||||||
|
cyan: '#7dcfff',
|
||||||
|
white: '#a9b1d6',
|
||||||
|
brightBlack: '#414868',
|
||||||
|
brightRed: '#f7768e',
|
||||||
|
brightGreen: '#9ece6a',
|
||||||
|
brightYellow: '#e0af68',
|
||||||
|
brightBlue: '#7aa2f7',
|
||||||
|
brightMagenta: '#bb9af7',
|
||||||
|
brightCyan: '#7dcfff',
|
||||||
|
brightWhite: '#c0caf5',
|
||||||
|
},
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
||||||
|
cursorBlink: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||||
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// Create container div
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'terminal-container';
|
||||||
|
container.id = `term-${sessionId}`;
|
||||||
|
terminalArea.appendChild(container);
|
||||||
|
|
||||||
|
term.open(container);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
// Send initial resize
|
||||||
|
send({
|
||||||
|
type: 'resize',
|
||||||
|
session_id: sessionId,
|
||||||
|
cols: term.cols,
|
||||||
|
rows: term.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle user input
|
||||||
|
term.onData(data => {
|
||||||
|
const bytes = new TextEncoder().encode(data);
|
||||||
|
const b64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
send({
|
||||||
|
type: 'input',
|
||||||
|
session_id: sessionId,
|
||||||
|
data: b64,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track scroll position for scroll-to-bottom button
|
||||||
|
term.onScroll(() => updateScrollButton());
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
|
||||||
|
|
||||||
|
// Add tab and switch to it
|
||||||
|
addTab(sessionId, projectName, sessionType);
|
||||||
|
switchToSession(sessionId);
|
||||||
|
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionOutput(sessionId, b64data) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
|
||||||
|
session.term.write(bytes);
|
||||||
|
// Update scroll button if this is the active session
|
||||||
|
if (sessionId === activeSessionId) updateScrollButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionExit(sessionId) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSession(sessionId) {
|
||||||
|
send({ type: 'close', session_id: sessionId });
|
||||||
|
removeSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSession(sessionId) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.term.dispose();
|
||||||
|
session.container.remove();
|
||||||
|
delete sessions[sessionId];
|
||||||
|
|
||||||
|
// Remove tab
|
||||||
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
|
if (tab) tab.remove();
|
||||||
|
|
||||||
|
// Switch to another session or show empty state
|
||||||
|
const remaining = Object.keys(sessions);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
switchToSession(remaining[remaining.length - 1]);
|
||||||
|
} else {
|
||||||
|
activeSessionId = null;
|
||||||
|
emptyState.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab bar ────────────────────────────────
|
||||||
|
function addTab(sessionId, projectName, sessionType) {
|
||||||
|
const tab = document.createElement('div');
|
||||||
|
tab.className = 'tab';
|
||||||
|
tab.id = `tab-${sessionId}`;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = `${projectName} (${sessionType})`;
|
||||||
|
tab.appendChild(label);
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.className = 'tab-close';
|
||||||
|
close.textContent = '\u00d7';
|
||||||
|
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
|
||||||
|
tab.appendChild(close);
|
||||||
|
|
||||||
|
tab.onclick = () => switchToSession(sessionId);
|
||||||
|
tabbar.appendChild(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToSession(sessionId) {
|
||||||
|
activeSessionId = sessionId;
|
||||||
|
|
||||||
|
// Update tab styles
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
|
if (tab) tab.classList.add('active');
|
||||||
|
|
||||||
|
// Show/hide terminal containers
|
||||||
|
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
|
||||||
|
const container = document.getElementById(`term-${sessionId}`);
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('active');
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (session) {
|
||||||
|
// Fit after making visible
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
session.fitAddon.fit();
|
||||||
|
session.term.focus();
|
||||||
|
updateScrollButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resize handling ────────────────────────
|
||||||
|
function handleResize() {
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
const session = sessions[activeSessionId];
|
||||||
|
session.fitAddon.fit();
|
||||||
|
send({
|
||||||
|
type: 'resize',
|
||||||
|
session_id: activeSessionId,
|
||||||
|
cols: session.term.cols,
|
||||||
|
rows: session.term.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeTimeout;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(handleResize, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Send helper ─────────────────────────────
|
||||||
|
function sendTerminalInput(str) {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
const b64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
send({
|
||||||
|
type: 'input',
|
||||||
|
session_id: activeSessionId,
|
||||||
|
data: b64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input bar (mobile/tablet) ──────────────
|
||||||
|
// Send characters immediately, bypassing IME composition buffering.
|
||||||
|
// Clearing value on each input event cancels any active composition.
|
||||||
|
mobileInput.addEventListener('input', () => {
|
||||||
|
const val = mobileInput.value;
|
||||||
|
if (val) {
|
||||||
|
sendTerminalInput(val);
|
||||||
|
mobileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch Enter in the input field itself
|
||||||
|
mobileInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = mobileInput.value;
|
||||||
|
if (val) {
|
||||||
|
sendTerminalInput(val);
|
||||||
|
mobileInput.value = '';
|
||||||
|
}
|
||||||
|
sendTerminalInput('\r');
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
sendTerminalInput('\t');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||||
|
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||||
|
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||||
|
|
||||||
|
// ── Scroll to bottom ──────────────────────
|
||||||
|
function updateScrollButton() {
|
||||||
|
if (!activeSessionId || !sessions[activeSessionId]) {
|
||||||
|
scrollBottomBtn.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const term = sessions[activeSessionId].term;
|
||||||
|
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
|
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollBottomBtn.onclick = () => {
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
sessions[activeSessionId].term.scrollToBottom();
|
||||||
|
scrollBottomBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Event listeners ────────────────────────
|
||||||
|
btnClaude.onclick = () => openSession('claude');
|
||||||
|
btnBash.onclick = () => openSession('bash');
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::commands::aws_commands;
|
||||||
|
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
|
||||||
|
|
||||||
|
use super::server::WebTerminalState;
|
||||||
|
|
||||||
|
// ── Wire protocol types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ClientMessage {
|
||||||
|
ListProjects,
|
||||||
|
Open {
|
||||||
|
project_id: String,
|
||||||
|
session_type: Option<String>,
|
||||||
|
},
|
||||||
|
Input {
|
||||||
|
session_id: String,
|
||||||
|
data: String, // base64
|
||||||
|
},
|
||||||
|
Resize {
|
||||||
|
session_id: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
},
|
||||||
|
Close {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ServerMessage {
|
||||||
|
Projects {
|
||||||
|
projects: Vec<ProjectEntry>,
|
||||||
|
},
|
||||||
|
Opened {
|
||||||
|
session_id: String,
|
||||||
|
project_name: String,
|
||||||
|
},
|
||||||
|
Output {
|
||||||
|
session_id: String,
|
||||||
|
data: String, // base64
|
||||||
|
},
|
||||||
|
Exit {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Pong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectEntry {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection handler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>) {
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
|
// Channel for sending messages from session output tasks → WS writer
|
||||||
|
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||||
|
|
||||||
|
// Track session IDs owned by this connection for cleanup
|
||||||
|
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
// Writer task: serializes ServerMessages and sends as WS text frames
|
||||||
|
let writer_handle = tokio::spawn(async move {
|
||||||
|
while let Some(msg) = out_rx.recv().await {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reader loop: parse incoming messages and dispatch
|
||||||
|
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||||
|
let text = match &msg {
|
||||||
|
Message::Text(t) => t.to_string(),
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Invalid message: {}", e),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client_msg {
|
||||||
|
ClientMessage::Ping => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Pong);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::ListProjects => {
|
||||||
|
let projects = state.projects_store.list();
|
||||||
|
let entries: Vec<ProjectEntry> = projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ProjectEntry {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
status: serde_json::to_value(&p.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = out_tx.send(ServerMessage::Projects { projects: entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Open {
|
||||||
|
project_id,
|
||||||
|
session_type,
|
||||||
|
} => {
|
||||||
|
let result = handle_open(
|
||||||
|
&state,
|
||||||
|
&project_id,
|
||||||
|
session_type.as_deref(),
|
||||||
|
&out_tx,
|
||||||
|
&owned_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error { message: e });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Input { session_id, data } => {
|
||||||
|
match BASE64.decode(&data) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Input error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Base64 decode error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Resize {
|
||||||
|
session_id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
} => {
|
||||||
|
if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Resize error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Close { session_id } => {
|
||||||
|
state.exec_manager.close_session(&session_id).await;
|
||||||
|
// Remove from owned list
|
||||||
|
owned_sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.retain(|id| id != &session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection closed — clean up all owned sessions
|
||||||
|
log::info!("Web terminal WebSocket disconnected, cleaning up sessions");
|
||||||
|
let sessions = owned_sessions.lock().await.clone();
|
||||||
|
for session_id in sessions {
|
||||||
|
state.exec_manager.close_session(&session_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||||
|
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||||
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
|
&& project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
let mut cmd = vec!["claude".to_string()];
|
||||||
|
cmd.extend(permission_args);
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = aws_commands::resolve_profile_for_project(
|
||||||
|
project,
|
||||||
|
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The args are interpolated into a shell script string below, so
|
||||||
|
// single-quote each one.
|
||||||
|
let permission_flags: String = permission_args
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
|
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "AWS session valid."
|
||||||
|
else
|
||||||
|
echo "AWS session expired or invalid."
|
||||||
|
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||||
|
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "Starting SSO login..."
|
||||||
|
echo ""
|
||||||
|
triple-c-sso-refresh
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||||
|
echo "You may see authentication errors."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||||
|
echo "Starting Claude anyway..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
{claude_cmd}
|
||||||
|
"#,
|
||||||
|
profile = profile,
|
||||||
|
claude_cmd = claude_cmd
|
||||||
|
);
|
||||||
|
|
||||||
|
vec!["bash".to_string(), "-c".to_string(), script]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a new terminal session for a project.
|
||||||
|
async fn handle_open(
|
||||||
|
state: &WebTerminalState,
|
||||||
|
project_id: &str,
|
||||||
|
session_type: Option<&str>,
|
||||||
|
out_tx: &mpsc::UnboundedSender<ServerMessage>,
|
||||||
|
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
if project.status != ProjectStatus::Running {
|
||||||
|
return Err(format!("Project '{}' is not running", project.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let cmd = match session_type {
|
||||||
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
|
_ => build_terminal_cmd(&project, &state.settings_store),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let project_name = project.name.clone();
|
||||||
|
|
||||||
|
// Set up output routing through the WS channel
|
||||||
|
let out_tx_output = out_tx.clone();
|
||||||
|
let session_id_output = session_id.clone();
|
||||||
|
let on_output = move |data: Vec<u8>| {
|
||||||
|
let encoded = BASE64.encode(&data);
|
||||||
|
let _ = out_tx_output.send(ServerMessage::Output {
|
||||||
|
session_id: session_id_output.clone(),
|
||||||
|
data: encoded,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let out_tx_exit = out_tx.clone();
|
||||||
|
let session_id_exit = session_id.clone();
|
||||||
|
let on_exit = Box::new(move || {
|
||||||
|
let _ = out_tx_exit.send(ServerMessage::Exit {
|
||||||
|
session_id: session_id_exit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Track this session for cleanup on disconnect
|
||||||
|
owned_sessions.lock().await.push(session_id.clone());
|
||||||
|
|
||||||
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
|
session_id,
|
||||||
|
project_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"identifier": "com.triple-c.desktop",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+146
-23
@@ -1,26 +1,55 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
|
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||||
|
import ToastHost from "./components/ui/ToastHost";
|
||||||
|
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||||
|
import Button from "./components/ui/Button";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useAppState } from "./store/appState";
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
|
import { useSTT } from "./hooks/useSTT";
|
||||||
|
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||||
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
|
||||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
useShallow(s => ({
|
||||||
|
sessions: s.sessions,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setSttToggle: s.setSttToggle,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||||
|
|
||||||
|
// Single STT instance bound to the active session. The mic lives in the
|
||||||
|
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
|
||||||
|
// store (registered below).
|
||||||
|
const { sendInput } = useTerminal();
|
||||||
|
const stt = useSTT(activeSessionId ?? "", sendInput);
|
||||||
|
useEffect(() => {
|
||||||
|
setSttToggle(stt.toggle);
|
||||||
|
}, [stt.toggle, setSttToggle]);
|
||||||
|
|
||||||
|
useContainerProgress();
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,15 +67,18 @@ export default function App() {
|
|||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setShowInstallDialog(true);
|
||||||
stopPolling = startDockerPolling();
|
stopPolling = startDockerPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
refreshMcp();
|
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
loadVersion();
|
loadVersion();
|
||||||
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
const updateTimer = setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
checkImageUpdate();
|
||||||
|
}, 3000);
|
||||||
const cleanup = startPeriodicCheck();
|
const cleanup = startPeriodicCheck();
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(updateTimer);
|
clearTimeout(updateTimer);
|
||||||
@@ -55,16 +87,25 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-6 gap-4 bg-[var(--bg-primary)]">
|
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex flex-1 min-h-0 gap-4">
|
<div className="flex flex-1 min-h-0 gap-3">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
|
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
|
||||||
{sessions.length === 0 ? (
|
{tabOrder.length === 0 ? (
|
||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
{homeProjectIds.map((projectId) => (
|
||||||
|
<ProjectHome
|
||||||
|
key={projectId}
|
||||||
|
projectId={projectId}
|
||||||
|
active={activeTabKey === homeTabKey(projectId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<TerminalView
|
<TerminalView
|
||||||
key={session.id}
|
key={session.id}
|
||||||
@@ -76,23 +117,105 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar stt={stt} />
|
||||||
|
<ToastHost />
|
||||||
|
{showInstallDialog && (
|
||||||
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First run is a checklist, not a paragraph: it reuses state the app already
|
||||||
|
* tracks and ends in a real button.
|
||||||
|
*/
|
||||||
function WelcomeScreen() {
|
function WelcomeScreen() {
|
||||||
|
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
projects: s.projects,
|
||||||
|
openProjectHome: s.openProjectHome,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const steps: {
|
||||||
|
label: string;
|
||||||
|
state: boolean | null;
|
||||||
|
pendingLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: "Docker detected",
|
||||||
|
state: dockerAvailable,
|
||||||
|
pendingLabel: "Checking for Docker…",
|
||||||
|
failLabel: "Docker not available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Container image ready",
|
||||||
|
state: imageExists,
|
||||||
|
pendingLabel: "Checking for the image…",
|
||||||
|
failLabel: "Image not pulled yet — see Settings › Container",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
|
||||||
|
state: projects.length > 0 ? true : false,
|
||||||
|
pendingLabel: "",
|
||||||
|
failLabel: "No projects yet",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<div className="text-center">
|
<div className="w-full max-w-md">
|
||||||
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
|
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
|
||||||
Triple-C
|
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
|
||||||
</h1>
|
Claude Code, sandboxed in a container.
|
||||||
<p className="text-sm mb-4">Claude Code Container</p>
|
|
||||||
<p className="text-xs max-w-md">
|
|
||||||
Add a project from the sidebar, start its container, then open a
|
|
||||||
terminal to begin using Claude Code in a sandboxed environment.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-2 mb-5">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<li
|
||||||
|
key={step.label}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<StatusIndicator
|
||||||
|
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
|
||||||
|
label={
|
||||||
|
step.state === true
|
||||||
|
? step.label
|
||||||
|
: step.state === false
|
||||||
|
? step.failLabel
|
||||||
|
: step.pendingLabel
|
||||||
|
}
|
||||||
|
className="text-[13px]"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
|
||||||
|
{projects.length === 0 ? "Add your first project" : "Add a project"}
|
||||||
|
</Button>
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
|
||||||
|
Open {projects[0].name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-[var(--text-secondary)]">
|
||||||
|
Then start its container and press{" "}
|
||||||
|
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
|
||||||
|
Ctrl+T
|
||||||
|
</kbd>{" "}
|
||||||
|
to open a Claude terminal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
|
import { useDocker } from "../hooks/useDocker";
|
||||||
|
import Modal from "./ui/Modal";
|
||||||
|
import Button from "./ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = "idle" | "installing" | "done" | "error";
|
||||||
|
|
||||||
|
export default function DockerInstallDialog({ onClose }: Props) {
|
||||||
|
const { options, loadOptions, runInstall } = useInstallHelper();
|
||||||
|
const { checkDocker } = useDocker();
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOptions();
|
||||||
|
}, [loadOptions]);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setPhase("installing");
|
||||||
|
setLog([]);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await runInstall((line) => setLog((prev) => [...prev, line]));
|
||||||
|
setPhase("done");
|
||||||
|
// Re-check Docker so the rest of the app can proceed without a reload.
|
||||||
|
await checkDocker();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDocs = async () => {
|
||||||
|
if (!options) return;
|
||||||
|
try {
|
||||||
|
await openUrl(options.docs_url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open docs URL:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecheck = async () => {
|
||||||
|
const available = await checkDocker();
|
||||||
|
if (available) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installVerb =
|
||||||
|
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Docker not detected"
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
// Closing mid-install would orphan a privileged installer.
|
||||||
|
dismissible={phase !== "installing"}
|
||||||
|
footer={
|
||||||
|
phase === "idle" ? (
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||||
|
Triple-C needs a Docker-compatible runtime to manage sandboxed project
|
||||||
|
containers. We can install{" "}
|
||||||
|
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
|
||||||
|
you, or you can follow the official instructions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{phase === "idle" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.can_auto_install ? (
|
||||||
|
<Button size="md" variant="primary" onClick={handleInstall}>
|
||||||
|
{installVerb} ({options.auto_install_method})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
|
One-click install unavailable:{" "}
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{options.auto_install_blocker ?? "required tooling missing."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="md" onClick={() => setShowManual((s) => !s)}>
|
||||||
|
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="md" onClick={handleOpenDocs}>
|
||||||
|
Open official documentation ↗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "installing" && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Installing… a system password prompt may appear. Do not close this window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "done" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-[13px] text-[var(--success)]">Install finished.</div>
|
||||||
|
{options.post_install_notes.length > 0 && (
|
||||||
|
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||||
|
{options.post_install_notes.map((note, i) => (
|
||||||
|
<li key={i}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button size="md" variant="primary" onClick={handleRecheck}>
|
||||||
|
Re-check Docker
|
||||||
|
</Button>
|
||||||
|
<Button size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "error" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-[13px] text-[var(--error)]">Install failed.</div>
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs font-mono text-[var(--error)] break-words">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button size="md" onClick={() => setPhase("idle")}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={handleOpenDocs}>
|
||||||
|
Open official docs ↗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showManual || phase === "error") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||||
|
Manual install steps
|
||||||
|
</div>
|
||||||
|
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
|
{options.manual_steps.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.length > 0 && (
|
||||||
|
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||||
|
{log.map((line, i) => (
|
||||||
|
<div key={i}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
import { getHelpContent } from "../../lib/tauri-commands";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert header text to a URL-friendly slug for anchor links. */
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||||
|
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||||
|
.replace(/\s+/g, "-") // spaces to dashes
|
||||||
|
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||||
|
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple markdown-to-HTML converter for the help content. */
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
let html = md;
|
||||||
|
|
||||||
|
// Normalize line endings
|
||||||
|
html = html.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
|
// Escape HTML entities (but we'll re-introduce tags below)
|
||||||
|
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
// Fenced code blocks (```...```)
|
||||||
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||||
|
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code (`...`)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
html = html.replace(
|
||||||
|
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
|
||||||
|
(_m, headerRow: string, _sep: string, bodyRows: string) => {
|
||||||
|
const headers = headerRow
|
||||||
|
.split("|")
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((c: string) => `<th>${c.trim()}</th>`)
|
||||||
|
.join("");
|
||||||
|
const rows = bodyRows
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((row: string) => {
|
||||||
|
const cells = row
|
||||||
|
.split("|")
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((c: string) => `<td>${c.trim()}</td>`)
|
||||||
|
.join("");
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blockquotes (> ...)
|
||||||
|
html = html.replace(/(?:^|\n)> (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
|
||||||
|
// Merge adjacent blockquotes
|
||||||
|
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
|
||||||
|
|
||||||
|
// Headers with id attributes for anchor navigation (process from h4 down to h1)
|
||||||
|
html = html.replace(/^#### (.+)$/gm, (_m, title) => `<h4 class="help-h4" id="${slugify(title)}">${title}</h4>`);
|
||||||
|
html = html.replace(/^### (.+)$/gm, (_m, title) => `<h3 class="help-h3" id="${slugify(title)}">${title}</h3>`);
|
||||||
|
html = html.replace(/^## (.+)$/gm, (_m, title) => `<h2 class="help-h2" id="${slugify(title)}">${title}</h2>`);
|
||||||
|
html = html.replace(/^# (.+)$/gm, (_m, title) => `<h1 class="help-h1" id="${slugify(title)}">${title}</h1>`);
|
||||||
|
|
||||||
|
// Bold (**...**)
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
|
||||||
|
// Italic (*...*)
|
||||||
|
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||||
|
|
||||||
|
// Markdown-style anchor links [text](#anchor)
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||||
|
'<a class="help-link" href="#$2">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Markdown-style external links [text](url)
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||||
|
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unordered list items (- ...)
|
||||||
|
// Group consecutive list items
|
||||||
|
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
|
||||||
|
const items = block
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
|
||||||
|
.join("");
|
||||||
|
return `<ul class="help-ul">${items}</ul>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordered list items (1. ...)
|
||||||
|
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
|
||||||
|
const items = block
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
|
||||||
|
.join("");
|
||||||
|
return `<ol class="help-ol">${items}</ol>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||||
|
html = html.replace(
|
||||||
|
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||||
|
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap remaining loose text lines in paragraphs
|
||||||
|
// Split by double newlines for paragraph breaks
|
||||||
|
const blocks = html.split(/\n\n+/);
|
||||||
|
html = blocks
|
||||||
|
.map((block) => {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
// Don't wrap blocks that are already HTML elements
|
||||||
|
if (
|
||||||
|
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
// Wrap in paragraph, replacing single newlines with <br/>
|
||||||
|
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpDialog({ onClose }: Props) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHelpContent()
|
||||||
|
.then(setMarkdown)
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle anchor link clicks to scroll within the dialog
|
||||||
|
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const anchor = target.closest("a");
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href");
|
||||||
|
if (!href || !href.startsWith("#")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const el = contentRef.current?.querySelector(href);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="How to Use Triple-C"
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[48rem]"
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
|
>
|
||||||
|
<div ref={contentRef} onClick={handleContentClick} className="help-content">
|
||||||
|
{error && (
|
||||||
|
<p className="text-[var(--error)] text-sm">
|
||||||
|
Failed to load help content: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!markdown && !error && (
|
||||||
|
<p className="text-[var(--text-secondary)] text-sm">Loading…</p>
|
||||||
|
)}
|
||||||
|
{markdown && (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import {
|
||||||
|
useAppState,
|
||||||
|
isHomeTab,
|
||||||
|
tabKeyId,
|
||||||
|
terminalTabKey,
|
||||||
|
} from "../../store/appState";
|
||||||
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
sessionId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||||
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||||
|
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
|
* terminals (▣).
|
||||||
|
*/
|
||||||
|
export default function MainTabs() {
|
||||||
|
const { sessions, close } = useTerminal();
|
||||||
|
const { projects, update } = useProjects();
|
||||||
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const dismiss = () => setMenu(null);
|
||||||
|
window.addEventListener("click", dismiss);
|
||||||
|
window.addEventListener("scroll", dismiss, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", dismiss);
|
||||||
|
window.removeEventListener("scroll", dismiss, true);
|
||||||
|
};
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingId) {
|
||||||
|
renameInputRef.current?.focus();
|
||||||
|
renameInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [renamingId]);
|
||||||
|
|
||||||
|
if (tabOrder.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
|
No open tabs — select a project to open its home view.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
return project?.renamed_session_names?.[sessionId] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const current =
|
||||||
|
getCustomName(session.projectId, sessionId) ??
|
||||||
|
session.sessionName ??
|
||||||
|
session.projectName;
|
||||||
|
setRenameDraft(current);
|
||||||
|
setRenamingId(sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitRename = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = renameDraft.trim();
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (trimmed) {
|
||||||
|
map[sessionId] = trimmed;
|
||||||
|
} else {
|
||||||
|
delete map[sessionId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename terminal tab:", err);
|
||||||
|
} finally {
|
||||||
|
setRenamingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCustomName = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) return;
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (!(sessionId in map)) {
|
||||||
|
setMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete map[sessionId];
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to reset terminal tab name:", err);
|
||||||
|
} finally {
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (active: boolean) =>
|
||||||
|
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
|
{tabOrder.map((key) => {
|
||||||
|
const active = activeTabKey === key;
|
||||||
|
|
||||||
|
if (isHomeTab(key)) {
|
||||||
|
const projectId = tabKeyId(key);
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setActiveTabKey(key)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={tabClass(active)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||||
|
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeHomeTab(projectId);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${project.name} home tab`}
|
||||||
|
title="Close tab"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = tabKeyId(key);
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
const customName = getCustomName(session.projectId, session.id);
|
||||||
|
const baseLabel =
|
||||||
|
(session.sessionName ?? session.projectName) +
|
||||||
|
(session.sessionType === "bash" ? " (bash)" : "");
|
||||||
|
const displayLabel = customName
|
||||||
|
? `${session.projectName}: ${customName}`
|
||||||
|
: baseLabel;
|
||||||
|
const isRenaming = renamingId === session.id;
|
||||||
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(terminalTabKey(session.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => startRename(session.id)}
|
||||||
|
className={tabClass(active)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameDraft}
|
||||||
|
aria-label="Rename tab"
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onBlur={() => commitRename(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") setRenamingId(null);
|
||||||
|
}}
|
||||||
|
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||||
|
title={`Permission mode: ${badge.text}`}
|
||||||
|
>
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
close(session.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${displayLabel}`}
|
||||||
|
title="Close terminal"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{menu && (() => {
|
||||||
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
|
const hasCustom = session
|
||||||
|
? !!getCustomName(session.projectId, menu.sessionId)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
|
||||||
|
style={{ top: menu.y, left: menu.x, boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => startRename(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Rename tab
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => clearCustomName(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Reset name
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
useAppState.getState().openProjectHome(session.projectId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open project home
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-[var(--border-color)] my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
close(menu.sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
|
|||||||
selector({
|
selector({
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: vi.fn(),
|
setSidebarView: vi.fn(),
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
setSidebarCollapsed: vi.fn(),
|
||||||
|
toggleSidebarCollapsed: vi.fn(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -19,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({
|
|||||||
vi.mock("../settings/SettingsPanel", () => ({
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("../mcp/McpPanel", () => ({
|
|
||||||
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -34,6 +34,12 @@ describe("Sidebar", () => {
|
|||||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the project list, not a settings form, in the projects view", () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByTestId("project-list")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("content area has min-w-0 to prevent flex overflow", () => {
|
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||||
const { container } = render(<Sidebar />);
|
const { container } = render(<Sidebar />);
|
||||||
const contentArea = container.querySelector(".overflow-y-auto");
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
|||||||
@@ -1,15 +1,87 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
import McpPanel from "../mcp/McpPanel";
|
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
|
type SidebarView = "projects" | "settings";
|
||||||
|
|
||||||
|
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
||||||
|
{
|
||||||
|
view: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { sidebarView, setSidebarView } = useAppState(
|
const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
|
||||||
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
useShallow(s => ({
|
||||||
|
sidebarView: s.sidebarView,
|
||||||
|
setSidebarView: s.setSidebarView,
|
||||||
|
sidebarCollapsed: s.sidebarCollapsed,
|
||||||
|
setSidebarCollapsed: s.setSidebarCollapsed,
|
||||||
|
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabCls = (view: typeof sidebarView) =>
|
if (sidebarCollapsed) {
|
||||||
|
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
|
||||||
|
const active = sidebarView === view;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={view}
|
||||||
|
onClick={() => {
|
||||||
|
setSidebarView(view);
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`flex items-center justify-center h-10 w-full transition-colors ${
|
||||||
|
active
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
title="Expand sidebar"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabCls = (view: SidebarView) =>
|
||||||
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
sidebarView === view
|
sidebarView === view
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||||
@@ -17,29 +89,30 @@ export default function Sidebar() {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
|
||||||
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
title="Collapse sidebar"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? (
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
<ProjectList />
|
|
||||||
) : sidebarView === "mcp" ? (
|
|
||||||
<McpPanel />
|
|
||||||
) : (
|
|
||||||
<SettingsPanel />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import SttButton from "../terminal/SttButton";
|
||||||
|
import type { useSTT } from "../../hooks/useSTT";
|
||||||
|
|
||||||
export default function StatusBar() {
|
interface Props {
|
||||||
const { projects, sessions } = useAppState(
|
stt: ReturnType<typeof useSTT>;
|
||||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
}
|
||||||
|
|
||||||
|
export default function StatusBar({ stt }: Props) {
|
||||||
|
const {
|
||||||
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
|
terminalAtBottom, scrollActiveToBottom,
|
||||||
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
projects: s.projects,
|
||||||
|
sessions: s.sessions,
|
||||||
|
terminalHasSelection: s.terminalHasSelection,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
|
terminalAtBottom: s.terminalAtBottom,
|
||||||
|
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||||
<span>
|
<span>
|
||||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -20,6 +37,34 @@ export default function StatusBar() {
|
|||||||
<span>
|
<span>
|
||||||
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{terminalHasSelection && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
<span className="text-[var(--accent)]">
|
||||||
|
Ctrl+Shift+C: copy trimmed · Ctrl+Shift+Alt+C: copy raw
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||||
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
|
{activeSessionId && !terminalAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollActiveToBottom()}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||||
|
title="Scroll the terminal to the latest output"
|
||||||
|
>
|
||||||
|
Jump to Current ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sttEnabled && activeSessionId && (
|
||||||
|
<SttButton
|
||||||
|
state={stt.state}
|
||||||
|
error={stt.error}
|
||||||
|
onToggle={stt.toggle}
|
||||||
|
onCancel={stt.cancelRecording}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import MainTabs from "./MainTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import UpdateDialog from "../settings/UpdateDialog";
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
|
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||||
|
import HelpDialog from "./HelpDialog";
|
||||||
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
dockerAvailable: s.dockerAvailable,
|
dockerAvailable: s.dockerAvailable,
|
||||||
imageExists: s.imageExists,
|
imageExists: s.imageExists,
|
||||||
updateInfo: s.updateInfo,
|
updateInfo: s.updateInfo,
|
||||||
|
imageUpdateInfo: s.imageUpdateInfo,
|
||||||
appVersion: s.appVersion,
|
appVersion: s.appVersion,
|
||||||
setUpdateInfo: s.setUpdateInfo,
|
setUpdateInfo: s.setUpdateInfo,
|
||||||
|
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||||
|
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
|
||||||
|
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
||||||
|
|
||||||
const handleDismiss = async () => {
|
const handleDismiss = async () => {
|
||||||
if (appSettings && updateInfo) {
|
if (appSettings && updateInfo) {
|
||||||
@@ -29,23 +36,64 @@ export default function TopBar() {
|
|||||||
setShowUpdateDialog(false);
|
setShowUpdateDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUpdateDismiss = async () => {
|
||||||
|
if (appSettings && imageUpdateInfo) {
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
dismissed_image_digest: imageUpdateInfo.remote_digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setImageUpdateInfo(null);
|
||||||
|
setShowImageUpdateDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
<div className="flex-1 overflow-x-auto pl-2">
|
<div className="flex-1 overflow-x-auto pl-1">
|
||||||
<TerminalTabs />
|
<MainTabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center gap-3 px-3 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowUpdateDialog(true)}
|
onClick={() => setShowUpdateDialog(true)}
|
||||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--accent-emphasis)] text-white hover:bg-[var(--accent-emphasis-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
{imageUpdateInfo && (
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowImageUpdateDialog(true)}
|
||||||
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--warning-emphasis)] text-white hover:opacity-90 transition-colors"
|
||||||
|
title="A newer container image is available"
|
||||||
|
>
|
||||||
|
Image Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<HealthDot
|
||||||
|
state={dockerAvailable}
|
||||||
|
okLabel="Docker"
|
||||||
|
failLabel="Docker unavailable"
|
||||||
|
pendingLabel="Docker — checking"
|
||||||
|
/>
|
||||||
|
<HealthDot
|
||||||
|
state={imageExists}
|
||||||
|
okLabel="Image"
|
||||||
|
failLabel="Image missing"
|
||||||
|
pendingLabel="Image — checking"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowHelpDialog(true)}
|
||||||
|
title="Help"
|
||||||
|
aria-label="Help"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showUpdateDialog && updateInfo && (
|
{showUpdateDialog && updateInfo && (
|
||||||
@@ -56,19 +104,43 @@ export default function TopBar() {
|
|||||||
onClose={() => setShowUpdateDialog(false)}
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showImageUpdateDialog && imageUpdateInfo && (
|
||||||
|
<ImageUpdateDialog
|
||||||
|
imageUpdateInfo={imageUpdateInfo}
|
||||||
|
onDismiss={handleImageUpdateDismiss}
|
||||||
|
onClose={() => setShowImageUpdateDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showHelpDialog && (
|
||||||
|
<HelpDialog onClose={() => setShowHelpDialog(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
|
/**
|
||||||
return (
|
* `null` (still checking) is visually distinct and pulses; `false` is an
|
||||||
<span className="flex items-center gap-1">
|
* outage and renders red — previously both fell through to the same gray dot.
|
||||||
<span
|
*/
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
function HealthDot({
|
||||||
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
|
state,
|
||||||
}`}
|
okLabel,
|
||||||
/>
|
failLabel,
|
||||||
{label}
|
pendingLabel,
|
||||||
</span>
|
}: {
|
||||||
);
|
state: boolean | null;
|
||||||
|
okLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
pendingLabel: string;
|
||||||
|
}) {
|
||||||
|
let tone: StatusTone = "unknown";
|
||||||
|
let label = pendingLabel;
|
||||||
|
if (state === true) {
|
||||||
|
tone = "ok";
|
||||||
|
label = okLabel;
|
||||||
|
} else if (state === false) {
|
||||||
|
tone = "error";
|
||||||
|
label = failLabel;
|
||||||
|
}
|
||||||
|
return <StatusIndicator tone={tone} label={label} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
||||||
import McpServerCard from "./McpServerCard";
|
|
||||||
|
|
||||||
export default function McpPanel() {
|
|
||||||
const { mcpServers, refresh, add, update, remove } = useMcpServers();
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await add(name);
|
|
||||||
setNewName("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 p-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
|
|
||||||
MCP Servers{" "}
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
|
||||||
Define MCP servers globally, then enable them per-project.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add new server */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
|
||||||
placeholder="Server name..."
|
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{mcpServers.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] italic">
|
|
||||||
No MCP servers configured.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
mcpServers.map((server) => (
|
|
||||||
<McpServerCard
|
|
||||||
key={server.id}
|
|
||||||
server={server}
|
|
||||||
onUpdate={update}
|
|
||||||
onRemove={remove}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { McpServer, McpTransportType } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
server: McpServer;
|
|
||||||
onUpdate: (server: McpServer) => Promise<McpServer | void>;
|
|
||||||
onRemove: (id: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [name, setName] = useState(server.name);
|
|
||||||
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
|
|
||||||
const [command, setCommand] = useState(server.command ?? "");
|
|
||||||
const [args, setArgs] = useState(server.args.join(" "));
|
|
||||||
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
|
|
||||||
const [url, setUrl] = useState(server.url ?? "");
|
|
||||||
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
|
|
||||||
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
|
|
||||||
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setName(server.name);
|
|
||||||
setTransportType(server.transport_type);
|
|
||||||
setCommand(server.command ?? "");
|
|
||||||
setArgs(server.args.join(" "));
|
|
||||||
setEnvPairs(Object.entries(server.env));
|
|
||||||
setUrl(server.url ?? "");
|
|
||||||
setHeaderPairs(Object.entries(server.headers));
|
|
||||||
setDockerImage(server.docker_image ?? "");
|
|
||||||
setContainerPort(server.container_port?.toString() ?? "3000");
|
|
||||||
}, [server]);
|
|
||||||
|
|
||||||
const saveServer = async (patch: Partial<McpServer>) => {
|
|
||||||
try {
|
|
||||||
await onUpdate({ ...server, ...patch });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update MCP server:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameBlur = () => {
|
|
||||||
if (name !== server.name) saveServer({ name });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTransportChange = (t: McpTransportType) => {
|
|
||||||
setTransportType(t);
|
|
||||||
saveServer({ transport_type: t });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommandBlur = () => {
|
|
||||||
saveServer({ command: command || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArgsBlur = () => {
|
|
||||||
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
|
|
||||||
saveServer({ args: parsed });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlBlur = () => {
|
|
||||||
saveServer({ url: url || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDockerImageBlur = () => {
|
|
||||||
saveServer({ docker_image: dockerImage || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContainerPortBlur = () => {
|
|
||||||
const port = parseInt(containerPort, 10);
|
|
||||||
saveServer({ container_port: isNaN(port) ? null : port });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEnv = (pairs: [string, string][]) => {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) env[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ env });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHeaders = (pairs: [string, string][]) => {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) headers[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ headers });
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
const isDocker = !!dockerImage;
|
|
||||||
|
|
||||||
const transportBadge = {
|
|
||||||
stdio: "Stdio",
|
|
||||||
http: "HTTP",
|
|
||||||
}[transportType];
|
|
||||||
|
|
||||||
const modeBadge = isDocker ? "Docker" : "Manual";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
|
|
||||||
<span className="text-sm font-medium truncate">{server.name}</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
|
||||||
{transportBadge}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
|
||||||
{modeBadge}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
|
|
||||||
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded config */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onBlur={handleNameBlur}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
|
|
||||||
<input
|
|
||||||
value={dockerImage}
|
|
||||||
onChange={(e) => setDockerImage(e.target.value)}
|
|
||||||
onBlur={handleDockerImageBlur}
|
|
||||||
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transport type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(["stdio", "http"] as McpTransportType[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => handleTransportChange(t)}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
|
||||||
transportType === t
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "stdio" ? "Stdio" : "HTTP"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode description */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] opacity-60">
|
|
||||||
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
|
|
||||||
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
|
|
||||||
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
|
|
||||||
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Container Port (HTTP+Docker only) */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
|
|
||||||
<input
|
|
||||||
value={containerPort}
|
|
||||||
onChange={(e) => setContainerPort(e.target.value)}
|
|
||||||
onBlur={handleContainerPortBlur}
|
|
||||||
placeholder="3000"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stdio fields */}
|
|
||||||
{transportType === "stdio" && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
|
|
||||||
<input
|
|
||||||
value={command}
|
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
|
||||||
onBlur={handleCommandBlur}
|
|
||||||
placeholder={isDocker ? "Command inside container" : "npx"}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
|
|
||||||
<input
|
|
||||||
value={args}
|
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
|
||||||
onBlur={handleArgsBlur}
|
|
||||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
|
|
||||||
{transportType === "http" && !isDocker && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
|
|
||||||
<input
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onBlur={handleUrlBlur}
|
|
||||||
placeholder="http://localhost:3000/mcp"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Headers"
|
|
||||||
pairs={headerPairs}
|
|
||||||
onChange={(pairs) => { setHeaderPairs(pairs); }}
|
|
||||||
onSave={saveHeaders}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Environment variables for HTTP+Docker */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyValueEditor({
|
|
||||||
label,
|
|
||||||
pairs,
|
|
||||||
onChange,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
pairs: [string, string][];
|
|
||||||
onChange: (pairs: [string, string][]) => void;
|
|
||||||
onSave: (pairs: [string, string][]) => void;
|
|
||||||
}) {
|
|
||||||
const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
|
|
||||||
{pairs.map(([key, value], i) => (
|
|
||||||
<div key={i} className="flex gap-1 items-center mb-1">
|
|
||||||
<input
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [e.target.value, value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="KEY"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">=</span>
|
|
||||||
<input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [key, e.target.value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="value"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const updated = pairs.filter((_, j) => j !== i);
|
|
||||||
onChange(updated);
|
|
||||||
onSave(updated);
|
|
||||||
}}
|
|
||||||
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onChange([...pairs, ["", ""]]);
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
+ Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import type { ProjectPath } from "../../lib/types";
|
import type { ProjectPath } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { inputClass, monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const formId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
nameInputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBrowse = async (index: number) => {
|
const handleBrowse = async (index: number) => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEntry = (
|
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
|
||||||
index: number,
|
|
||||||
field: keyof PathEntry,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const entries = [...pathEntries];
|
const entries = [...pathEntries];
|
||||||
entries[index] = { ...entries[index], [field]: value };
|
entries[index] = { ...entries[index], [field]: value };
|
||||||
setPathEntries(entries);
|
setPathEntries(entries);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
|
||||||
setPathEntries(pathEntries.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Add Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[30rem]"
|
||||||
|
initialFocusRef={nameInputRef}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||||
|
{loading ? "Adding…" : "Add Project"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<div>
|
||||||
|
<label
|
||||||
<form onSubmit={handleSubmit}>
|
htmlFor={`${formId}-name`}
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
className="block text-[13px] font-medium mb-1"
|
||||||
Project Name
|
>
|
||||||
|
Project name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id={`${formId}-name`}
|
||||||
ref={nameInputRef}
|
ref={nameInputRef}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="my-project"
|
placeholder="my-project"
|
||||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<div>
|
||||||
Folders
|
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||||
</label>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
{pathEntries.map((entry, i) => (
|
{pathEntries.map((entry, i) => (
|
||||||
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
<div
|
||||||
<div className="flex gap-1">
|
key={i}
|
||||||
|
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
value={entry.host_path}
|
value={entry.host_path}
|
||||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
placeholder="/path/to/folder"
|
placeholder="/path/to/folder"
|
||||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
aria-label={`Folder ${i + 1} host path`}
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||||
type="button"
|
|
||||||
onClick={() => handleBrowse(i)}
|
|
||||||
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</Button>
|
||||||
{pathEntries.length > 1 && (
|
{pathEntries.length > 1 && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="md"
|
||||||
onClick={() => removeEntry(i)}
|
variant="danger"
|
||||||
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
aria-label={`Remove folder ${i + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
x
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||||
|
/workspace/
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
value={entry.mount_name}
|
value={entry.mount_name}
|
||||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||||
placeholder="mount-name"
|
placeholder="mount-name"
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
aria-label={`Folder ${i + 1} mount name`}
|
||||||
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
className="mt-2"
|
||||||
onClick={addEntry}
|
onClick={() =>
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ Add folder
|
+ Add folder
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="px-2 py-1.5 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/30 rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Adding..." : "Add Project"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
import { SwitchRow, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClaudeCodeSettings | null;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||||
|
tui_mode: null,
|
||||||
|
effort: null,
|
||||||
|
auto_scroll_disabled: false,
|
||||||
|
focus_mode: false,
|
||||||
|
show_thinking_summaries: false,
|
||||||
|
enable_session_recap: false,
|
||||||
|
env_scrub: false,
|
||||||
|
prompt_caching_1h: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||||
|
return (
|
||||||
|
s.tui_mode === null &&
|
||||||
|
s.effort === null &&
|
||||||
|
s.auto_scroll_disabled === false &&
|
||||||
|
s.focus_mode === false &&
|
||||||
|
s.show_thinking_summaries === false &&
|
||||||
|
s.enable_session_recap === false &&
|
||||||
|
s.env_scrub === false &&
|
||||||
|
s.prompt_caching_1h === false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOLEAN_FIELDS: {
|
||||||
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||||
|
{
|
||||||
|
key: "show_thinking_summaries",
|
||||||
|
label: "Thinking summaries",
|
||||||
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enable_session_recap",
|
||||||
|
label: "Session recap",
|
||||||
|
hint: "Provides context when returning to a session.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auto_scroll_disabled",
|
||||||
|
label: "Auto-scroll disabled",
|
||||||
|
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "env_scrub",
|
||||||
|
label: "Env scrub",
|
||||||
|
hint: "Strips credentials from subprocess environments.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "prompt_caching_1h",
|
||||||
|
label: "Prompt caching (1h)",
|
||||||
|
hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ClaudeCodeSettingsEditor({
|
||||||
|
settings,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||||
|
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS });
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const apply = (patch: Partial<ClaudeCodeSettings>) => {
|
||||||
|
const next = { ...local, ...patch };
|
||||||
|
setLocal(next);
|
||||||
|
onSave(isAllDefaults(next) ? null : next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="TUI mode"
|
||||||
|
hint="Enables flicker-free alt-screen rendering."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.tui_mode ?? ""}
|
||||||
|
aria-label="TUI mode"
|
||||||
|
onChange={(e) => apply({ tui_mode: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="fullscreen">Fullscreen</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Effort level"
|
||||||
|
hint="Controls how much reasoning Claude applies."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.effort ?? ""}
|
||||||
|
aria-label="Effort level"
|
||||||
|
onChange={(e) => apply({ effort: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||||
|
<SwitchRow
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label={label}
|
||||||
|
checked={local[key]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClaudeCodeSettings | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (settings: ClaudeCodeSettings | null) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
|
||||||
|
export default function ClaudeCodeSettingsModal({
|
||||||
|
settings,
|
||||||
|
disabled,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Claude Code Settings"
|
||||||
|
onClose={onClose}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
|
>
|
||||||
|
<ClaudeCodeSettingsEditor
|
||||||
|
settings={settings}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason="Container must be stopped to change Claude Code settings."
|
||||||
|
onSave={async (next) => {
|
||||||
|
try {
|
||||||
|
await onSave(next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save Claude Code settings:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (instructions: string) => Promise<unknown>;
|
||||||
|
rows?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsEditor({
|
||||||
|
instructions: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
rows = 10,
|
||||||
|
autoFocus = false,
|
||||||
|
}: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInstructions(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
onBlur={() => onSave(instructions)}
|
||||||
|
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||||
|
aria-label="Claude instructions"
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] resize-y font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeInstructionsEditor from "./ClaudeInstructionsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
@@ -7,74 +9,35 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
|
||||||
const [instructions, setInstructions] = useState(initial);
|
export default function ClaudeInstructionsModal({
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
instructions,
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
disabled,
|
||||||
|
onSave,
|
||||||
useEffect(() => {
|
onClose,
|
||||||
textareaRef.current?.focus();
|
}: Props) {
|
||||||
}, []);
|
return (
|
||||||
|
<Modal
|
||||||
useEffect(() => {
|
title="Claude Instructions"
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
description="Written to ~/.claude/CLAUDE.md inside containers."
|
||||||
if (e.key === "Escape") onClose();
|
onClose={onClose}
|
||||||
};
|
widthClassName="w-[40rem]"
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
>
|
||||||
}, [onClose]);
|
<ClaudeInstructionsEditor
|
||||||
|
instructions={instructions}
|
||||||
const handleOverlayClick = useCallback(
|
disabled={disabled}
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
disabledReason="Container must be stopped to change Claude instructions."
|
||||||
if (e.target === overlayRef.current) onClose();
|
rows={14}
|
||||||
},
|
autoFocus
|
||||||
[onClose],
|
onSave={async (value) => {
|
||||||
);
|
try {
|
||||||
|
await onSave(value);
|
||||||
const handleBlur = async () => {
|
} catch (err) {
|
||||||
try { await onSave(instructions); } catch (err) {
|
|
||||||
console.error("Failed to update Claude instructions:", err);
|
console.error("Failed to update Claude instructions:", err);
|
||||||
}
|
}
|
||||||
};
|
}}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
|
|
||||||
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
|
||||||
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{disabled && (
|
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
|
||||||
Container must be stopped to change Claude instructions.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={instructions}
|
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="Enter instructions for Claude Code in this project's container..."
|
|
||||||
disabled={disabled}
|
|
||||||
rows={14}
|
|
||||||
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
|
|
||||||
/>
|
/>
|
||||||
|
</Modal>
|
||||||
<div className="flex justify-end mt-4">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -7,49 +8,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onCancel();
|
|
||||||
},
|
|
||||||
[onCancel],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Remove Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onCancel}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[26rem]"
|
||||||
>
|
footer={
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
<>
|
||||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
|
||||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="md"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Are you sure you want to remove{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||||
|
delete the container, config volume, and stored credentials.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import ConfirmResetModal from "./ConfirmResetModal";
|
||||||
|
|
||||||
|
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||||
|
async function flushFocus() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConfirmResetModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderModal() {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmResetModal
|
||||||
|
projectName="api-server"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
return { onConfirm, onCancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("names what will be lost rather than just asking to confirm", async () => {
|
||||||
|
await renderModal();
|
||||||
|
// The whole point of the gate: Reset deletes the volumes, and the two
|
||||||
|
// losses users do not expect are the login and the session transcripts.
|
||||||
|
expect(screen.getByText(/sign in again/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/session transcript/i)).toBeInTheDocument();
|
||||||
|
// And it must say what is safe, or the warning reads as "you lose everything".
|
||||||
|
expect(screen.getByText(/mounted project folders/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset until confirmed", async () => {
|
||||||
|
const { onConfirm, onCancel } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets on confirm", async () => {
|
||||||
|
const { onConfirm } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Reset container" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset is destructive in a way its name does not advertise.
|
||||||
|
*
|
||||||
|
* `rebuild_project_container` calls `remove_project_volumes`, which deletes
|
||||||
|
* both `triple-c-home-{id}` and `triple-c-claude-config-{id}` — so the OAuth
|
||||||
|
* login, any skills or agents installed in the container, and every session
|
||||||
|
* transcript go with them. That is intentional (Reset exists to get back to a
|
||||||
|
* clean base image), but it is not recoverable, so it gets the same
|
||||||
|
* confirmation gate as Remove.
|
||||||
|
*/
|
||||||
|
export default function ConfirmResetModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Reset container"
|
||||||
|
onClose={onCancel}
|
||||||
|
widthClassName="w-[28rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
|
>
|
||||||
|
Reset container
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
<p>
|
||||||
|
Rebuild{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>’s
|
||||||
|
container from the clean base image.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This deletes the container’s volumes, which means you will lose:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
your <code className="font-mono">claude login</code> — you will need to
|
||||||
|
sign in again
|
||||||
|
</li>
|
||||||
|
<li>any skills, agents or plugins installed inside the container</li>
|
||||||
|
<li>every saved session transcript, so past sessions cannot be resumed</li>
|
||||||
|
<li>anything installed with <code className="font-mono">apt</code>, <code className="font-mono">pip</code> or <code className="font-mono">npm</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Your mounted project folders are on the host and are{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">not</strong> affected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectName: string;
|
|
||||||
operation: "starting" | "stopping" | "resetting";
|
|
||||||
progressMsg: string | null;
|
|
||||||
error: string | null;
|
|
||||||
completed: boolean;
|
|
||||||
onForceStop: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationLabels: Record<string, string> = {
|
|
||||||
starting: "Starting",
|
|
||||||
stopping: "Stopping",
|
|
||||||
resetting: "Resetting",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContainerProgressModal({
|
|
||||||
projectName,
|
|
||||||
operation,
|
|
||||||
progressMsg,
|
|
||||||
error,
|
|
||||||
completed,
|
|
||||||
onForceStop,
|
|
||||||
onClose,
|
|
||||||
}: Props) {
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-close on success after 800ms
|
|
||||||
useEffect(() => {
|
|
||||||
if (completed && !error) {
|
|
||||||
const timer = setTimeout(onClose, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
// Escape to close (only when completed or error)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && (completed || error)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
|
||||||
},
|
|
||||||
[completed, error, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const inProgress = !completed && !error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">
|
|
||||||
{operationLabels[operation]} “{projectName}”
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Spinner / checkmark / error icon */}
|
|
||||||
<div className="flex justify-center mb-3">
|
|
||||||
{error ? (
|
|
||||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
|
||||||
) : completed ? (
|
|
||||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress message */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
|
||||||
{error
|
|
||||||
? <span className="text-[var(--error)]">{error}</span>
|
|
||||||
: completed
|
|
||||||
? "Done!"
|
|
||||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex justify-center gap-2">
|
|
||||||
{inProgress && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
|
||||||
>
|
|
||||||
Force Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(completed || error) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Env-var table. Used inline in Project Home → Config and in global Settings. */
|
||||||
|
export default function EnvVarsEditor({
|
||||||
|
envVars: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVars(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||||
|
const updated = [...vars];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vars.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
No environment variables configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vars.map((ev, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={ev.key}
|
||||||
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
|
onBlur={() => onSave(vars)}
|
||||||
|
placeholder="KEY"
|
||||||
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-2/5 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
|
onBlur={() => onSave(vars)}
|
||||||
|
placeholder="value"
|
||||||
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove environment variable ${ev.key || i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = vars.filter((_, j) => j !== i);
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...vars, { key: "", value: "" }];
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import EnvVarsEditor from "./EnvVarsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
envVars: EnvVar[];
|
envVars: EnvVar[];
|
||||||
@@ -8,117 +10,27 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
|
||||||
const [vars, setVars] = useState<EnvVar[]>(initial);
|
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
return (
|
||||||
|
<Modal
|
||||||
useEffect(() => {
|
title="Environment Variables"
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
onClose={onClose}
|
||||||
if (e.key === "Escape") onClose();
|
widthClassName="w-[36rem]"
|
||||||
};
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
>
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
<EnvVarsEditor
|
||||||
}, [onClose]);
|
envVars={envVars}
|
||||||
|
disabled={disabled}
|
||||||
const handleOverlayClick = useCallback(
|
disabledReason="Container must be stopped to change environment variables."
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onSave={async (vars) => {
|
||||||
if (e.target === overlayRef.current) onClose();
|
try {
|
||||||
},
|
await onSave(vars);
|
||||||
[onClose],
|
} catch (err) {
|
||||||
);
|
|
||||||
|
|
||||||
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
|
||||||
const updated = [...vars];
|
|
||||||
updated[index] = { ...updated[index], [field]: value };
|
|
||||||
setVars(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeVar = async (index: number) => {
|
|
||||||
const updated = vars.filter((_, i) => i !== index);
|
|
||||||
setVars(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to remove environment variable:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addVar = async () => {
|
|
||||||
const updated = [...vars, { key: "", value: "" }];
|
|
||||||
setVars(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to add environment variable:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(vars); } catch (err) {
|
|
||||||
console.error("Failed to update environment variables:", err);
|
console.error("Failed to update environment variables:", err);
|
||||||
}
|
}
|
||||||
};
|
}}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
|
|
||||||
|
|
||||||
{disabled && (
|
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
|
||||||
Container must be stopped to change environment variables.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{vars.length === 0 && (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
|
|
||||||
)}
|
|
||||||
{vars.map((ev, i) => (
|
|
||||||
<div key={i} className="flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
value={ev.key}
|
|
||||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="KEY"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
/>
|
||||||
<input
|
</Modal>
|
||||||
value={ev.value}
|
|
||||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="value"
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => removeVar(i)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
onClick={addVar}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add variable
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { useFileManager } from "../../hooks/useFileManager";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
|
||||||
const {
|
|
||||||
currentPath,
|
|
||||||
entries,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
navigate,
|
|
||||||
goUp,
|
|
||||||
refresh,
|
|
||||||
downloadFile,
|
|
||||||
uploadFile,
|
|
||||||
} = useFileManager(projectId);
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Load initial directory
|
|
||||||
useEffect(() => {
|
|
||||||
navigate("/workspace");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build breadcrumbs from current path
|
|
||||||
const breadcrumbs = currentPath === "/"
|
|
||||||
? [{ label: "/", path: "/" }]
|
|
||||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
|
||||||
if (i === 0) {
|
|
||||||
acc.push({ label: "/", path: "/" });
|
|
||||||
} else if (part) {
|
|
||||||
const parentPath = acc[acc.length - 1].path;
|
|
||||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
|
||||||
acc.push({ label: part, path: fullPath });
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
|
|
||||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Path bar */}
|
|
||||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
|
||||||
{breadcrumbs.map((crumb, i) => (
|
|
||||||
<span key={crumb.path} className="flex items-center gap-1">
|
|
||||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(crumb.path)}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{crumb.label}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
|
||||||
{error && (
|
|
||||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && entries.length === 0 ? (
|
|
||||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<tbody>
|
|
||||||
{/* Go up entry */}
|
|
||||||
{currentPath !== "/" && (
|
|
||||||
<tr
|
|
||||||
onClick={() => goUp()}
|
|
||||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<tr
|
|
||||||
key={entry.name}
|
|
||||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
|
||||||
className={`${
|
|
||||||
entry.is_directory ? "cursor-pointer" : ""
|
|
||||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5">
|
|
||||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
|
||||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
|
||||||
{!entry.is_directory && formatSize(entry.size)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
|
||||||
{entry.modified}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
{!entry.is_directory && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadFile(entry);
|
|
||||||
}}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{entries.length === 0 && !loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
|
||||||
Empty directory
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
|
|
||||||
<button
|
|
||||||
onClick={uploadFile}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Upload file
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import PermissionModeControl, {
|
||||||
|
effectivePermissionMode,
|
||||||
|
permissionModePatch,
|
||||||
|
} from "./PermissionModeControl";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "running",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("effectivePermissionMode", () => {
|
||||||
|
it("falls back to the legacy boolean when permission_mode is null", () => {
|
||||||
|
expect(effectivePermissionMode(baseProject)).toBe("default");
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({ ...baseProject, full_permissions: true }),
|
||||||
|
).toBe("bypass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers permission_mode when it is set", () => {
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "plan",
|
||||||
|
full_permissions: true,
|
||||||
|
}),
|
||||||
|
).toBe("plan");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissionModePatch", () => {
|
||||||
|
it("keeps the legacy full_permissions flag in sync", () => {
|
||||||
|
expect(permissionModePatch("bypass")).toEqual({
|
||||||
|
permission_mode: "bypass",
|
||||||
|
full_permissions: true,
|
||||||
|
});
|
||||||
|
expect(permissionModePatch("acceptEdits")).toEqual({
|
||||||
|
permission_mode: "acceptEdits",
|
||||||
|
full_permissions: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PermissionModeControl", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all four modes as a radio group with the effective one checked", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
|
||||||
|
expect(group).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
||||||
|
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the picked mode", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Accept Edits" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves selection with the arrow keys", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
|
||||||
|
key: "ArrowRight",
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sandbox state beside the control", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
expect(screen.getByTestId("sandbox-state")).toHaveTextContent(
|
||||||
|
/Sandbox\s*ON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not paint Bypass as dangerous while the sandbox contains it", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{ ...baseProject, permission_mode: "bypass" }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--accent-emphasis");
|
||||||
|
expect(bypass.className).not.toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/contained by the sandbox/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses caution colour only when Bypass runs with the sandbox off", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "bypass",
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/Caution/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { PermissionMode, Project } from "../../lib/types";
|
||||||
|
import SegmentedControl, { type Segment } from "../ui/SegmentedControl";
|
||||||
|
|
||||||
|
export const PERMISSION_MODES: Segment<PermissionMode>[] = [
|
||||||
|
{ value: "plan", label: "Plan", hint: "Claude proposes a plan and makes no changes." },
|
||||||
|
{ value: "default", label: "Default", hint: "Claude asks before each tool call." },
|
||||||
|
{
|
||||||
|
value: "acceptEdits",
|
||||||
|
label: "Accept Edits",
|
||||||
|
hint: "File edits are auto-approved; other tools still prompt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bypass",
|
||||||
|
label: "Bypass",
|
||||||
|
hint: "Every tool call is auto-approved (--dangerously-skip-permissions).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `permission_mode` is nullable for projects saved before it existed; fall back
|
||||||
|
* to the legacy boolean.
|
||||||
|
*/
|
||||||
|
export function effectivePermissionMode(project: Project): PermissionMode {
|
||||||
|
return project.permission_mode ?? (project.full_permissions ? "bypass" : "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch to apply when the user picks a mode. `full_permissions` is kept in
|
||||||
|
* sync so anything still reading the legacy field cannot drift.
|
||||||
|
*/
|
||||||
|
export function permissionModePatch(mode: PermissionMode): Partial<Project> {
|
||||||
|
return { permission_mode: mode, full_permissions: mode === "bypass" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
onChange: (mode: PermissionMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Explanation of why the control is disabled, shown beneath it. */
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hero control. Per §B3.3: Bypass is only painted as caution when the
|
||||||
|
* sandbox is OFF — with the sandbox ON, bypassing prompts is contained.
|
||||||
|
*/
|
||||||
|
export default function PermissionModeControl({
|
||||||
|
project,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
|
}: Props) {
|
||||||
|
const mode = effectivePermissionMode(project);
|
||||||
|
const sandboxOn = project.sandbox_mode_enabled;
|
||||||
|
const uncontainedBypass = mode === "bypass" && !sandboxOn;
|
||||||
|
|
||||||
|
const segments = PERMISSION_MODES.map((segment) =>
|
||||||
|
segment.value === "bypass" ? { ...segment, caution: !sandboxOn } : segment,
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = PERMISSION_MODES.find((s) => s.value === mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Permission mode
|
||||||
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Permission mode"
|
||||||
|
segments={segments}
|
||||||
|
value={mode}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs text-[var(--text-secondary)]"
|
||||||
|
data-testid="sandbox-state"
|
||||||
|
>
|
||||||
|
Sandbox{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
sandboxOn ? "text-[var(--success)] font-semibold" : "text-[var(--warning)] font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sandboxOn ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
{sandboxOn ? " — bubblewrap isolation" : " — no filesystem/network isolation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-xs leading-snug ${
|
||||||
|
uncontainedBypass ? "text-[var(--warning)]" : "text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
data-testid="permission-mode-hint"
|
||||||
|
>
|
||||||
|
{uncontainedBypass
|
||||||
|
? "Caution: every tool call is auto-approved and the sandbox is off, so nothing contains what Claude runs."
|
||||||
|
: mode === "bypass"
|
||||||
|
? "Every tool call is auto-approved — contained by the sandbox."
|
||||||
|
: (active?.hint ?? "")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.status === "running" && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">
|
||||||
|
Applies to terminals opened from now on.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PortMapping } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portMappings: PortMapping[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (mappings: PortMapping[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortMappingsEditor({
|
||||||
|
portMappings: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMappings(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updatePort = (
|
||||||
|
index: number,
|
||||||
|
field: "host_port" | "container_port",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||||
|
<span className="w-[28%]">Host port</span>
|
||||||
|
<span className="w-[28%]">Container port</span>
|
||||||
|
<span className="w-[22%]">Protocol</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.map((pm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.host_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Host port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.container_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Container port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pm.protocol}
|
||||||
|
aria-label={`Protocol ${i + 1}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[i] = { ...updated[i], protocol: e.target.value };
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[22%] ${selectClass}`}
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove port mapping ${i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = mappings.filter((_, j) => j !== i);
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [
|
||||||
|
...mappings,
|
||||||
|
{ host_port: 0, container_port: 0, protocol: "tcp" },
|
||||||
|
];
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add port mapping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { PortMapping } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
portMappings: PortMapping[];
|
|
||||||
disabled: boolean;
|
|
||||||
onSave: (mappings: PortMapping[]) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
|
||||||
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProtocol = (index: number, value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
updated[index] = { ...updated[index], protocol: value };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMapping = async (index: number) => {
|
|
||||||
const updated = mappings.filter((_, i) => i !== index);
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to remove port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMapping = async () => {
|
|
||||||
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to add port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(mappings); } catch (err) {
|
|
||||||
console.error("Failed to update port mappings:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
|
||||||
Map host ports to container ports. Services can be started after the container is running.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{disabled && (
|
|
||||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
|
||||||
Container must be stopped to change port mappings.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{mappings.length === 0 && (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
|
||||||
)}
|
|
||||||
{mappings.length > 0 && (
|
|
||||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
|
||||||
<span className="w-[30%]">Host Port</span>
|
|
||||||
<span className="w-[30%]">Container Port</span>
|
|
||||||
<span className="w-[25%]">Protocol</span>
|
|
||||||
<span className="w-[15%]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mappings.map((pm, i) => (
|
|
||||||
<div key={i} className="flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.host_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.container_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={pm.protocol}
|
|
||||||
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => removeMapping(i)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
onClick={addMapping}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add port mapping
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
||||||
import ProjectCard from "./ProjectCard";
|
|
||||||
import type { Project } from "../../lib/types";
|
|
||||||
|
|
||||||
// Mock Tauri dialog plugin
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
open: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock hooks
|
|
||||||
const mockUpdate = vi.fn();
|
|
||||||
const mockStart = vi.fn();
|
|
||||||
const mockStop = vi.fn();
|
|
||||||
const mockRebuild = vi.fn();
|
|
||||||
const mockRemove = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useProjects", () => ({
|
|
||||||
useProjects: () => ({
|
|
||||||
start: mockStart,
|
|
||||||
stop: mockStop,
|
|
||||||
rebuild: mockRebuild,
|
|
||||||
remove: mockRemove,
|
|
||||||
update: mockUpdate,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useTerminal", () => ({
|
|
||||||
useTerminal: () => ({
|
|
||||||
open: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useMcpServers", () => ({
|
|
||||||
useMcpServers: () => ({
|
|
||||||
mcpServers: [],
|
|
||||||
refresh: vi.fn(),
|
|
||||||
add: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockSelectedProjectId: string | null = null;
|
|
||||||
vi.mock("../../store/appState", () => ({
|
|
||||||
useAppState: vi.fn((selector) =>
|
|
||||||
selector({
|
|
||||||
selectedProjectId: mockSelectedProjectId,
|
|
||||||
setSelectedProject: vi.fn(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockProject: Project = {
|
|
||||||
id: "test-1",
|
|
||||||
name: "Test Project",
|
|
||||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
|
||||||
container_id: null,
|
|
||||||
status: "stopped",
|
|
||||||
auth_mode: "anthropic",
|
|
||||||
bedrock_config: null,
|
|
||||||
allow_docker_access: false,
|
|
||||||
ssh_key_path: null,
|
|
||||||
git_token: null,
|
|
||||||
git_user_name: null,
|
|
||||||
git_user_email: null,
|
|
||||||
custom_env_vars: [],
|
|
||||||
port_mappings: [],
|
|
||||||
claude_instructions: null,
|
|
||||||
enabled_mcp_servers: [],
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ProjectCard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSelectedProjectId = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders project name and path", () => {
|
|
||||||
render(<ProjectCard project={mockProject} />);
|
|
||||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("card root has min-w-0 and overflow-hidden to contain content", () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
const card = container.firstElementChild;
|
|
||||||
expect(card).not.toBeNull();
|
|
||||||
expect(card!.className).toContain("min-w-0");
|
|
||||||
expect(card!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when selected and showing config", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSelectedProjectId = "test-1";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expanded area has min-w-0 and overflow-hidden", () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
|
|
||||||
const expandedSection = container.querySelector(".ml-4.mt-2");
|
|
||||||
expect(expandedSection).not.toBeNull();
|
|
||||||
expect(expandedSection!.className).toContain("min-w-0");
|
|
||||||
expect(expandedSection!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("folder path inputs use min-w-0 to allow shrinking", async () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
|
|
||||||
// Click Config button to show config panel
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Config"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// After config is shown, check the folder host_path input has min-w-0
|
|
||||||
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
|
|
||||||
expect(hostPathInputs.length).toBeGreaterThan(0);
|
|
||||||
expect(hostPathInputs[0].className).toContain("min-w-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("config panel container has overflow-hidden", async () => {
|
|
||||||
const { container } = render(<ProjectCard project={mockProject} />);
|
|
||||||
|
|
||||||
// Click Config button
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Config"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// The config panel has border-t and overflow containment classes
|
|
||||||
const allDivs = container.querySelectorAll("div");
|
|
||||||
const configPanel = Array.from(allDivs).find(
|
|
||||||
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
|
|
||||||
);
|
|
||||||
expect(configPanel).toBeDefined();
|
|
||||||
expect(configPanel!.className).toContain("overflow-hidden");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,32 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import ProjectCard from "./ProjectCard";
|
import ProjectRow from "./ProjectRow";
|
||||||
import AddProjectDialog from "./AddProjectDialog";
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
<div className="flex items-center justify-between px-1 py-1 mb-1.5">
|
||||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Projects
|
Projects
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button onClick={() => setShowAdd(true)} aria-label="Add project">
|
||||||
onClick={() => setShowAdd(true)}
|
+ Add
|
||||||
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
|
</Button>
|
||||||
title="Add project"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="px-2 text-sm text-[var(--text-secondary)]">
|
<p className="px-1 text-xs text-[var(--text-secondary)]">
|
||||||
No projects yet. Click + to add one.
|
No projects yet — use “+ Add” to create one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectRow key={project.id} project={project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import ProjectRow from "./ProjectRow";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const mockStart = vi.fn();
|
||||||
|
const mockStop = vi.fn();
|
||||||
|
const mockOpenClaudeTerminal = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjectActions", () => ({
|
||||||
|
useProjectActions: () => ({
|
||||||
|
busy: false,
|
||||||
|
backingUp: false,
|
||||||
|
handleStart: mockStart,
|
||||||
|
handleStop: mockStop,
|
||||||
|
handleReset: vi.fn(),
|
||||||
|
handleBackup: vi.fn(),
|
||||||
|
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||||
|
openShell: vi.fn(),
|
||||||
|
openTerminalWithCommand: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOpenProjectHome = vi.fn();
|
||||||
|
let storeState: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
vi.mock("../../store/appState", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../store/appState")>(
|
||||||
|
"../../store/appState",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "test-1",
|
||||||
|
name: "Test Project",
|
||||||
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
storeState = {
|
||||||
|
activeTabKey: null,
|
||||||
|
selectedProjectId: null,
|
||||||
|
openProjectHome: mockOpenProjectHome,
|
||||||
|
containerProgress: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectRow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders project name and mount path", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("row root has min-w-0 and overflow-hidden to contain content", () => {
|
||||||
|
const { container } = render(<ProjectRow project={baseProject} />);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(row!.className).toContain("min-w-0");
|
||||||
|
expect(row!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("communicates status with a word, not colour alone", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
|
||||||
|
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting the row opens that project's home tab instead of expanding in place", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByText("Test Project"));
|
||||||
|
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
|
||||||
|
// No config form is rendered in the sidebar any more.
|
||||||
|
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers start when stopped and stop when running", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
|
||||||
|
expect(mockStart).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar rows are select-only: name, paths, status, and hover controls.
|
||||||
|
* Clicking a row opens (or focuses) that project's Project Home tab — the
|
||||||
|
* settings form no longer lives in a 280px accordion.
|
||||||
|
*/
|
||||||
|
export default function ProjectRow({ project }: Props) {
|
||||||
|
const { activeTabKey, selectedProjectId, openProjectHome, progress } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
selectedProjectId: s.selectedProjectId,
|
||||||
|
openProjectHome: s.openProjectHome,
|
||||||
|
progress: s.containerProgress[project.id],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { busy, handleStart, handleStop, openClaudeTerminal } =
|
||||||
|
useProjectActions(project);
|
||||||
|
|
||||||
|
const isSelected =
|
||||||
|
activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id;
|
||||||
|
const isRunning = project.status === "running";
|
||||||
|
const isTransitioning =
|
||||||
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--bg-tertiary)]"
|
||||||
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProjectHome(project.id)}
|
||||||
|
aria-current={isSelected ? "true" : undefined}
|
||||||
|
className="w-full text-left min-w-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||||
|
<span className="text-[13px] font-medium truncate flex-1 text-[var(--text-primary)]">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
{/* Space reserved for the hover controls so the name never jumps. */}
|
||||||
|
<span className="w-[3.75rem] flex-shrink-0" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 ml-4 space-y-0.5 min-w-0">
|
||||||
|
{project.paths.map((pp, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-xs text-[var(--text-secondary)] truncate font-mono"
|
||||||
|
>
|
||||||
|
/workspace/{pp.mount_name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-xs">
|
||||||
|
{isTransitioning ? (
|
||||||
|
<span className="text-[var(--warning)] truncate block">
|
||||||
|
{progress ?? `${project.status}…`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ProjectStatusIndicator
|
||||||
|
status={project.status}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hover / focus-within controls */}
|
||||||
|
<div className="absolute top-1.5 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
// While a container is mid-transition this stays live so it can act
|
||||||
|
// as the force-stop that the old progress modal used to offer.
|
||||||
|
onClick={() => (isRunning || isTransitioning ? handleStop() : handleStart())}
|
||||||
|
title={
|
||||||
|
isTransitioning
|
||||||
|
? `Force stop ${project.name}`
|
||||||
|
: isRunning
|
||||||
|
? `Stop ${project.name}`
|
||||||
|
: `Start ${project.name}`
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
isTransitioning
|
||||||
|
? `Force stop ${project.name}`
|
||||||
|
: isRunning
|
||||||
|
? `Stop ${project.name}`
|
||||||
|
: `Start ${project.name}`
|
||||||
|
}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
|
>
|
||||||
|
{isRunning || isTransitioning ? (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 5.5v13l11-6.5z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isRunning}
|
||||||
|
onClick={() => openClaudeTerminal()}
|
||||||
|
title={`Open a Claude terminal for ${project.name}`}
|
||||||
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
<polyline points="7 9 10 12 7 15" />
|
||||||
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Project, ScheduledTask, SchedulerNotification } from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
clearSchedulerNotifications,
|
||||||
|
getScheduledTaskLog,
|
||||||
|
getSchedulerNotifications,
|
||||||
|
listScheduledTasks,
|
||||||
|
removeScheduledTask,
|
||||||
|
runScheduledTaskNow,
|
||||||
|
setScheduledTaskEnabled,
|
||||||
|
} from "../../../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import Toggle from "../../ui/Toggle";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
import TaskEditorModal from "./TaskEditorModal";
|
||||||
|
import { formatAge } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI for `triple-c-scheduler`, which ships in every container and until now
|
||||||
|
* had no interface beyond a CLAUDE.md paragraph.
|
||||||
|
*/
|
||||||
|
export default function AutomationTab({ project }: Props) {
|
||||||
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
|
const [notifications, setNotifications] = useState<SchedulerNotification[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||||
|
const [log, setLog] = useState<{ task: ScheduledTask; text: string } | null>(null);
|
||||||
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
|
||||||
|
/** `undefined` = closed, `null` = creating, a task = editing it. */
|
||||||
|
const [editing, setEditing] = useState<ScheduledTask | null | undefined>(undefined);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!running) {
|
||||||
|
setTasks([]);
|
||||||
|
setNotifications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
listScheduledTasks(project.id).catch(() => [] as ScheduledTask[]),
|
||||||
|
getSchedulerNotifications(project.id).catch(
|
||||||
|
() => [] as SchedulerNotification[],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([t, n]) => {
|
||||||
|
setTasks(t);
|
||||||
|
setNotifications(n);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [project.id, running]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||||
|
setBusyTaskId(taskId);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `${label} failed`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLog = async (task: ScheduledTask) => {
|
||||||
|
setBusyTaskId(task.id);
|
||||||
|
try {
|
||||||
|
const text = await getScheduledTaskLog(project.id, task.id, 200);
|
||||||
|
setLog({ task, text });
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not read the log for “${task.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removing = tasks.find((t) => t.id === confirmRemoveId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 max-w-4xl">
|
||||||
|
{/* Notifications */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<section className="border border-[var(--accent)]/40 bg-[var(--accent-muted)] rounded-[var(--radius-panel)]">
|
||||||
|
<header className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--accent)]">
|
||||||
|
{notifications.length} notification
|
||||||
|
{notifications.length === 1 ? "" : "s"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await clearSchedulerNotifications(project.id);
|
||||||
|
setNotifications([]);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not clear notifications",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<ul className="divide-y divide-[var(--border-color)]">
|
||||||
|
{notifications.map((n, i) => (
|
||||||
|
<li key={`${n.task_id}-${i}`} className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{n.task_name ?? n.task_id}
|
||||||
|
</span>
|
||||||
|
{n.status && (
|
||||||
|
<StatusIndicator
|
||||||
|
tone={n.status.toLowerCase() === "success" ? "ok" : "error"}
|
||||||
|
label={n.status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--text-secondary)] ml-auto">
|
||||||
|
{formatAge(n.created_at) ?? n.time ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||||
|
{n.summary ?? n.body}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Recurring Claude Code runs managed by{" "}
|
||||||
|
<code className="font-mono text-[var(--text-primary)]">
|
||||||
|
triple-c-scheduler
|
||||||
|
</code>{" "}
|
||||||
|
inside the container.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={load} disabled={!running || loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" disabled={!running} onClick={() => setEditing(null)}>
|
||||||
|
New task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to list its scheduled tasks.
|
||||||
|
</p>
|
||||||
|
) : tasks.length === 0 && !loading ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
No scheduled tasks yet. Use <strong>New task</strong>, or ask Claude to add one with{" "}
|
||||||
|
<code className="font-mono">triple-c-scheduler add</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||||
|
{task.task_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||||
|
{task.at ?? task.schedule}
|
||||||
|
{task.last_run ? ` · last run ${formatAge(task.last_run) ?? task.last_run}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
label={`${task.name} enabled`}
|
||||||
|
checked={task.enabled}
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onChange={(v) =>
|
||||||
|
withTask(task.id, "Toggle task", () =>
|
||||||
|
setScheduledTaskEnabled(project.id, task.id, v),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onClick={() =>
|
||||||
|
withTask(task.id, "Run now", () =>
|
||||||
|
runScheduledTaskNow(project.id, task.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Run now
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
|
||||||
|
Log
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onClick={() => setConfirmRemoveId(task.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{editing !== undefined && (
|
||||||
|
<TaskEditorModal
|
||||||
|
project={project}
|
||||||
|
task={editing}
|
||||||
|
onClose={() => setEditing(undefined)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log && (
|
||||||
|
<Modal
|
||||||
|
title={`Log — ${log.task.name}`}
|
||||||
|
onClose={() => setLog(null)}
|
||||||
|
widthClassName="w-[46rem]"
|
||||||
|
footer={<Button onClick={() => setLog(null)}>Close</Button>}
|
||||||
|
>
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{log.text.trim() || "(empty log)"}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{removing && (
|
||||||
|
<Modal
|
||||||
|
title="Remove scheduled task"
|
||||||
|
onClose={() => setConfirmRemoveId(null)}
|
||||||
|
widthClassName="w-[26rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRemoveId(null);
|
||||||
|
withTask(removing.id, "Remove task", () =>
|
||||||
|
removeScheduledTask(project.id, removing.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Remove <strong className="text-[var(--text-primary)]">{removing.name}</strong>{" "}
|
||||||
|
from this container’s scheduler?
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
CapabilityGroup,
|
||||||
|
ContainerCapabilities,
|
||||||
|
Project,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
import { listContainerCapabilities } from "../../../lib/tauri-commands";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only inventory of what Claude Code can do inside this container.
|
||||||
|
* Triple-C surfaces counts and launches the real editors in the terminal —
|
||||||
|
* it does not rebuild `/agents`, `/hooks`, or `/plugins` as forms.
|
||||||
|
*/
|
||||||
|
const GROUPS: { key: keyof ContainerCapabilities; label: string }[] = [
|
||||||
|
{ key: "skills", label: "Skills" },
|
||||||
|
{ key: "agents", label: "Agents" },
|
||||||
|
{ key: "commands", label: "Commands" },
|
||||||
|
{ key: "hooks", label: "Hooks" },
|
||||||
|
{ key: "plugins", label: "Plugins" },
|
||||||
|
{ key: "mcp_servers", label: "MCP servers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SLASH_HINT: Partial<Record<keyof ContainerCapabilities, string>> = {
|
||||||
|
agents: "/agents",
|
||||||
|
hooks: "/hooks",
|
||||||
|
plugins: "/plugins",
|
||||||
|
mcp_servers: "/mcp",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
onManageInTerminal: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CapabilityTiles({ project, onManageInTerminal }: Props) {
|
||||||
|
const [capabilities, setCapabilities] = useState<ContainerCapabilities | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState<keyof ContainerCapabilities | null>(null);
|
||||||
|
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
setCapabilities(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
listContainerCapabilities(project.id)
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCapabilities(c);
|
||||||
|
})
|
||||||
|
// Introspection degrades to "nothing found" when the container is
|
||||||
|
// unreachable — that is an empty state, not an error banner.
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCapabilities(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.id, running, project.container_id]);
|
||||||
|
|
||||||
|
const openGroup: CapabilityGroup | null =
|
||||||
|
open && capabilities ? capabilities[open] : null;
|
||||||
|
const openLabel = GROUPS.find((g) => g.key === open)?.label ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-2">
|
||||||
|
Capabilities
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to read its skills, agents, commands, hooks and plugins.
|
||||||
|
</p>
|
||||||
|
) : loading && !capabilities ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Reading container volume…</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{GROUPS.map(({ key, label }) => {
|
||||||
|
const count = capabilities?.[key].count ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
disabled={count === 0}
|
||||||
|
onClick={() => setOpen(key)}
|
||||||
|
className="flex items-baseline gap-2 px-3 py-2 min-w-[7.5rem] text-left bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] hover:border-[var(--accent)] disabled:hover:border-[var(--border-color)] disabled:cursor-default transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-lg font-semibold tabular-nums ${
|
||||||
|
count === 0 ? "text-[var(--text-disabled)]" : "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && openGroup && (
|
||||||
|
<Modal
|
||||||
|
title={`${openLabel} — ${project.name}`}
|
||||||
|
description={
|
||||||
|
SLASH_HINT[open]
|
||||||
|
? `Claude Code manages these with ${SLASH_HINT[open]}.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(null);
|
||||||
|
// Claude Code owns the editors; we just deep-link into them.
|
||||||
|
onManageInTerminal("claude");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage in terminal
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setOpen(null)}>Close</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{openGroup.items.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Nothing configured.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{openGroup.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`${item.name}-${i}`}
|
||||||
|
className="pb-2 border-b border-[var(--border-color)] last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-primary)] font-mono">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)]">
|
||||||
|
{item.scope}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import type { SaveState } from "../../../hooks/useSaveState";
|
||||||
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
|
import WorkspaceSection from "./config/WorkspaceSection";
|
||||||
|
import ModelSection from "./config/ModelSection";
|
||||||
|
import AccessSection from "./config/AccessSection";
|
||||||
|
import RuntimeSection from "./config/RuntimeSection";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
saveState: SaveState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPPED_ONLY =
|
||||||
|
"Container must be stopped to change this setting.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the seven config modals used to hold, full-width and grouped.
|
||||||
|
* Saves happen on blur; the indicator in the header reports the outcome.
|
||||||
|
*/
|
||||||
|
export default function ConfigTab({ project, save, saveState }: Props) {
|
||||||
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
const disabled = !isStopped;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between gap-4 min-h-[1.5rem]">
|
||||||
|
{disabled ? (
|
||||||
|
<p className="px-2 py-1 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]">
|
||||||
|
Container is {project.status} — stop it to change these settings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Changes save when a field loses focus.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceSection project={project} save={save} disabled={disabled} />
|
||||||
|
<ModelSection project={project} save={save} disabled={disabled} />
|
||||||
|
<AccessSection
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={STOPPED_ONLY}
|
||||||
|
/>
|
||||||
|
<RuntimeSection
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={STOPPED_ONLY}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old 42rem FileManager popup, now a main-area section. */
|
||||||
|
export default function FilesTab({ project }: Props) {
|
||||||
|
const {
|
||||||
|
currentPath,
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
navigate,
|
||||||
|
goUp,
|
||||||
|
refresh,
|
||||||
|
downloadFile,
|
||||||
|
uploadFile,
|
||||||
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running) navigate("/workspace");
|
||||||
|
// Re-list when the container comes up.
|
||||||
|
}, [navigate, running]);
|
||||||
|
|
||||||
|
const breadcrumbs =
|
||||||
|
currentPath === "/"
|
||||||
|
? [{ label: "/", path: "/" }]
|
||||||
|
: currentPath
|
||||||
|
.split("/")
|
||||||
|
.reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
acc.push({ label: "/", path: "/" });
|
||||||
|
} else if (part) {
|
||||||
|
const parentPath = acc[acc.length - 1].path;
|
||||||
|
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||||
|
acc.push({ label: part, path: fullPath });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to browse its files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||||
|
<nav aria-label="Path" className="flex items-center gap-1">
|
||||||
|
{breadcrumbs.map((crumb, i) => (
|
||||||
|
<span key={crumb.path} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(crumb.path)}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button onClick={uploadFile}>Upload file</Button>
|
||||||
|
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{currentPath !== "/" && (
|
||||||
|
<tr
|
||||||
|
onClick={goUp}
|
||||||
|
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
||||||
|
<td colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr
|
||||||
|
key={entry.name}
|
||||||
|
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||||
|
className={`${
|
||||||
|
entry.is_directory ? "cursor-pointer" : ""
|
||||||
|
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5">
|
||||||
|
<span
|
||||||
|
className={`font-mono ${
|
||||||
|
entry.is_directory
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.is_directory ? "📁 " : ""}
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||||
|
{!entry.is_directory && formatBytes(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
|
{entry.modified}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{!entry.is_directory && (
|
||||||
|
<Button
|
||||||
|
aria-label={`Download ${entry.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadFile(entry);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
Empty directory
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ClaudeSession, Project, ScheduledTask } from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
listClaudeSessions,
|
||||||
|
listScheduledTasks,
|
||||||
|
getSchedulerNotifications,
|
||||||
|
resumeSessionCommand,
|
||||||
|
} from "../../../lib/tauri-commands";
|
||||||
|
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import type { SaveState } from "../../../hooks/useSaveState";
|
||||||
|
import PermissionModeControl, {
|
||||||
|
permissionModePatch,
|
||||||
|
} from "../PermissionModeControl";
|
||||||
|
import CapabilityTiles from "./CapabilityTiles";
|
||||||
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatAge } from "./format";
|
||||||
|
import type { ProjectHomeTabId } from "./ProjectHome";
|
||||||
|
|
||||||
|
const BACKEND_LABEL: Record<Project["backend"], string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
bedrock: "AWS Bedrock",
|
||||||
|
ollama: "Ollama",
|
||||||
|
open_ai_compatible: "OpenAI Compatible",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
saveState: SaveState;
|
||||||
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
|
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewTab({
|
||||||
|
project,
|
||||||
|
save,
|
||||||
|
saveState,
|
||||||
|
actions,
|
||||||
|
onOpenTab,
|
||||||
|
}: Props) {
|
||||||
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
setSessions([]);
|
||||||
|
setTasks([]);
|
||||||
|
setNotificationCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
// All three degrade to empty when the container is unreachable.
|
||||||
|
listClaudeSessions(project.id)
|
||||||
|
.then((s) => !cancelled && setSessions(s.slice(0, 4)))
|
||||||
|
.catch(() => !cancelled && setSessions([]));
|
||||||
|
listScheduledTasks(project.id)
|
||||||
|
.then((t) => !cancelled && setTasks(t))
|
||||||
|
.catch(() => !cancelled && setTasks([]));
|
||||||
|
getSchedulerNotifications(project.id)
|
||||||
|
.then((n) => !cancelled && setNotificationCount(n.length))
|
||||||
|
.catch(() => !cancelled && setNotificationCount(0));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.id, running, project.container_id]);
|
||||||
|
|
||||||
|
const handleResume = async (session: ClaudeSession) => {
|
||||||
|
try {
|
||||||
|
const command = await resumeSessionCommand(project.id, session.id);
|
||||||
|
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to build the resume command:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 max-w-4xl">
|
||||||
|
{/* Permission mode — the hero control */}
|
||||||
|
<section className="p-3 border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<PermissionModeControl
|
||||||
|
project={project}
|
||||||
|
disabled={!running && project.status !== "stopped" && project.status !== "error"}
|
||||||
|
onChange={(mode) => save(permissionModePatch(mode))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex flex-wrap gap-x-6 gap-y-1 text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Backend{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{BACKEND_LABEL[project.backend]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Docker access{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{project.allow_docker_access ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Mission Control{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("config")}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit configuration →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CapabilityTiles
|
||||||
|
project={project}
|
||||||
|
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Recent sessions */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Recent sessions
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("sessions")}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
All sessions →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to list saved conversations.
|
||||||
|
</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No sessions yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||||
|
{session.name ?? session.summary ?? session.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
|
||||||
|
{formatAge(session.last_modified) ?? ""}
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => handleResume(session)}>Resume</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Scheduled tasks */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Scheduled tasks
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("automation")}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Automation →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to list scheduled tasks.
|
||||||
|
</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No scheduled tasks.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tasks.slice(0, 4).map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||||
|
{task.at ?? task.schedule}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{notificationCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("automation")}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
{notificationCount} notification{notificationCount === 1 ? "" : "s"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
|
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||||
|
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import OverflowMenu from "../../ui/OverflowMenu";
|
||||||
|
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||||
|
import ConfirmResetModal from "../ConfirmResetModal";
|
||||||
|
import OverviewTab from "./OverviewTab";
|
||||||
|
import SessionsTab from "./SessionsTab";
|
||||||
|
import AutomationTab from "./AutomationTab";
|
||||||
|
import ConfigTab from "./ConfigTab";
|
||||||
|
import FilesTab from "./FilesTab";
|
||||||
|
import { formatUptime } from "./format";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "sessions", label: "Sessions" },
|
||||||
|
{ id: "automation", label: "Automation" },
|
||||||
|
{ id: "config", label: "Config" },
|
||||||
|
{ id: "files", label: "Files" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project promoted from a sidebar card to a first-class main-area view.
|
||||||
|
* Everything that used to spray out of `ProjectCard` as a modal lives here.
|
||||||
|
*/
|
||||||
|
export default function ProjectHome({ projectId, active }: Props) {
|
||||||
|
const { projects, remove } = useProjects();
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const { runningSince, progress } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
runningSince: s.runningSince[projectId],
|
||||||
|
progress: s.containerProgress[projectId],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render once a minute so the uptime line stays honest.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || runningSince === undefined) return;
|
||||||
|
const timer = setInterval(() => setTick((t) => t + 1), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [active, runningSince]);
|
||||||
|
|
||||||
|
const actions = useProjectActions(
|
||||||
|
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||||
|
);
|
||||||
|
const { save, saveState } = useProjectSave(
|
||||||
|
project ?? ({ id: projectId, name: "" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex items-center justify-center ${active ? "" : "hidden"}`}>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
This project is no longer available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRunning = project.status === "running";
|
||||||
|
const isTransitioning =
|
||||||
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-[var(--text-primary)] truncate">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-xs">
|
||||||
|
<ProjectStatusIndicator status={project.status} />
|
||||||
|
{isRunning && uptime && (
|
||||||
|
<span className="text-[var(--text-secondary)]">· {uptime}</span>
|
||||||
|
)}
|
||||||
|
{isTransitioning && progress && (
|
||||||
|
<span className="text-[var(--warning)] truncate">· {progress}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{isRunning ? (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={actions.busy}
|
||||||
|
onClick={actions.openClaudeTerminal}
|
||||||
|
>
|
||||||
|
Open Claude Terminal
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={actions.busy || isTransitioning}
|
||||||
|
onClick={actions.handleStart}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isRunning && (
|
||||||
|
<>
|
||||||
|
<Button size="md" onClick={actions.openShell}>
|
||||||
|
Shell
|
||||||
|
</Button>
|
||||||
|
<Button size="md" onClick={() => setTab("files")}>
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
|
<Button size="md" disabled={actions.busy} onClick={actions.handleStop}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isTransitioning && (
|
||||||
|
<Button size="md" variant="danger" onClick={actions.handleStop}>
|
||||||
|
Force stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: actions.backingUp ? "Backing up…" : "Back up container",
|
||||||
|
onSelect: actions.handleBackup,
|
||||||
|
disabled: actions.backingUp || !project.container_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reset container…",
|
||||||
|
onSelect: () => setConfirmReset(true),
|
||||||
|
disabled: !isStopped || actions.busy,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remove project…",
|
||||||
|
onSelect: () => setConfirmRemove(true),
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div role="tablist" aria-label="Project sections" className="flex gap-1 mt-3 -mb-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`project-tab-${projectId}-${t.id}`}
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
aria-controls={`project-panel-${projectId}-${t.id}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? "text-[var(--text-primary)] border-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id={`project-panel-${projectId}-${tab}`}
|
||||||
|
aria-labelledby={`project-tab-${projectId}-${tab}`}
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{tab === "overview" && (
|
||||||
|
<OverviewTab
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
saveState={saveState}
|
||||||
|
actions={actions}
|
||||||
|
onOpenTab={setTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||||
|
{tab === "automation" && <AutomationTab project={project} />}
|
||||||
|
{tab === "config" && (
|
||||||
|
<ConfigTab project={project} save={save} saveState={saveState} />
|
||||||
|
)}
|
||||||
|
{tab === "files" && <FilesTab project={project} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmReset && (
|
||||||
|
<ConfirmResetModal
|
||||||
|
projectName={project.name}
|
||||||
|
onCancel={() => setConfirmReset(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmReset(false);
|
||||||
|
actions.handleReset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirmRemove && (
|
||||||
|
<ConfirmRemoveModal
|
||||||
|
projectName={project.name}
|
||||||
|
onCancel={() => setConfirmRemove(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setConfirmRemove(false);
|
||||||
|
try {
|
||||||
|
await remove(project.id);
|
||||||
|
} catch (e) {
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not remove “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ClaudeSession, Project } from "../../../lib/types";
|
||||||
|
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
|
||||||
|
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatAge, formatBytes } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stop/start container model buries "which conversation was I in?" in the
|
||||||
|
* config volume. This lists it and makes [Resume] one click.
|
||||||
|
*/
|
||||||
|
export default function SessionsTab({ project, actions }: Props) {
|
||||||
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!running) {
|
||||||
|
setSessions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
listClaudeSessions(project.id)
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => setSessions([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [project.id, running]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const resume = async (session: ClaudeSession) => {
|
||||||
|
try {
|
||||||
|
const command = await resumeSessionCommand(project.id, session.id);
|
||||||
|
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not resume that session",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Conversations stored on this project’s config volume. Resume opens a
|
||||||
|
terminal running the resume command.
|
||||||
|
</p>
|
||||||
|
<Button onClick={load} disabled={!running || loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to read its saved sessions.
|
||||||
|
</p>
|
||||||
|
) : sessions.length === 0 && !loading ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
No sessions recorded yet. Open a Claude terminal to start one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] text-[var(--text-primary)] truncate">
|
||||||
|
{session.name ?? session.summary ?? "(untitled session)"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] truncate font-mono">
|
||||||
|
{session.id}
|
||||||
|
{session.cwd ? ` · ${session.cwd}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||||
|
<div>{formatAge(session.last_modified) ?? "—"}</div>
|
||||||
|
<div>
|
||||||
|
{formatBytes(session.size_bytes)} · {session.message_count} msg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => resume(session)}>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user