Compare commits
62 Commits
build-df3d
...
v0.1.87
| Author | SHA1 | Date | |
|---|---|---|---|
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 | |||
| c5e28f9caa | |||
| 86176d8830 | |||
| 58a10c65e9 | |||
| d56c6e3845 | |||
| 574fca633a | |||
| e07c0e6150 | |||
| 20a07c84f2 | |||
| 625d48a6ed | |||
| 2ddc705925 | |||
| 1aced2d860 | |||
| 652e451afe | |||
| eb86aa95b7 | |||
| 3228e6cdd7 | |||
| 3344ce1cbf | |||
| d642cc64de | |||
| e3502876eb | |||
| 4f41f0d98b | |||
| c9dc232fc4 | |||
| 2d4fce935f | |||
| e739f6aaff | |||
| 550159fc63 | |||
| e3c874bc75 | |||
| 6cae0e7feb | |||
| b566446b75 | |||
| 601a2db3cf | |||
| b795e27251 | |||
| 19d4cbce27 | |||
| 946ea03956 | |||
| ba4cb4176d | |||
| 4b56610ff5 | |||
| db51abb970 | |||
| d947824436 | |||
| c2b21b794c | |||
| 40493ae284 | |||
| 2e81b52205 | |||
| 06be613e36 | |||
| da078af73f | |||
| 01ea581f8a | |||
| 552aaebf16 | |||
| c2736ace90 | |||
| 2ff270ebfe | |||
| 5a59fdb64b | |||
| 1ce5151e59 | |||
| 66ddc182c9 | |||
| 1524ec4a98 | |||
| 4721950eae | |||
| fba4b9442c | |||
| 48f0e2f64c | |||
| 7e1cc92aa4 | |||
| 854f59a95a | |||
| 265b365f0b | |||
| 03e0590631 | |||
| 82f159d2a9 | |||
| a03bdccdc7 | |||
| 82c487184a | |||
| 96f8acc40d | |||
| b77b9679b1 | |||
| 0a4f207556 | |||
| 839dd9f105 |
84
.gitea/workflows/backfill-releases.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Backfill Releases to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backfill:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Backfill all Gitea releases to GitHub
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
GITEA_API: https://repo.anhonesthost.net/api/v1
|
||||||
|
GITEA_REPO: cybercovellc/triple-c
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "==> Fetching releases from Gitea..."
|
||||||
|
RELEASES=$(curl -sf \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_API/repos/$GITEA_REPO/releases?limit=50")
|
||||||
|
|
||||||
|
echo "$RELEASES" | jq -c '.[]' | while read release; do
|
||||||
|
TAG=$(echo "$release" | jq -r '.tag_name')
|
||||||
|
NAME=$(echo "$release" | jq -r '.name')
|
||||||
|
BODY=$(echo "$release" | jq -r '.body')
|
||||||
|
IS_PRERELEASE=$(echo "$release" | jq -r '.prerelease')
|
||||||
|
IS_DRAFT=$(echo "$release" | jq -r '.draft')
|
||||||
|
|
||||||
|
EXISTS=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG" \
|
||||||
|
-o /dev/null -w "%{http_code}" || true)
|
||||||
|
|
||||||
|
if [ "$EXISTS" = "200" ]; then
|
||||||
|
echo "==> Skipping $TAG (already exists on GitHub)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Creating release $TAG..."
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$TAG\",
|
||||||
|
\"name\": \"$NAME\",
|
||||||
|
\"body\": $(echo "$BODY" | jq -Rs .),
|
||||||
|
\"draft\": $IS_DRAFT,
|
||||||
|
\"prerelease\": $IS_PRERELEASE
|
||||||
|
}")
|
||||||
|
|
||||||
|
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
|
echo "$release" | jq -c '.assets[]?' | while read asset; do
|
||||||
|
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||||
|
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||||
|
|
||||||
|
echo " ==> Downloading $ASSET_NAME..."
|
||||||
|
DOWNLOAD_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
curl -sfL -o "/tmp/$ASSET_NAME" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$DOWNLOAD_URL"
|
||||||
|
|
||||||
|
echo " ==> Uploading $ASSET_NAME to GitHub..."
|
||||||
|
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@/tmp/$ASSET_NAME" \
|
||||||
|
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||||
|
|
||||||
|
echo " Uploaded: $ASSET_NAME"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Done: $TAG"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Backfill complete."
|
||||||
@@ -19,9 +19,55 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install Node.js 22
|
||||||
|
run: |
|
||||||
|
NEED_INSTALL=false
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
OLD_NODE_DIR=$(dirname "$(which node)")
|
||||||
|
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
||||||
|
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
||||||
|
hash -r
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Node.js not found, installing 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
hash -r
|
||||||
|
fi
|
||||||
|
echo "Node.js at: $(which node)"
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
VERSION="0.1.${COMMIT_COUNT}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${VERSION}"
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -43,29 +89,35 @@ jobs:
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
- name: Rust cache
|
echo "Rust already installed: $(rustc --version)"
|
||||||
uses: swatinem/rust-cache@v2
|
rustup update stable
|
||||||
with:
|
rustup default stable
|
||||||
workspaces: "./app/src-tauri -> target"
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
- name: Install Node.js
|
fi
|
||||||
uses: actions/setup-node@v4
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
with:
|
rustc --version
|
||||||
node-version: "22"
|
cargo --version
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npm ci
|
run: |
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
- name: Install Tauri CLI
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npx tauri --version || npm install @tauri-apps/cli
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: npx tauri build
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
@@ -80,12 +132,122 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="build-$(echo ${{ gitea.sha }} | cut -c1-7)"
|
TAG="v${{ steps.version.outputs.VERSION }}"
|
||||||
# Create release
|
# Create release
|
||||||
curl -s -X POST \
|
curl -s -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\": \"Linux Build ${TAG}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
|
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
|
# Upload each artifact
|
||||||
|
for file in artifacts/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo "Uploading ${filename}..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||||
|
done
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js 22
|
||||||
|
run: |
|
||||||
|
NEED_INSTALL=false
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Node.js not found, installing 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
brew install node@22
|
||||||
|
brew link --overwrite node@22
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
VERSION="0.1.${COMMIT_COUNT}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${VERSION}"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
echo "Rust already installed: $(rustc --version)"
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Install Tauri CLI
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app (universal)
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.version.outputs.VERSION }}-mac"
|
||||||
|
# Create release
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
"${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]*')
|
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
@@ -109,6 +271,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
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.1.%COMMIT_COUNT%"
|
||||||
|
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
||||||
|
echo Computed version: %VERSION%
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$version = "${{ steps.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/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
|
- name: Install Rust stable
|
||||||
run: |
|
run: |
|
||||||
@@ -155,8 +336,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
if exist node_modules rmdir /s /q node_modules
|
if exist node_modules rmdir /s /q node_modules
|
||||||
if exist package-lock.json del package-lock.json
|
npm ci
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -186,9 +366,9 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
set "TAG=build-win-%COMMIT_SHA:~0,7%"
|
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
||||||
echo Creating release %TAG%...
|
echo Creating release %TAG%...
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Windows Build %TAG%\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
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
|
||||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
||||||
:found
|
:found
|
||||||
echo Release ID: %RELEASE_ID%
|
echo Release ID: %RELEASE_ID%
|
||||||
@@ -196,3 +376,96 @@ jobs:
|
|||||||
echo Uploading %%~nxf...
|
echo Uploading %%~nxf...
|
||||||
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"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sync-to-github:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, build-macos, build-windows]
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts from Gitea releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VERSION: ${{ needs.build-linux.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# Download assets from all 3 platform releases
|
||||||
|
for TAG_SUFFIX in "" "-mac" "-win"; do
|
||||||
|
TAG="v${VERSION}${TAG_SUFFIX}"
|
||||||
|
echo "==> Fetching assets for release ${TAG}..."
|
||||||
|
|
||||||
|
RELEASE_JSON=$(curl -sf \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||||
|
|
||||||
|
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
|
||||||
|
[ -z "$NAME" ] && continue
|
||||||
|
echo " Downloading ${NAME}..."
|
||||||
|
curl -sfL \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-o "artifacts/${NAME}" \
|
||||||
|
"$URL"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> All downloaded artifacts:"
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Create GitHub release and upload artifacts
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.build-linux.outputs.version }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
echo "==> Creating unified release ${TAG} on GitHub..."
|
||||||
|
|
||||||
|
# Delete existing release if present (idempotent re-runs)
|
||||||
|
EXISTING=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${TAG}\",
|
||||||
|
\"name\": \"Triple-C ${TAG}\",
|
||||||
|
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
echo "==> Upload URL: ${UPLOAD_URL}"
|
||||||
|
|
||||||
|
for file in artifacts/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
FILENAME=$(basename "$file")
|
||||||
|
MIME="application/octet-stream"
|
||||||
|
echo "==> Uploading ${FILENAME}..."
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: ${MIME}" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> GitHub release sync complete."
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./container
|
context: ./container
|
||||||
file: ./container/Dockerfile
|
file: ./container/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: ${{ gitea.event_name == 'push' }}
|
push: ${{ gitea.event_name == 'push' }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|||||||
59
.gitea/workflows/sync-release.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Sync Release to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Mirror release to GitHub
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||||
|
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||||
|
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||||
|
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||||
|
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$RELEASE_TAG\",
|
||||||
|
\"name\": \"$RELEASE_NAME\",
|
||||||
|
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||||
|
\"draft\": $IS_DRAFT,
|
||||||
|
\"prerelease\": $IS_PRERELEASE
|
||||||
|
}")
|
||||||
|
|
||||||
|
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||||
|
|
||||||
|
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||||
|
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||||
|
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
|
||||||
|
echo "==> Downloading asset: $ASSET_NAME"
|
||||||
|
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||||
|
|
||||||
|
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||||
|
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $GH_PAT" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@/tmp/$ASSET_NAME" \
|
||||||
|
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||||
|
|
||||||
|
echo " Uploaded: $ASSET_NAME"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Release sync complete."
|
||||||
53
BUILDING.md
@@ -1,6 +1,6 @@
|
|||||||
# Building Triple-C
|
# Building Triple-C
|
||||||
|
|
||||||
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux and Windows.
|
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux, macOS, and Windows.
|
||||||
|
|
||||||
## Prerequisites (All Platforms)
|
## Prerequisites (All Platforms)
|
||||||
|
|
||||||
@@ -79,6 +79,57 @@ Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
|||||||
| Debian pkg | `deb/*.deb` |
|
| Debian pkg | `deb/*.deb` |
|
||||||
| RPM pkg | `rpm/*.rpm` |
|
| RPM pkg | `rpm/*.rpm` |
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
### 1. Install prerequisites
|
||||||
|
|
||||||
|
- **Xcode Command Line Tools** — required for the C/C++ toolchain and system headers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
No additional system libraries are needed — macOS includes WebKit natively.
|
||||||
|
|
||||||
|
### 2. Install Rust targets (universal binary)
|
||||||
|
|
||||||
|
To build a universal binary that runs on both Apple Silicon and Intel Macs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install frontend dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build
|
||||||
|
|
||||||
|
For a universal binary (recommended for distribution):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
For the current architecture only (faster, for local development):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build artifacts are located in `app/src-tauri/target/universal-apple-darwin/release/bundle/` (or `target/release/bundle/` for single-arch builds):
|
||||||
|
|
||||||
|
| Format | Path |
|
||||||
|
|--------|------|
|
||||||
|
| DMG | `dmg/*.dmg` |
|
||||||
|
| macOS App | `macos/*.app` |
|
||||||
|
| macOS App (compressed) | `macos/*.app.tar.gz` |
|
||||||
|
|
||||||
|
> **Note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it. Right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
### 1. Install prerequisites
|
### 1. Install prerequisites
|
||||||
|
|||||||
115
CLAUDE.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Triple-C (Claude-Code-Container) is a Tauri v2 desktop application that sandboxes Claude Code inside Docker containers. It has two main parts: a React/TypeScript frontend, a Rust backend, and a Docker container image definition.
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
All frontend/tauri commands run from the `app/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm ci # Install dependencies (required first time)
|
||||||
|
npx tauri dev # Launch app in dev mode with hot reload (Vite on port 1420)
|
||||||
|
npx tauri build # Production build (outputs to src-tauri/target/release/bundle/)
|
||||||
|
npm run build # Frontend-only build (tsc + vite)
|
||||||
|
npm run test # Run Vitest once
|
||||||
|
npm run test:watch # Run Vitest in watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust backend is compiled automatically by `tauri dev`/`tauri build`. To check Rust independently:
|
||||||
|
```bash
|
||||||
|
cd app/src-tauri
|
||||||
|
cargo check # Type-check without full build
|
||||||
|
cargo build # Build Rust backend only
|
||||||
|
```
|
||||||
|
|
||||||
|
Container image:
|
||||||
|
```bash
|
||||||
|
docker build -t triple-c-sandbox ./container
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux Build Dependencies (Ubuntu/Debian)
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf libssl-dev pkg-config build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two-Process Model (Tauri IPC)
|
||||||
|
|
||||||
|
- **React frontend** (`app/src/`) renders UI in the OS webview
|
||||||
|
- **Rust backend** (`app/src-tauri/src/`) handles Docker API, credential storage, and terminal I/O
|
||||||
|
- Communication uses two patterns:
|
||||||
|
- `invoke()` — request/response for discrete operations (CRUD, start/stop containers)
|
||||||
|
- `emit()`/`listen()` — event streaming for continuous data (terminal I/O)
|
||||||
|
|
||||||
|
### Terminal I/O Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User keystroke → xterm.js onData() → invoke("terminal_input") → mpsc channel → docker exec stdin
|
||||||
|
docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → listen() → xterm.js write()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Structure (`app/src/`)
|
||||||
|
|
||||||
|
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
|
||||||
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||||
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||||
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
|
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
||||||
|
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
||||||
|
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
|
||||||
|
|
||||||
|
### Backend Structure (`app/src-tauri/src/`)
|
||||||
|
|
||||||
|
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
|
||||||
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
|
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||||
|
- `image.rs` — Image build/pull with progress streaming
|
||||||
|
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||||
|
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||||
|
|
||||||
|
### Container (`container/`)
|
||||||
|
|
||||||
|
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
|
||||||
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity`
|
||||||
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Per-project, independently configured:
|
||||||
|
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||||
|
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`). No separate tailwind config file.
|
||||||
|
- All colors use CSS custom properties in `index.css` `:root` (e.g., `--bg-primary`, `--text-secondary`, `--accent`)
|
||||||
|
- `color-scheme: dark` is set on `:root` for native dark-mode controls
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
||||||
|
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
|
||||||
|
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
|
||||||
|
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
|
||||||
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npx vitest run src/path/to/test.test.ts
|
||||||
|
```
|
||||||
397
HOW-TO-USE.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# How to Use Triple-C
|
||||||
|
|
||||||
|
Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code inside isolated Docker containers. Each project gets its own sandboxed environment with bind-mounted directories, so Claude only has access to the files you explicitly provide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Triple-C requires a running Docker daemon. Install one of the following:
|
||||||
|
|
||||||
|
| Platform | Option | Link |
|
||||||
|
|----------|--------|------|
|
||||||
|
| **Windows** | Docker Desktop | https://docs.docker.com/desktop/install/windows-install/ |
|
||||||
|
| **macOS** | Docker Desktop | https://docs.docker.com/desktop/install/mac-install/ |
|
||||||
|
| **Linux** | Docker Engine | https://docs.docker.com/engine/install/ |
|
||||||
|
| **Linux** | Docker Desktop (alternative) | https://docs.docker.com/desktop/install/linux/ |
|
||||||
|
|
||||||
|
After installation, verify Docker is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Windows note:** Docker Desktop must be running before launching Triple-C. The app communicates with Docker through the named pipe at `//./pipe/docker_engine`.
|
||||||
|
|
||||||
|
> **Linux note:** Your user must have permission to access the Docker socket (`/var/run/docker.sock`). Either add your user to the `docker` group (`sudo usermod -aG docker $USER`, then log out and back in) or run Docker in rootless mode.
|
||||||
|
|
||||||
|
### Claude Code Account
|
||||||
|
|
||||||
|
You need access to Claude Code through one of:
|
||||||
|
|
||||||
|
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||||
|
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Launch
|
||||||
|
|
||||||
|
### 1. Get the Container Image
|
||||||
|
|
||||||
|
When you first open Triple-C, go to the **Settings** tab in the sidebar. Under **Docker**, you'll see:
|
||||||
|
|
||||||
|
- **Docker Status** — Should show "Connected" (green). If it shows "Not Available", make sure Docker is running.
|
||||||
|
- **Image Status** — Will show "Not Found" on first launch.
|
||||||
|
|
||||||
|
Choose an **Image Source**:
|
||||||
|
|
||||||
|
| Source | Description | When to Use |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users |
|
||||||
|
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
|
||||||
|
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
|
||||||
|
|
||||||
|
Click **Pull Image** (for Registry/Custom) or **Build Image** (for Local Build). A progress log will stream below the button. When complete, the status changes to "Ready" (green).
|
||||||
|
|
||||||
|
### 2. Create Your First Project
|
||||||
|
|
||||||
|
Switch to the **Projects** tab in the sidebar and click the **+** button.
|
||||||
|
|
||||||
|
1. **Project Name** — Give it a meaningful name (e.g., "my-web-app").
|
||||||
|
2. **Folders** — Click **Browse** to select a directory on your host machine. This directory will be mounted into the container at `/workspace/<folder-name>`. You can add multiple folders with the **+** button at the bottom of the folder list.
|
||||||
|
3. Click **Add Project**.
|
||||||
|
|
||||||
|
### 3. Start the Container
|
||||||
|
|
||||||
|
Select your project in the sidebar and click **Start**. The status dot changes from gray (stopped) to orange (starting) to green (running).
|
||||||
|
|
||||||
|
### 4. Open a Terminal
|
||||||
|
|
||||||
|
Click the **Terminal** button (highlighted in accent color) to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
||||||
|
|
||||||
|
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
|
||||||
|
|
||||||
|
### 5. Authenticate
|
||||||
|
|
||||||
|
**Anthropic (OAuth) — default:**
|
||||||
|
|
||||||
|
1. Type `claude login` or `/login` in the terminal.
|
||||||
|
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||||
|
3. Complete the login in your browser. The token is saved and persists across container stops and resets.
|
||||||
|
|
||||||
|
**AWS Bedrock:**
|
||||||
|
|
||||||
|
1. Stop the container first (settings can only be changed while stopped).
|
||||||
|
2. In the project card, switch the auth mode to **Bedrock**.
|
||||||
|
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
|
||||||
|
4. Start the container again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Interface
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar [ Terminal Tabs ] Docker ● Image ●│
|
||||||
|
├────────────┬────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ │
|
||||||
|
│ │ Terminal View │
|
||||||
|
│ Projects │ (xterm.js) │
|
||||||
|
│ Settings │ │
|
||||||
|
│ │ │
|
||||||
|
├────────────┴────────────────────────────────────────┤
|
||||||
|
│ StatusBar X projects · X running · X terminals │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **TopBar** — Terminal tabs for switching between sessions. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
||||||
|
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
|
||||||
|
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
|
||||||
|
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Management
|
||||||
|
|
||||||
|
### Project Status
|
||||||
|
|
||||||
|
Each project shows a colored status dot:
|
||||||
|
|
||||||
|
| Color | Status | Meaning |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Gray | Stopped | Container is not running |
|
||||||
|
| Orange | Starting / Stopping | Container is transitioning |
|
||||||
|
| Green | Running | Container is active, ready for terminals |
|
||||||
|
| Red | Error | Something went wrong (check error message) |
|
||||||
|
|
||||||
|
### Project Actions
|
||||||
|
|
||||||
|
Select a project in the sidebar to see its action buttons:
|
||||||
|
|
||||||
|
| Button | When Available | What It Does |
|
||||||
|
|--------|---------------|--------------|
|
||||||
|
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||||
|
| **Stop** | Running | Stops the container but preserves its state |
|
||||||
|
| **Terminal** | Running | Opens a new terminal session in this container |
|
||||||
|
| **Reset** | Stopped | Destroys and recreates the container from scratch |
|
||||||
|
| **Config** | Always | Toggles the configuration panel |
|
||||||
|
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
|
||||||
|
|
||||||
|
**Reset** removes the container and creates a fresh one. However, your Claude Code configuration (including OAuth tokens from `claude login`) is stored in a separate Docker volume and survives resets.
|
||||||
|
|
||||||
|
Only **Remove** deletes everything, including the config volume and any stored credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Configuration
|
||||||
|
|
||||||
|
Click **Config** on a selected project to expand the configuration panel. Settings can only be changed when the container is **stopped** (an orange warning box appears if the container is running).
|
||||||
|
|
||||||
|
### Mounted Folders
|
||||||
|
|
||||||
|
Each project mounts one or more host directories into the container. The mount appears at `/workspace/<mount-name>` inside the container.
|
||||||
|
|
||||||
|
- Click **Browse** ("...") to change the host path
|
||||||
|
- Edit the mount name to control where it appears inside `/workspace/`
|
||||||
|
- Click **+** to add more folders, or **x** to remove one
|
||||||
|
- Mount names must be unique and use only letters, numbers, dashes, underscores, and dots
|
||||||
|
|
||||||
|
### SSH Keys
|
||||||
|
|
||||||
|
Specify the path to your SSH key directory (typically `~/.ssh`). Keys are mounted read-only and copied into the container with correct permissions. This enables `git clone` via SSH inside the container.
|
||||||
|
|
||||||
|
### Git Configuration
|
||||||
|
|
||||||
|
- **Git Name / Email** — Sets `git config user.name` and `user.email` inside the container.
|
||||||
|
- **Git HTTPS Token** — A personal access token (e.g., from GitHub) for HTTPS git operations. Stored securely in your OS keychain — never written to disk in plaintext.
|
||||||
|
|
||||||
|
### Allow Container Spawning
|
||||||
|
|
||||||
|
When enabled, the host Docker socket is mounted into the container so Claude Code can create sibling containers (e.g., for running databases, test environments). This is **off by default** for security.
|
||||||
|
|
||||||
|
> Toggling this requires stopping and restarting the container to take effect.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
|
||||||
|
|
||||||
|
> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `CLAUDE_`, `TRIPLE_C_`) are filtered out to prevent conflicts with internal variables.
|
||||||
|
|
||||||
|
### Port Mappings
|
||||||
|
|
||||||
|
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
|
||||||
|
|
||||||
|
Each mapping specifies:
|
||||||
|
- **Host Port** — The port on your machine (1–65535)
|
||||||
|
- **Container Port** — The port inside the container (1–65535)
|
||||||
|
- **Protocol** — TCP (default) or UDP
|
||||||
|
|
||||||
|
### Claude Instructions
|
||||||
|
|
||||||
|
Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AWS Bedrock Configuration
|
||||||
|
|
||||||
|
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
|
||||||
|
|
||||||
|
### Authentication Methods
|
||||||
|
|
||||||
|
| Method | Fields | Use Case |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| **Keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup |
|
||||||
|
| **Profile** | AWS Profile name | Uses `~/.aws/config` and `~/.aws/credentials` on the host |
|
||||||
|
| **Token** | Bearer Token | Temporary bearer token authentication |
|
||||||
|
|
||||||
|
### Additional Bedrock Settings
|
||||||
|
|
||||||
|
- **AWS Region** — Required. The region where your Bedrock models are deployed (e.g., `us-east-1`).
|
||||||
|
- **Model ID** — Optional. Override the default Claude model (e.g., `anthropic.claude-sonnet-4-20250514-v1:0`).
|
||||||
|
|
||||||
|
### Global AWS Defaults
|
||||||
|
|
||||||
|
In **Settings > AWS Configuration**, you can set defaults that apply to all Bedrock projects:
|
||||||
|
|
||||||
|
- **AWS Config Path** — Path to your `~/.aws` directory. Click **Detect** to auto-find it.
|
||||||
|
- **Default Profile** — Select from profiles found in your AWS config.
|
||||||
|
- **Default Region** — Fallback region for projects that don't specify one.
|
||||||
|
|
||||||
|
Per-project settings always override these global defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Access global settings via the **Settings** tab in the sidebar.
|
||||||
|
|
||||||
|
### Docker Settings
|
||||||
|
|
||||||
|
- **Docker Status** — Connection status to the Docker daemon.
|
||||||
|
- **Image Source** — Where to get the sandbox container image (Registry, Local Build, or Custom).
|
||||||
|
- **Pull / Build Image** — Download or build the image. Progress streams in real time.
|
||||||
|
- **Refresh** — Re-check Docker and image status.
|
||||||
|
|
||||||
|
### Container Timezone
|
||||||
|
|
||||||
|
Set the timezone for all containers (IANA format, e.g., `America/New_York`, `Europe/London`, `UTC`). Auto-detected from your host on first launch. This affects scheduled task timing inside containers.
|
||||||
|
|
||||||
|
### Global Claude Instructions
|
||||||
|
|
||||||
|
Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in every container, before any per-project instructions.
|
||||||
|
|
||||||
|
### Global Environment Variables
|
||||||
|
|
||||||
|
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
- **Current Version** — The installed version of Triple-C.
|
||||||
|
- **Auto-check** — Toggle automatic update checks (every 24 hours).
|
||||||
|
- **Check now** — Manually check for updates.
|
||||||
|
|
||||||
|
When an update is available, a pulsing **Update** button appears in the top bar. Click it to see release notes and download links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal Features
|
||||||
|
|
||||||
|
### Multiple Sessions
|
||||||
|
|
||||||
|
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it.
|
||||||
|
|
||||||
|
### URL Detection
|
||||||
|
|
||||||
|
When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detects it and shows a toast notification at the top of the terminal with an **Open** button. Clicking it opens the URL in your default browser. The toast auto-dismisses after 30 seconds.
|
||||||
|
|
||||||
|
Shorter URLs in terminal output are also clickable directly.
|
||||||
|
|
||||||
|
### Image Paste
|
||||||
|
|
||||||
|
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container and the file path is injected into the terminal input so Claude Code can reference it.
|
||||||
|
|
||||||
|
### Terminal Rendering
|
||||||
|
|
||||||
|
The terminal uses WebGL for hardware-accelerated rendering of the active tab. Inactive tabs fall back to canvas rendering to conserve GPU resources. The terminal automatically resizes when you resize the window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduled Tasks (Inside the Container)
|
||||||
|
|
||||||
|
Once inside a running container terminal, you can set up recurring or one-time tasks using `triple-c-scheduler`. Tasks run as separate Claude Code sessions.
|
||||||
|
|
||||||
|
### Create a Recurring Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "daily-review" --schedule "0 9 * * *" --prompt "Review open issues and summarize"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a One-Time Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "migrate-db" --at "2026-03-05 14:00" --prompt "Run database migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
One-time tasks automatically remove themselves after execution.
|
||||||
|
|
||||||
|
### Manage Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler list # List all tasks
|
||||||
|
triple-c-scheduler enable --id abc123 # Enable a task
|
||||||
|
triple-c-scheduler disable --id abc123 # Disable a task
|
||||||
|
triple-c-scheduler remove --id abc123 # Delete a task
|
||||||
|
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
||||||
|
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||||
|
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||||
|
triple-c-scheduler notifications # View completion notifications
|
||||||
|
triple-c-scheduler notifications --clear # Clear notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Schedule Format
|
||||||
|
|
||||||
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
|
|
||||||
|
| Example | Meaning |
|
||||||
|
|---------|---------|
|
||||||
|
| `*/30 * * * *` | Every 30 minutes |
|
||||||
|
| `0 9 * * 1-5` | 9:00 AM on weekdays |
|
||||||
|
| `0 */2 * * *` | Every 2 hours |
|
||||||
|
| `0 0 1 * *` | Midnight on the 1st of each month |
|
||||||
|
|
||||||
|
### Working Directory
|
||||||
|
|
||||||
|
By default, tasks run in `/workspace`. Use `--working-dir` to specify a different directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run tests" --working-dir /workspace/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Inside the Container
|
||||||
|
|
||||||
|
The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Claude Code | Latest | AI coding assistant (the tool being sandboxed) |
|
||||||
|
| Node.js | 22 LTS | JavaScript/TypeScript development |
|
||||||
|
| pnpm | Latest | Fast Node.js package manager |
|
||||||
|
| Python | 3.12 | Python development |
|
||||||
|
| uv | Latest | Fast Python package manager |
|
||||||
|
| ruff | Latest | Python linter/formatter |
|
||||||
|
| Rust | Stable | Rust development (via rustup) |
|
||||||
|
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||||
|
| git | Latest | Version control |
|
||||||
|
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||||
|
| AWS CLI | v2 | AWS services and Bedrock |
|
||||||
|
| ripgrep | Latest | Fast code search |
|
||||||
|
| build-essential | — | C/C++ compiler toolchain |
|
||||||
|
| openssh-client | — | SSH for git and remote access |
|
||||||
|
|
||||||
|
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Docker is "Not Available"
|
||||||
|
|
||||||
|
- **Is Docker running?** Start Docker Desktop or the Docker daemon (`sudo systemctl start docker`).
|
||||||
|
- **Permissions?** On Linux, ensure your user is in the `docker` group or the socket is accessible.
|
||||||
|
- **Custom socket path?** If your Docker socket is not at the default location, set it in Settings. The app expects `/var/run/docker.sock` on Linux/macOS or `//./pipe/docker_engine` on Windows.
|
||||||
|
|
||||||
|
### Image is "Not Found"
|
||||||
|
|
||||||
|
- Click **Pull Image** or **Build Image** in Settings > Docker.
|
||||||
|
- If pulling fails, check your network connection and whether you can reach the registry.
|
||||||
|
- Try switching to **Local Build** as an alternative.
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
- Check that the Docker image is "Ready" in Settings.
|
||||||
|
- Verify that the mounted folder paths exist on your host.
|
||||||
|
- Look at the error message displayed in red below the project card.
|
||||||
|
|
||||||
|
### OAuth Login URL Not Opening
|
||||||
|
|
||||||
|
- Triple-C detects long URLs printed by `claude login` and shows a toast with an **Open** button.
|
||||||
|
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
||||||
|
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
||||||
|
|
||||||
|
### File Permission Issues
|
||||||
|
|
||||||
|
- Triple-C automatically remaps the container user's UID/GID to match your host user, so files created inside the container should have the correct ownership on your host.
|
||||||
|
- If you see permission errors, try resetting the container (stop, then click **Reset**).
|
||||||
|
|
||||||
|
### Settings Won't Save
|
||||||
|
|
||||||
|
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
|
||||||
|
- Some changes (like toggling Docker access or changing mounted folders) trigger an automatic container recreation on the next start.
|
||||||
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||||
|
- **Backend**: Rust (Tauri v2 framework)
|
||||||
|
- **Terminal**: xterm.js with WebGL rendering
|
||||||
|
- **Docker API**: bollard (pure Rust Docker client)
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar (terminal tabs + Docker/Image status) │
|
||||||
|
├────────────┬────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ Main Content (terminal views) │
|
||||||
|
│ (25% w, │ │
|
||||||
|
│ responsive│ │
|
||||||
|
│ min/max) │ │
|
||||||
|
├────────────┴────────────────────────────────────────┤
|
||||||
|
│ StatusBar (project/terminal counts) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
1. **Create**: New container created with bind mounts, env vars, and labels
|
||||||
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
|
||||||
|
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
||||||
|
4. **Stop**: Container halted (filesystem persists in named volume)
|
||||||
|
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
||||||
|
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
| Target in Container | Source | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace` | Project directory | Bind | Read-write |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
||||||
|
| `/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 |
|
||||||
|
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||||
|
|
||||||
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
|
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Docker Socket Path
|
||||||
|
|
||||||
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
|
||||||
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
|
||||||
|
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
||||||
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||||
|
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
||||||
|
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
||||||
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
||||||
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
||||||
|
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
||||||
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
||||||
|
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
||||||
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
||||||
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
||||||
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
||||||
|
|
||||||
|
## CSS / Styling Notes
|
||||||
|
|
||||||
|
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
|
||||||
|
- All colors use CSS custom properties defined in `index.css` `:root`
|
||||||
|
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
|
||||||
|
|
||||||
|
## Container Image
|
||||||
|
|
||||||
|
**Base**: Ubuntu 24.04
|
||||||
|
|
||||||
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||||
|
|
||||||
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
60
TODO.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# TODO / Future Improvements
|
||||||
|
|
||||||
|
## In-App Auto-Update via `tauri-plugin-updater`
|
||||||
|
|
||||||
|
**Priority:** High
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
Currently the app detects available updates via the Gitea API (`check_for_updates` command) but cannot apply them. Users must manually download and install the new version. On macOS and Linux this is a poor experience compared to Windows (where NSIS handles upgrades cleanly).
|
||||||
|
|
||||||
|
### Recommended approach: `tauri-plugin-updater`
|
||||||
|
|
||||||
|
Full in-app auto-update: detects, downloads, verifies, and applies updates seamlessly on all platforms. The user clicks "Update" and the app restarts with the new version.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. **Generate a Tauri update signing key pair** (this is Tauri's own Ed25519 key, not OS code signing):
|
||||||
|
```bash
|
||||||
|
npx @tauri-apps/cli signer generate -w ~/.tauri/triple-c.key
|
||||||
|
```
|
||||||
|
Set `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` in CI.
|
||||||
|
|
||||||
|
2. **Add `tauri-plugin-updater`** to Rust and JS dependencies.
|
||||||
|
|
||||||
|
3. **Create an update endpoint** that returns Tauri's expected JSON format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "v0.1.100",
|
||||||
|
"notes": "Changelog here",
|
||||||
|
"pub_date": "2026-03-01T00:00:00Z",
|
||||||
|
"platforms": {
|
||||||
|
"darwin-x86_64": { "signature": "...", "url": "https://..." },
|
||||||
|
"darwin-aarch64": { "signature": "...", "url": "https://..." },
|
||||||
|
"linux-x86_64": { "signature": "...", "url": "https://..." },
|
||||||
|
"windows-x86_64": { "signature": "...", "url": "https://..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This could be a static JSON file uploaded alongside release assets, or a small API that reads from Gitea releases and reformats.
|
||||||
|
|
||||||
|
4. **Configure the updater** in `tauri.conf.json`:
|
||||||
|
```json
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": ["https://repo.anhonesthost.net/...update-endpoint..."],
|
||||||
|
"pubkey": "<public key from step 1>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add frontend UI** for the update prompt (replace or enhance the existing update check flow).
|
||||||
|
|
||||||
|
6. **Update CI pipeline** to:
|
||||||
|
- Sign bundles with the Tauri key during build
|
||||||
|
- Upload `.sig` files alongside installers
|
||||||
|
- Generate/upload the update endpoint JSON
|
||||||
|
|
||||||
|
### References
|
||||||
|
- https://v2.tauri.app/plugin/updater/
|
||||||
|
- Existing update check code: `app/src-tauri/src/commands/update_commands.rs`
|
||||||
|
- Existing models: `app/src-tauri/src/models/update_info.rs`
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Triple-C (Claude-Code-Container)
|
|
||||||
|
|
||||||
Triple C is a container intended to limit what files Claude Code has access to, so when you run with `--dangerously-skip-permissions` Claude only has access to files/projects you provide to it.
|
|
||||||
1185
app/package-lock.json
generated
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -25,13 +27,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.7",
|
"typescript": "^5.7",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/public/audio-capture-processor.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
||||||
|
process(inputs, outputs, parameters) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (input && input.length > 0 && input[0].length > 0) {
|
||||||
|
const samples = input[0]; // Float32Array, mono channel
|
||||||
|
const int16 = new Int16Array(samples.length);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
this.port.postMessage(int16.buffer, [int16.buffer]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
|
||||||
489
app/src-tauri/Cargo.lock
generated
@@ -41,56 +41,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-query"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -404,6 +354,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -523,6 +479,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -537,12 +499,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -932,29 +888,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1018,6 +951,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fern"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1333,8 +1276,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1344,9 +1289,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1649,6 +1596,23 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1718,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,6 +1799,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1902,12 +1879,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1937,30 +1908,6 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2153,6 +2100,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2232,6 +2185,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.7.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -2247,7 +2210,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -2550,12 +2513,6 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2839,6 +2796,19 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -2853,21 +2823,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2976,6 +2931,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -2985,6 +2949,61 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -3025,6 +3044,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3045,6 +3074,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3063,6 +3102,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3165,6 +3213,44 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3223,6 +3309,26 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3245,6 +3351,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3709,6 +3850,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3860,6 +4007,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -3873,7 +4021,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3927,7 +4075,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4258,6 +4406,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.49.0"
|
||||||
@@ -4286,6 +4449,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4487,7 +4660,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -4500,19 +4673,21 @@ dependencies = [
|
|||||||
"bollard",
|
"bollard",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"fern",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"iana-time-zone",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tar",
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -4605,6 +4780,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4642,12 +4823,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8parse"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.21.0"
|
version = "1.21.0"
|
||||||
@@ -4857,6 +5032,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -4901,6 +5086,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5131,6 +5325,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ name = "triple-c"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
@@ -24,11 +24,13 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "2"
|
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
iana-time-zone = "0.1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 91 KiB |
38
app/src-tauri/src/commands/mcp_commands.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -1,10 +1,71 @@
|
|||||||
use tauri::State;
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, AuthMode, Project, ProjectStatus};
|
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"container-progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"message": message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract secret fields from a project and store them in the OS keychain.
|
||||||
|
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||||
|
if let Some(ref token) = project.git_token {
|
||||||
|
secure::store_project_secret(&project.id, "git-token", token)?;
|
||||||
|
}
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
if let Some(ref v) = bedrock.aws_access_key_id {
|
||||||
|
secure::store_project_secret(&project.id, "aws-access-key-id", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_secret_access_key {
|
||||||
|
secure::store_project_secret(&project.id, "aws-secret-access-key", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_session_token {
|
||||||
|
secure::store_project_secret(&project.id, "aws-session-token", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_bearer_token {
|
||||||
|
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate secret fields on a project struct from the OS keychain.
|
||||||
|
fn load_secrets_for_project(project: &mut Project) {
|
||||||
|
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(ref mut bedrock) = project.bedrock_config {
|
||||||
|
bedrock.aws_access_key_id = secure::get_project_secret(&project.id, "aws-access-key-id")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_secret_access_key = secure::get_project_secret(&project.id, "aws-secret-access-key")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_session_token = secure::get_project_secret(&project.id, "aws-session-token")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
||||||
|
.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())
|
||||||
@@ -13,10 +74,27 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_project(
|
pub async fn add_project(
|
||||||
name: String,
|
name: String,
|
||||||
path: String,
|
paths: Vec<ProjectPath>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let project = Project::new(name, path);
|
// Validate paths
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("At least one folder path is required.".to_string());
|
||||||
|
}
|
||||||
|
let mut seen_names = std::collections::HashSet::new();
|
||||||
|
for p in &paths {
|
||||||
|
if p.mount_name.is_empty() {
|
||||||
|
return Err("Mount name cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
||||||
|
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
||||||
|
}
|
||||||
|
if !seen_names.insert(p.mount_name.clone()) {
|
||||||
|
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let project = Project::new(name, paths);
|
||||||
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.add(project)
|
state.projects_store.add(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,15 +104,37 @@ pub async fn remove_project(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists
|
||||||
if let Some(project) = state.projects_store.get(&project_id) {
|
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
let _ = docker::stop_container(container_id).await;
|
let _ = docker::stop_container(container_id).await;
|
||||||
let _ = docker::remove_container(container_id).await;
|
let _ = docker::remove_container(container_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove MCP containers and network
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||||
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
if let Err(e) = docker::remove_project_volumes(project).await {
|
||||||
|
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close any exec sessions
|
// Clean up keychain secrets for this project
|
||||||
state.exec_manager.close_all_sessions().await;
|
if let Err(e) = secure::delete_project_secrets(&project_id) {
|
||||||
|
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
state.projects_store.remove(&project_id)
|
state.projects_store.remove(&project_id)
|
||||||
}
|
}
|
||||||
@@ -44,12 +144,14 @@ pub async fn update_project(
|
|||||||
project: Project,
|
project: Project,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.update(project)
|
state.projects_store.update(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_project_container(
|
pub async fn start_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let mut project = state
|
let mut project = state
|
||||||
@@ -57,93 +159,150 @@ pub async fn start_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))?;
|
||||||
|
|
||||||
|
// Populate secret fields from the OS keychain so they are available
|
||||||
|
// in memory when building environment variables for the container.
|
||||||
|
load_secrets_for_project(&mut project);
|
||||||
|
|
||||||
// Load settings for image resolution and global AWS
|
// Load settings for image resolution and global AWS
|
||||||
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);
|
||||||
|
|
||||||
// Get API key only if auth mode requires it
|
// Resolve enabled MCP servers for this project
|
||||||
let api_key = match project.auth_mode {
|
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
||||||
AuthMode::ApiKey => {
|
|
||||||
let key = secure::get_api_key()?
|
// Validate auth mode requirements
|
||||||
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
Some(key)
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
|
// Region can come from per-project or global
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
AuthMode::Login => {
|
}
|
||||||
None
|
|
||||||
}
|
|
||||||
AuthMode::Bedrock => {
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
|
||||||
// Region can come from per-project or global
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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)?;
|
||||||
|
|
||||||
// Ensure image exists
|
// Wrap container operations so that any failure resets status to Stopped.
|
||||||
if !docker::image_exists(&image_name).await? {
|
let result: Result<String, String> = async {
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
// Ensure image exists
|
||||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||||
}
|
if !docker::image_exists(&image_name).await? {
|
||||||
|
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||||
|
}
|
||||||
|
|
||||||
// Determine docker socket path
|
// Determine docker socket path
|
||||||
let docker_socket = settings.docker_socket_path
|
let docker_socket = settings.docker_socket_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| default_docker_socket());
|
.unwrap_or_else(|| default_docker_socket());
|
||||||
|
|
||||||
// AWS config path from global settings
|
// AWS config path from global settings
|
||||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||||
|
|
||||||
// Check for existing container
|
// Set up Docker network and MCP containers if needed
|
||||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
let network_name = if !docker_mcp.is_empty() {
|
||||||
// Compare the running container's configuration (mounts, env vars)
|
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
||||||
// against the current project settings. If anything changed (SSH key
|
let net = docker::ensure_project_network(&project.id).await?;
|
||||||
// path, git config, docker socket, etc.) we recreate the container.
|
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
||||||
// Safe to recreate: the claude config named volume is keyed by
|
docker::start_mcp_containers(&docker_mcp, &net).await?;
|
||||||
// project ID (not container ID) so it persists across recreation.
|
Some(net)
|
||||||
let needs_recreation = docker::container_needs_recreation(&existing_id, &project)
|
} else {
|
||||||
.await
|
None
|
||||||
.unwrap_or(false);
|
};
|
||||||
if needs_recreation {
|
|
||||||
log::info!("Container config changed, recreating container for project {}", project.id);
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||||
let _ = docker::stop_container(&existing_id).await;
|
// Check if config changed — if so, snapshot + recreate
|
||||||
docker::remove_container(&existing_id).await?;
|
let needs_recreate = docker::container_needs_recreation(
|
||||||
|
&existing_id,
|
||||||
|
&project,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
|
&enabled_mcp,
|
||||||
|
).await.unwrap_or(false);
|
||||||
|
|
||||||
|
if needs_recreate {
|
||||||
|
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||||
|
// Snapshot the filesystem before destroying
|
||||||
|
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||||
|
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||||
|
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||||
|
}
|
||||||
|
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||||
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
|
docker::remove_container(&existing_id).await?;
|
||||||
|
|
||||||
|
// Create from snapshot image (preserves system-level changes)
|
||||||
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||||
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
|
snapshot_image
|
||||||
|
} else {
|
||||||
|
image_name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_id = docker::create_container(
|
||||||
|
&project,
|
||||||
|
&docker_socket,
|
||||||
|
&create_image,
|
||||||
|
aws_config_path.as_deref(),
|
||||||
|
&settings.global_aws,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
|
&enabled_mcp,
|
||||||
|
network_name.as_deref(),
|
||||||
|
).await?;
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
|
docker::start_container(&new_id).await?;
|
||||||
|
new_id
|
||||||
|
} else {
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
|
docker::start_container(&existing_id).await?;
|
||||||
|
existing_id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Container doesn't exist (first start, or Docker pruned it).
|
||||||
|
// Check for a snapshot image first — it preserves system-level
|
||||||
|
// changes (apt/pip/npm installs) from the previous session.
|
||||||
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||||
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
|
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||||
|
snapshot_image
|
||||||
|
} else {
|
||||||
|
image_name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&create_image,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
|
&enabled_mcp,
|
||||||
|
network_name.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
} else {
|
};
|
||||||
// Start existing container as-is
|
|
||||||
docker::start_container(&existing_id).await?;
|
|
||||||
existing_id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new container
|
|
||||||
let new_id = docker::create_container(
|
|
||||||
&project,
|
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
|
||||||
&image_name,
|
|
||||||
aws_config_path.as_deref(),
|
|
||||||
&settings.global_aws,
|
|
||||||
).await?;
|
|
||||||
docker::start_container(&new_id).await?;
|
|
||||||
new_id
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update project with container info
|
Ok(container_id)
|
||||||
|
}.await;
|
||||||
|
|
||||||
|
// On failure, reset status to Stopped so the project doesn't get stuck.
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
log::error!("Failed to start container for project {}: {}", project_id, e);
|
||||||
|
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
let container_id = result?;
|
||||||
|
|
||||||
|
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
||||||
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)?;
|
||||||
|
|
||||||
@@ -155,6 +314,7 @@ pub async fn start_project_container(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_project_container(
|
pub async fn stop_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let project = state
|
let project = state
|
||||||
@@ -162,22 +322,35 @@ pub async fn stop_project_container(
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
|
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
|
||||||
|
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
state.exec_manager.close_all_sessions().await;
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||||
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
|
|
||||||
docker::stop_container(container_id).await?;
|
if let Err(e) = docker::stop_container(container_id).await {
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn rebuild_project_container(
|
pub async fn rebuild_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let project = state
|
let project = state
|
||||||
@@ -187,14 +360,30 @@ pub async fn rebuild_project_container(
|
|||||||
|
|
||||||
// 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_all_sessions().await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
let _ = docker::stop_container(container_id).await;
|
let _ = docker::stop_container(container_id).await;
|
||||||
docker::remove_container(container_id).await?;
|
docker::remove_container(container_id).await?;
|
||||||
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
|
||||||
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||||
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
if let Err(e) = docker::remove_project_volumes(&project).await {
|
||||||
|
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
// Start fresh
|
// Start fresh
|
||||||
start_project_container(project_id, state).await
|
start_project_container(project_id, app_handle, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_docker_socket() -> String {
|
fn default_docker_socket() -> String {
|
||||||
|
|||||||
@@ -2,24 +2,8 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::AppSettings;
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
|
||||||
secure::store_api_key(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn has_api_key() -> Result<bool, String> {
|
|
||||||
secure::has_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_api_key() -> Result<(), String> {
|
|
||||||
secure::delete_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
@@ -45,6 +29,33 @@ pub async fn pull_image(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||||
|
// Try the iana-time-zone crate first (cross-platform)
|
||||||
|
match iana_time_zone::get_timezone() {
|
||||||
|
Ok(tz) => return Ok(tz),
|
||||||
|
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check TZ env var
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read /etc/timezone (Linux)
|
||||||
|
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
||||||
|
let tz = tz.trim().to_string();
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to UTC if detection fails
|
||||||
|
Ok("UTC".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
|||||||
@@ -1,7 +1,74 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Build the command to run in the container terminal.
|
||||||
|
///
|
||||||
|
/// 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`
|
||||||
|
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||||
|
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||||
|
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
||||||
|
&& project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
return vec![
|
||||||
|
"claude".to_string(),
|
||||||
|
"--dangerously-skip-permissions".to_string(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve AWS profile: project-level → global settings → "default"
|
||||||
|
let profile = project
|
||||||
|
.bedrock_config
|
||||||
|
.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,
|
||||||
|
// then exec's into claude.
|
||||||
|
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."
|
||||||
|
# Check if this profile uses SSO (has sso_start_url configured)
|
||||||
|
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "Starting SSO login — click the URL below to authenticate:"
|
||||||
|
echo ""
|
||||||
|
aws sso login --profile '{profile}'
|
||||||
|
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
|
||||||
|
exec claude --dangerously-skip-permissions
|
||||||
|
"#,
|
||||||
|
profile = profile
|
||||||
|
);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
script,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_terminal_session(
|
pub async fn open_terminal_session(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -19,10 +86,7 @@ pub async fn open_terminal_session(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Container not running".to_string())?;
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
let cmd = vec![
|
let cmd = build_terminal_cmd(&project, &state);
|
||||||
"claude".to_string(),
|
|
||||||
"--dangerously-skip-permissions".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
let exit_event = format!("terminal-exit-{}", session_id);
|
let exit_event = format!("terminal-exit-{}", session_id);
|
||||||
@@ -69,6 +133,80 @@ pub async fn close_terminal_session(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Close audio bridge if it exists
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
|
// Close terminal session
|
||||||
state.exec_manager.close_session(&session_id).await;
|
state.exec_manager.close_session(&session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn paste_image_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
image_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let file_name = format!("clipboard_{}.png", timestamp);
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_audio_bridge(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Get container_id from the terminal session
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||||
|
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
let cmd = vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.create_session_with_tty(
|
||||||
|
&container_id,
|
||||||
|
&audio_session_id,
|
||||||
|
cmd,
|
||||||
|
false,
|
||||||
|
|_data| { /* ignore output from the audio bridge */ },
|
||||||
|
Box::new(|| { /* no exit handler needed */ }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_audio_data(
|
||||||
|
session_id: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.send_input(&audio_session_id, data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_audio_bridge(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let audio_session_id = format!("audio-{}", session_id);
|
||||||
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
117
app/src-tauri/src/commands/update_commands.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
|
||||||
|
|
||||||
|
const RELEASES_URL: &str =
|
||||||
|
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_app_version() -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let releases: Vec<GiteaRelease> = client
|
||||||
|
.get(RELEASES_URL)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
|
let is_windows = cfg!(target_os = "windows");
|
||||||
|
|
||||||
|
// Filter releases by platform tag suffix
|
||||||
|
let platform_releases: Vec<&GiteaRelease> = releases
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
if is_windows {
|
||||||
|
r.tag_name.ends_with("-win")
|
||||||
|
} else {
|
||||||
|
!r.tag_name.ends_with("-win")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Find the latest release with a higher patch version
|
||||||
|
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
|
||||||
|
let current_patch = parse_patch_version(current_version).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut best: Option<(&GiteaRelease, u32)> = None;
|
||||||
|
for release in &platform_releases {
|
||||||
|
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
|
||||||
|
if patch > current_patch {
|
||||||
|
if best.is_none() || patch > best.unwrap().1 {
|
||||||
|
best = Some((release, patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match best {
|
||||||
|
Some((release, _)) => {
|
||||||
|
let assets = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.map(|a| ReleaseAsset {
|
||||||
|
name: a.name.clone(),
|
||||||
|
browser_download_url: a.browser_download_url.clone(),
|
||||||
|
size: a.size,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Reconstruct version string from tag
|
||||||
|
let version = extract_version_from_tag(&release.tag_name)
|
||||||
|
.unwrap_or_else(|| release.tag_name.clone());
|
||||||
|
|
||||||
|
Ok(Some(UpdateInfo {
|
||||||
|
version,
|
||||||
|
tag_name: release.tag_name.clone(),
|
||||||
|
release_url: release.html_url.clone(),
|
||||||
|
body: release.body.clone(),
|
||||||
|
assets,
|
||||||
|
published_at: release.published_at.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse patch version from a semver string like "0.1.5" -> 5
|
||||||
|
fn parse_patch_version(version: &str) -> Option<u32> {
|
||||||
|
let clean = version.trim_start_matches('v');
|
||||||
|
let parts: Vec<&str> = clean.split('.').collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
parts[2].parse().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
|
||||||
|
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
|
||||||
|
let clean = tag.trim_start_matches('v');
|
||||||
|
// Remove platform suffix
|
||||||
|
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"
|
||||||
|
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||||
|
let clean = tag.trim_start_matches('v');
|
||||||
|
let clean = clean.strip_suffix("-win").unwrap_or(clean);
|
||||||
|
// 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()) {
|
||||||
|
Some(clean.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
use std::sync::OnceLock;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
|
||||||
|
|
||||||
pub fn get_docker() -> Result<&'static Docker, String> {
|
pub fn get_docker() -> Result<Docker, String> {
|
||||||
let result = DOCKER.get_or_init(|| {
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
Docker::connect_with_local_defaults()
|
if let Some(docker) = guard.as_ref() {
|
||||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
|
return Ok(docker.clone());
|
||||||
});
|
|
||||||
match result {
|
|
||||||
Ok(docker) => Ok(docker),
|
|
||||||
Err(e) => Err(e.clone()),
|
|
||||||
}
|
}
|
||||||
|
let docker = Docker::connect_with_local_defaults()
|
||||||
|
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
|
||||||
|
guard.replace(docker.clone());
|
||||||
|
Ok(docker)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_docker_available() -> Result<bool, String> {
|
pub async fn check_docker_available() -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
match docker.ping().await {
|
match docker.ping().await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
|
Err(_) => {
|
||||||
|
// Connection object exists but daemon not responding — clear cache
|
||||||
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
|
*guard = None;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,259 @@ use bollard::container::{
|
|||||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
StartContainerOptions, StopContainerOptions,
|
||||||
};
|
};
|
||||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
|
||||||
|
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
|
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
|
||||||
|
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
|
||||||
|
- `triple-c-scheduler list` — List all scheduled tasks
|
||||||
|
- `triple-c-scheduler remove --id ID` — Remove a task
|
||||||
|
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
|
||||||
|
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
|
||||||
|
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
|
||||||
|
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
|
||||||
|
|
||||||
|
### Cron format
|
||||||
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
|
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
|
||||||
|
|
||||||
|
### One-time tasks
|
||||||
|
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
|
||||||
|
|
||||||
|
### Checking results
|
||||||
|
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
|
||||||
|
|
||||||
|
### Timezone
|
||||||
|
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||||
|
|
||||||
|
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||||
|
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||||
|
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||||
|
/// the same value is produced in both paths.
|
||||||
|
fn build_claude_instructions(
|
||||||
|
global_instructions: Option<&str>,
|
||||||
|
project_instructions: Option<&str>,
|
||||||
|
port_mappings: &[PortMapping],
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
|
||||||
|
|
||||||
|
if !port_mappings.is_empty() {
|
||||||
|
let mut port_lines: Vec<String> = Vec::new();
|
||||||
|
port_lines.push("## Available Port Mappings".to_string());
|
||||||
|
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||||
|
for pm in port_mappings {
|
||||||
|
port_lines.push(format!(
|
||||||
|
"- Host port {} -> Container port {} ({})",
|
||||||
|
pm.host_port, pm.container_port, pm.protocol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port_info = port_lines.join("\n");
|
||||||
|
combined = Some(match combined {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||||
|
None => port_info,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
combined = Some(match combined {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, SCHEDULER_INSTRUCTIONS),
|
||||||
|
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
|
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||||
|
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||||
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for env_var in custom_env_vars {
|
||||||
|
let key = env_var.key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
|
||||||
|
if is_reserved {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(format!("{}={}", key, env_var.value));
|
||||||
|
}
|
||||||
|
parts.sort();
|
||||||
|
parts.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge global and per-project custom environment variables.
|
||||||
|
/// Per-project variables override global variables with the same key.
|
||||||
|
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||||
|
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
|
||||||
|
for ev in global {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ev in project {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge global and per-project Claude instructions into a single string.
|
||||||
|
fn merge_claude_instructions(
|
||||||
|
global_instructions: Option<&str>,
|
||||||
|
project_instructions: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
match (global_instructions, project_instructions) {
|
||||||
|
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
|
||||||
|
(Some(g), None) => Some(g.to_string()),
|
||||||
|
(None, Some(p)) => Some(p.to_string()),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a string with SHA-256 and return the hex digest.
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
|
||||||
|
fn compute_bedrock_fingerprint(project: &Project) -> String {
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
let parts = vec![
|
||||||
|
format!("{:?}", bedrock.auth_method),
|
||||||
|
bedrock.aws_region.clone(),
|
||||||
|
bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
|
||||||
|
bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(),
|
||||||
|
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
|
||||||
|
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
|
||||||
|
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
|
||||||
|
bedrock.model_id.as_deref().unwrap_or("").to_string(),
|
||||||
|
format!("{}", bedrock.disable_prompt_caching),
|
||||||
|
];
|
||||||
|
sha256_hex(&parts.join("|"))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for the project paths so we can detect changes.
|
||||||
|
/// Sorted by mount_name so order changes don't cause spurious recreation.
|
||||||
|
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
||||||
|
let mut parts: Vec<String> = paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}:{}", p.mount_name, p.host_path))
|
||||||
|
.collect();
|
||||||
|
parts.sort();
|
||||||
|
let joined = parts.join(",");
|
||||||
|
sha256_hex(&joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for port mappings so we can detect changes.
|
||||||
|
/// Sorted so order changes don't cause spurious recreation.
|
||||||
|
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||||
|
let mut parts: Vec<String> = port_mappings
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
|
||||||
|
.collect();
|
||||||
|
parts.sort();
|
||||||
|
let joined = parts.join(",");
|
||||||
|
sha256_hex(&joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
|
||||||
|
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
||||||
|
///
|
||||||
|
/// Handles 4 modes:
|
||||||
|
/// - Stdio+Docker: `docker exec -i <mcp-container-name> <command> ...args`
|
||||||
|
/// - Stdio+Manual: `<command> ...args` (existing behavior)
|
||||||
|
/// - HTTP+Docker: `streamableHttp` URL pointing to `http://<mcp-container-name>:<port>/mcp`
|
||||||
|
/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers
|
||||||
|
fn build_mcp_servers_json(servers: &[McpServer]) -> String {
|
||||||
|
let mut mcp_map = serde_json::Map::new();
|
||||||
|
for server in servers {
|
||||||
|
let mut entry = serde_json::Map::new();
|
||||||
|
match server.transport_type {
|
||||||
|
McpTransportType::Stdio => {
|
||||||
|
entry.insert("type".to_string(), serde_json::json!("stdio"));
|
||||||
|
if server.is_docker() {
|
||||||
|
// Stdio+Docker: use `docker exec` to communicate with MCP container
|
||||||
|
entry.insert("command".to_string(), serde_json::json!("docker"));
|
||||||
|
let mut args = vec![
|
||||||
|
"exec".to_string(),
|
||||||
|
"-i".to_string(),
|
||||||
|
server.mcp_container_name(),
|
||||||
|
];
|
||||||
|
if let Some(ref cmd) = server.command {
|
||||||
|
args.push(cmd.clone());
|
||||||
|
}
|
||||||
|
args.extend(server.args.iter().cloned());
|
||||||
|
entry.insert("args".to_string(), serde_json::json!(args));
|
||||||
|
} else {
|
||||||
|
// Stdio+Manual: existing behavior
|
||||||
|
if let Some(ref cmd) = server.command {
|
||||||
|
entry.insert("command".to_string(), serde_json::json!(cmd));
|
||||||
|
}
|
||||||
|
if !server.args.is_empty() {
|
||||||
|
entry.insert("args".to_string(), serde_json::json!(server.args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !server.env.is_empty() {
|
||||||
|
entry.insert("env".to_string(), serde_json::json!(server.env));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpTransportType::Http => {
|
||||||
|
entry.insert("type".to_string(), serde_json::json!("streamableHttp"));
|
||||||
|
if server.is_docker() {
|
||||||
|
// HTTP+Docker: point to MCP container by name on the shared network
|
||||||
|
let url = format!(
|
||||||
|
"http://{}:{}/mcp",
|
||||||
|
server.mcp_container_name(),
|
||||||
|
server.effective_container_port()
|
||||||
|
);
|
||||||
|
entry.insert("url".to_string(), serde_json::json!(url));
|
||||||
|
} else {
|
||||||
|
// HTTP+Manual: user-provided URL + headers
|
||||||
|
if let Some(ref url) = server.url {
|
||||||
|
entry.insert("url".to_string(), serde_json::json!(url));
|
||||||
|
}
|
||||||
|
if !server.headers.is_empty() {
|
||||||
|
entry.insert("headers".to_string(), serde_json::json!(server.headers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry));
|
||||||
|
}
|
||||||
|
let wrapper = serde_json::json!({ "mcpServers": mcp_map });
|
||||||
|
serde_json::to_string(&wrapper).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for MCP server configuration so we can detect changes.
|
||||||
|
fn compute_mcp_fingerprint(servers: &[McpServer]) -> String {
|
||||||
|
if servers.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let json = build_mcp_servers_json(servers);
|
||||||
|
sha256_hex(&json)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -40,17 +288,24 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
|||||||
|
|
||||||
pub async fn create_container(
|
pub async fn create_container(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
|
mcp_servers: &[McpServer],
|
||||||
|
network_name: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
|
|
||||||
let mut env_vars: Vec<String> = Vec::new();
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||||
|
env_vars.push("COLORTERM=truecolor".to_string());
|
||||||
|
|
||||||
// Pass host UID/GID so the entrypoint can remap the container user
|
// Pass host UID/GID so the entrypoint can remap the container user
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -84,10 +339,6 @@ pub async fn create_container(
|
|||||||
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||||
}
|
}
|
||||||
@@ -150,24 +401,81 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = vec![
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
// Project directory -> /workspace
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
Mount {
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
target: Some("/workspace".to_string()),
|
for env_var in &merged_env {
|
||||||
source: Some(project.path.clone()),
|
let key = env_var.key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
|
||||||
|
if is_reserved {
|
||||||
|
log::warn!("Skipping reserved env var: {}", key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
env_vars.push(format!("{}={}", key, env_var.value));
|
||||||
|
}
|
||||||
|
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
|
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||||
|
|
||||||
|
// Container timezone
|
||||||
|
if let Some(tz) = timezone {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
env_vars.push(format!("TZ={}", tz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||||
|
let combined_instructions = build_claude_instructions(
|
||||||
|
global_claude_instructions,
|
||||||
|
project.claude_instructions.as_deref(),
|
||||||
|
&project.port_mappings,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref instructions) = combined_instructions {
|
||||||
|
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP servers config
|
||||||
|
if !mcp_servers.is_empty() {
|
||||||
|
let mcp_json = build_mcp_servers_json(mcp_servers);
|
||||||
|
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
|
|
||||||
|
// Project directories -> /workspace/{mount_name}
|
||||||
|
for pp in &project.paths {
|
||||||
|
mounts.push(Mount {
|
||||||
|
target: Some(format!("/workspace/{}", pp.mount_name)),
|
||||||
|
source: Some(pp.host_path.clone()),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
});
|
||||||
// Named volume for claude config persistence
|
}
|
||||||
Mount {
|
|
||||||
target: Some("/home/claude/.claude".to_string()),
|
// Named volume for the entire home directory — preserves ~/.claude.json,
|
||||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
// ~/.local (pip/npm globals), and any other user-level state across
|
||||||
typ: Some(MountTypeEnum::VOLUME),
|
// container stop/start cycles.
|
||||||
read_only: Some(false),
|
mounts.push(Mount {
|
||||||
..Default::default()
|
target: Some("/home/claude".to_string()),
|
||||||
},
|
source: Some(format!("triple-c-home-{}", project.id)),
|
||||||
];
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
|
read_only: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Named volume for claude config persistence — mounted as a nested volume
|
||||||
|
// inside the home volume; Docker gives the more-specific mount precedence.
|
||||||
|
mounts.push(Mount {
|
||||||
|
target: Some("/home/claude/.claude".to_string()),
|
||||||
|
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||||
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
|
read_only: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
||||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
if let Some(ref ssh_path) = project.ssh_key_path {
|
||||||
@@ -200,7 +508,7 @@ pub async fn create_container(
|
|||||||
if let Some(ref aws_path) = aws_dir {
|
if let Some(ref aws_path) = aws_dir {
|
||||||
if aws_path.exists() {
|
if aws_path.exists() {
|
||||||
mounts.push(Mount {
|
mounts.push(Mount {
|
||||||
target: Some("/home/claude/.aws".to_string()),
|
target: Some("/tmp/.host-aws".to_string()),
|
||||||
source: Some(aws_path.to_string_lossy().to_string()),
|
source: Some(aws_path.to_string_lossy().to_string()),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(true),
|
read_only: Some(true),
|
||||||
@@ -210,34 +518,79 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker socket (only if allowed)
|
// Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers)
|
||||||
if project.allow_docker_access {
|
let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers);
|
||||||
|
if project.allow_docker_access || needs_docker_for_mcp {
|
||||||
|
if needs_docker_for_mcp && !project.allow_docker_access {
|
||||||
|
log::info!("Auto-enabling Docker socket access for stdio+Docker MCP servers");
|
||||||
|
}
|
||||||
|
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
|
||||||
|
// bind-mounted into a Linux container. Docker Desktop exposes the
|
||||||
|
// daemon socket as /var/run/docker.sock for container mounts.
|
||||||
|
let mount_source = if docker_socket_path == "//./pipe/docker_engine" {
|
||||||
|
"/var/run/docker.sock".to_string()
|
||||||
|
} else {
|
||||||
|
docker_socket_path.to_string()
|
||||||
|
};
|
||||||
mounts.push(Mount {
|
mounts.push(Mount {
|
||||||
target: Some("/var/run/docker.sock".to_string()),
|
target: Some("/var/run/docker.sock".to_string()),
|
||||||
source: Some(docker_socket_path.to_string()),
|
source: Some(mount_source),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port mappings
|
||||||
|
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||||
|
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||||
|
for pm in &project.port_mappings {
|
||||||
|
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
|
||||||
|
exposed_ports.insert(container_key.clone(), HashMap::new());
|
||||||
|
port_bindings.insert(
|
||||||
|
container_key,
|
||||||
|
Some(vec![PortBinding {
|
||||||
|
host_ip: Some("0.0.0.0".to_string()),
|
||||||
|
host_port: Some(pm.host_port.to_string()),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut labels = HashMap::new();
|
let mut labels = HashMap::new();
|
||||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||||
|
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||||
|
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||||
|
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
|
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||||
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
|
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
|
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||||
|
init: Some(true),
|
||||||
|
// Connect to project network if specified (for MCP container communication)
|
||||||
|
network_mode: network_name.map(|n| n.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let working_dir = if project.paths.len() == 1 {
|
||||||
|
format!("/workspace/{}", project.paths[0].mount_name)
|
||||||
|
} else {
|
||||||
|
"/workspace".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
image: Some(image_name.to_string()),
|
image: Some(image_name.to_string()),
|
||||||
hostname: Some("triple-c".to_string()),
|
hostname: Some("triple-c".to_string()),
|
||||||
env: Some(env_vars),
|
env: Some(env_vars),
|
||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some(working_dir),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
|
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
|
||||||
tty: Some(true),
|
tty: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -276,10 +629,15 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
log::info!(
|
||||||
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
|
container_id
|
||||||
|
);
|
||||||
docker
|
docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
|
v: false, // preserve named volumes (claude config)
|
||||||
force: true,
|
force: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
@@ -288,30 +646,185 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the snapshot image name for a project.
|
||||||
|
pub fn get_snapshot_image_name(project: &Project) -> String {
|
||||||
|
format!("triple-c-snapshot-{}:latest", project.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit the container's filesystem to a snapshot image so that system-level
|
||||||
|
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
||||||
|
/// removal. The Config is left empty so that secrets injected as env vars are
|
||||||
|
/// NOT baked into the image.
|
||||||
|
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let image_name = get_snapshot_image_name(project);
|
||||||
|
|
||||||
|
// Parse repo:tag
|
||||||
|
let (repo, tag) = match image_name.rsplit_once(':') {
|
||||||
|
Some((r, t)) => (r.to_string(), t.to_string()),
|
||||||
|
None => (image_name.clone(), "latest".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CommitContainerOptions {
|
||||||
|
container: container_id.to_string(),
|
||||||
|
repo: repo.clone(),
|
||||||
|
tag: tag.clone(),
|
||||||
|
pause: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty config — no env vars / cmd baked in
|
||||||
|
let config = Config::<String> {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
docker
|
||||||
|
.commit_container(options, config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the snapshot image for a project (used on Reset / project removal).
|
||||||
|
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let image_name = get_snapshot_image_name(project);
|
||||||
|
|
||||||
|
docker
|
||||||
|
.remove_image(
|
||||||
|
&image_name,
|
||||||
|
Some(RemoveImageOptions {
|
||||||
|
force: true,
|
||||||
|
noprune: false,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
||||||
|
|
||||||
|
log::info!("Removed snapshot image {}", image_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove both named volumes for a project (used on Reset / project removal).
|
||||||
|
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
for vol in [
|
||||||
|
format!("triple-c-home-{}", project.id),
|
||||||
|
format!("triple-c-claude-config-{}", project.id),
|
||||||
|
] {
|
||||||
|
match docker.remove_volume(&vol, None).await {
|
||||||
|
Ok(_) => log::info!("Removed volume {}", vol),
|
||||||
|
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Check whether the existing container's configuration still matches the
|
/// Check whether the existing container's configuration still matches the
|
||||||
/// current project settings. Returns `true` when the container must be
|
/// current project settings. Returns `true` when the container must be
|
||||||
/// recreated (mounts or env vars differ).
|
/// recreated (mounts or env vars differ).
|
||||||
pub async fn container_needs_recreation(container_id: &str, project: &Project) -> Result<bool, String> {
|
pub async fn container_needs_recreation(
|
||||||
|
container_id: &str,
|
||||||
|
project: &Project,
|
||||||
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
|
mcp_servers: &[McpServer],
|
||||||
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
.inspect_container(container_id, None)
|
.inspect_container(container_id, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to inspect container: {}", e))?;
|
.map_err(|e| format!("Failed to inspect container: {}", e))?;
|
||||||
|
|
||||||
|
let labels = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.labels.as_ref());
|
||||||
|
|
||||||
|
let get_label = |name: &str| -> Option<String> {
|
||||||
|
labels.and_then(|l| l.get(name).cloned())
|
||||||
|
};
|
||||||
|
|
||||||
let mounts = info
|
let mounts = info
|
||||||
.host_config
|
.host_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|hc| hc.mounts.as_ref());
|
.and_then(|hc| hc.mounts.as_ref());
|
||||||
|
|
||||||
// ── Docker socket mount ──────────────────────────────────────────────
|
// ── Docker socket mount ──────────────────────────────────────────────
|
||||||
let has_socket = mounts
|
// Intentionally NOT checked here. Toggling "Allow container spawning"
|
||||||
.map(|m| {
|
// should not trigger a full container recreation (which loses Claude
|
||||||
m.iter()
|
// Code settings stored in the named volume). The change takes effect
|
||||||
.any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock"))
|
// on the next explicit rebuild instead.
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
// ── Auth mode ────────────────────────────────────────────────────────
|
||||||
if has_socket != project.allow_docker_access {
|
let current_auth_mode = format!("{:?}", project.auth_mode);
|
||||||
log::info!("Docker socket mismatch (container={}, project={})", has_socket, project.allow_docker_access);
|
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
|
||||||
|
if container_auth_mode != current_auth_mode {
|
||||||
|
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project paths fingerprint ──────────────────────────────────────────
|
||||||
|
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
|
||||||
|
match get_label("triple-c.paths-fingerprint") {
|
||||||
|
Some(container_fp) => {
|
||||||
|
if container_fp != expected_paths_fp {
|
||||||
|
log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Old container without paths-fingerprint label -> force recreation for migration
|
||||||
|
log::info!("Container missing paths-fingerprint label, triggering recreation for migration");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port mappings fingerprint ──────────────────────────────────────────
|
||||||
|
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
|
||||||
|
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
|
||||||
|
if container_ports_fp != expected_ports_fp {
|
||||||
|
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bedrock config fingerprint ───────────────────────────────────────
|
||||||
|
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
||||||
|
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
||||||
|
if container_bedrock_fp != expected_bedrock_fp {
|
||||||
|
log::info!("Bedrock config mismatch");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image ────────────────────────────────────────────────────────────
|
||||||
|
// The image label is set at creation time; if the user changed the
|
||||||
|
// configured image we need to recreate. We only compare when the
|
||||||
|
// label exists (containers created before this change won't have it).
|
||||||
|
if let Some(container_image) = get_label("triple-c.image") {
|
||||||
|
// The caller doesn't pass the image name, but we can read the
|
||||||
|
// container's actual image from Docker inspect.
|
||||||
|
let actual_image = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.image.as_ref());
|
||||||
|
if let Some(actual) = actual_image {
|
||||||
|
if *actual != container_image {
|
||||||
|
log::info!("Image mismatch (actual={:?}, label={:?})", actual, container_image);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timezone ─────────────────────────────────────────────────────────
|
||||||
|
let expected_tz = timezone.unwrap_or("");
|
||||||
|
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
|
||||||
|
if container_tz != expected_tz {
|
||||||
|
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +876,35 @@ pub async fn container_needs_recreation(container_id: &str, project: &Project) -
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom environment variables ──────────────────────────────────────
|
||||||
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
|
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
||||||
|
if container_fingerprint != expected_fingerprint {
|
||||||
|
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claude instructions ───────────────────────────────────────────────
|
||||||
|
let expected_instructions = build_claude_instructions(
|
||||||
|
global_claude_instructions,
|
||||||
|
project.claude_instructions.as_deref(),
|
||||||
|
&project.port_mappings,
|
||||||
|
);
|
||||||
|
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||||
|
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||||
|
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MCP servers fingerprint ─────────────────────────────────────────
|
||||||
|
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
|
||||||
|
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
|
||||||
|
if container_mcp_fp != expected_mcp_fp {
|
||||||
|
log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,3 +963,178 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
|
|||||||
|
|
||||||
Ok(siblings)
|
Ok(siblings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MCP Container Lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns true if any MCP server uses stdio transport with Docker.
|
||||||
|
pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool {
|
||||||
|
servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if any MCP server uses Docker.
|
||||||
|
pub fn any_docker_mcp(servers: &[McpServer]) -> bool {
|
||||||
|
servers.iter().any(|s| s.is_docker())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an existing MCP container by its expected name.
|
||||||
|
pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let container_name = server.mcp_container_name();
|
||||||
|
|
||||||
|
let filters: HashMap<String, Vec<String>> = HashMap::from([
|
||||||
|
("name".to_string(), vec![container_name.clone()]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let containers: Vec<ContainerSummary> = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
all: true,
|
||||||
|
filters,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list MCP containers: {}", e))?;
|
||||||
|
|
||||||
|
let expected = format!("/{}", container_name);
|
||||||
|
for c in &containers {
|
||||||
|
if let Some(names) = &c.names {
|
||||||
|
if names.iter().any(|n| n == &expected) {
|
||||||
|
return Ok(c.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Docker container for an MCP server.
|
||||||
|
pub async fn create_mcp_container(
|
||||||
|
server: &McpServer,
|
||||||
|
network_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let container_name = server.mcp_container_name();
|
||||||
|
|
||||||
|
let image = server
|
||||||
|
.docker_image
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| format!("MCP server '{}' has no docker_image", server.name))?;
|
||||||
|
|
||||||
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
for (k, v) in &server.env {
|
||||||
|
env_vars.push(format!("{}={}", k, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build command + args as Cmd
|
||||||
|
let mut cmd: Vec<String> = Vec::new();
|
||||||
|
if let Some(ref command) = server.command {
|
||||||
|
cmd.push(command.clone());
|
||||||
|
}
|
||||||
|
cmd.extend(server.args.iter().cloned());
|
||||||
|
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||||
|
labels.insert("triple-c.mcp-server".to_string(), server.id.clone());
|
||||||
|
|
||||||
|
let host_config = HostConfig {
|
||||||
|
network_mode: Some(network_name.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
image: Some(image.clone()),
|
||||||
|
env: if env_vars.is_empty() { None } else { Some(env_vars) },
|
||||||
|
cmd: if cmd.is_empty() { None } else { Some(cmd) },
|
||||||
|
labels: Some(labels),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CreateContainerOptions {
|
||||||
|
name: container_name.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = docker
|
||||||
|
.create_container(Some(options), config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create MCP container '{}': {}", container_name, e))?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Created MCP container {} (image: {}) on network {}",
|
||||||
|
container_name,
|
||||||
|
image,
|
||||||
|
network_name
|
||||||
|
);
|
||||||
|
Ok(response.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start all Docker-based MCP server containers. Finds or creates each one.
|
||||||
|
pub async fn start_mcp_containers(
|
||||||
|
servers: &[McpServer],
|
||||||
|
network_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for server in servers {
|
||||||
|
if !server.is_docker() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let container_id = if let Some(existing_id) = find_mcp_container(server).await? {
|
||||||
|
log::debug!("Found existing MCP container for '{}'", server.name);
|
||||||
|
existing_id
|
||||||
|
} else {
|
||||||
|
create_mcp_container(server, network_name).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the container (ignore already-started errors)
|
||||||
|
if let Err(e) = start_container(&container_id).await {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("already started") || err_str.contains("304") {
|
||||||
|
log::debug!("MCP container '{}' already running", server.name);
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to start MCP container '{}': {}",
|
||||||
|
server.name, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("MCP container '{}' started", server.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop all Docker-based MCP server containers (best-effort).
|
||||||
|
pub async fn stop_mcp_containers(servers: &[McpServer]) -> Result<(), String> {
|
||||||
|
for server in servers {
|
||||||
|
if !server.is_docker() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(Some(container_id)) = find_mcp_container(server).await {
|
||||||
|
if let Err(e) = stop_container(&container_id).await {
|
||||||
|
log::warn!("Failed to stop MCP container '{}': {}", server.name, e);
|
||||||
|
} else {
|
||||||
|
log::info!("Stopped MCP container '{}'", server.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and remove all Docker-based MCP server containers (best-effort).
|
||||||
|
pub async fn remove_mcp_containers(servers: &[McpServer]) -> Result<(), String> {
|
||||||
|
for server in servers {
|
||||||
|
if !server.is_docker() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(Some(container_id)) = find_mcp_container(server).await {
|
||||||
|
let _ = stop_container(&container_id).await;
|
||||||
|
if let Err(e) = remove_container(&container_id).await {
|
||||||
|
log::warn!("Failed to remove MCP container '{}': {}", server.name, e);
|
||||||
|
} else {
|
||||||
|
log::info!("Removed MCP container '{}'", server.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use bollard::container::UploadToContainerOptions;
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -9,6 +10,7 @@ use super::client::get_docker;
|
|||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
|
pub container_id: String,
|
||||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||||
shutdown_tx: mpsc::Sender<()>,
|
shutdown_tx: mpsc::Sender<()>,
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,22 @@ impl ExecSessionManager {
|
|||||||
on_output: F,
|
on_output: F,
|
||||||
on_exit: Box<dyn FnOnce() + Send>,
|
on_exit: Box<dyn FnOnce() + Send>,
|
||||||
) -> Result<(), String>
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
|
{
|
||||||
|
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session_with_tty<F>(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
on_output: F,
|
||||||
|
on_exit: Box<dyn FnOnce() + Send>,
|
||||||
|
) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: Fn(Vec<u8>) + Send + 'static,
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
{
|
{
|
||||||
@@ -70,7 +88,7 @@ impl ExecSessionManager {
|
|||||||
attach_stdin: Some(true),
|
attach_stdin: Some(true),
|
||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
tty: Some(true),
|
tty: Some(tty),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
user: Some("claude".to_string()),
|
user: Some("claude".to_string()),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some("/workspace".to_string()),
|
||||||
@@ -140,6 +158,7 @@ impl ExecSessionManager {
|
|||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
|
container_id: container_id.to_string(),
|
||||||
input_tx,
|
input_tx,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
};
|
};
|
||||||
@@ -161,11 +180,26 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||||
let sessions = self.sessions.lock().await;
|
// Clone the exec_id under the lock, then drop the lock before the
|
||||||
let session = sessions
|
// async Docker API call to avoid holding the mutex across await.
|
||||||
.get(session_id)
|
let exec_id = {
|
||||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
let sessions = self.sessions.lock().await;
|
||||||
session.resize(cols, rows).await
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
session.exec_id.clone()
|
||||||
|
};
|
||||||
|
let docker = get_docker()?;
|
||||||
|
docker
|
||||||
|
.resize_exec(
|
||||||
|
&exec_id,
|
||||||
|
ResizeExecOptions {
|
||||||
|
width: cols,
|
||||||
|
height: rows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn close_session(&self, session_id: &str) {
|
pub async fn close_session(&self, session_id: &str) {
|
||||||
@@ -175,10 +209,71 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let ids_to_close: Vec<String> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.container_id == container_id)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in ids_to_close {
|
||||||
|
if let Some(session) = sessions.remove(&id) {
|
||||||
|
session.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close_all_sessions(&self) {
|
pub async fn close_all_sessions(&self) {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
for (_, session) in sessions.drain() {
|
for (_, session) in sessions.drain() {
|
||||||
session.shutdown();
|
session.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
Ok(session.container_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_file_to_container(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Build a tar archive in memory containing the file
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: "/tmp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("/tmp/{}", file_name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::models::container_config;
|
|||||||
|
|
||||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||||
|
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||||
|
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||||
|
|
||||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
header.set_cksum();
|
header.set_cksum();
|
||||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||||
|
|
||||||
|
let scheduler_bytes = SCHEDULER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(scheduler_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
|
||||||
|
|
||||||
|
let task_runner_bytes = TASK_RUNNER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(task_runner_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
|
||||||
|
|
||||||
archive.finish()?;
|
archive.finish()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ 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 use client::*;
|
pub use client::*;
|
||||||
pub use container::*;
|
pub use container::*;
|
||||||
pub use image::*;
|
pub use image::*;
|
||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
|
pub use network::*;
|
||||||
|
|||||||
128
app/src-tauri/src/docker/network.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -1,30 +1,78 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: ProjectsStore,
|
||||||
pub settings_store: SettingsStore,
|
pub settings_store: SettingsStore,
|
||||||
|
pub mcp_store: McpStore,
|
||||||
pub exec_manager: ExecSessionManager,
|
pub exec_manager: ExecSessionManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
env_logger::init();
|
logging::init();
|
||||||
|
|
||||||
|
let projects_store = match ProjectsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize projects store: {}", e);
|
||||||
|
panic!("Failed to initialize projects store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settings_store = match SettingsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize settings store: {}", e);
|
||||||
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mcp_store = match McpStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize MCP store: {}", e);
|
||||||
|
panic!("Failed to initialize MCP store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store: ProjectsStore::new(),
|
projects_store,
|
||||||
settings_store: SettingsStore::new(),
|
settings_store,
|
||||||
|
mcp_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
|
Ok(icon) => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.set_icon(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load window icon: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
|
let state = window.state::<AppState>();
|
||||||
|
tauri::async_runtime::block_on(async {
|
||||||
|
state.exec_manager.close_all_sessions().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Docker
|
// Docker
|
||||||
commands::docker_commands::check_docker,
|
commands::docker_commands::check_docker,
|
||||||
@@ -41,19 +89,29 @@ 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,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::set_api_key,
|
|
||||||
commands::settings_commands::has_api_key,
|
|
||||||
commands::settings_commands::delete_api_key,
|
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
commands::settings_commands::detect_aws_config,
|
commands::settings_commands::detect_aws_config,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
|
commands::settings_commands::detect_host_timezone,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
|
commands::terminal_commands::start_audio_bridge,
|
||||||
|
commands::terminal_commands::send_audio_data,
|
||||||
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
|
// 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,
|
||||||
|
// Updates
|
||||||
|
commands::update_commands::get_app_version,
|
||||||
|
commands::update_commands::check_for_updates,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
73
app/src-tauri/src/logging.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
|
fn log_dir() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise logging to both stderr and a log file in the app data directory.
|
||||||
|
///
|
||||||
|
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
|
||||||
|
/// A panic hook is also installed so that unexpected crashes are captured in the
|
||||||
|
/// same log file before the process exits.
|
||||||
|
pub fn init() {
|
||||||
|
let log_file_path = log_dir().and_then(|dir| {
|
||||||
|
fs::create_dir_all(&dir).ok()?;
|
||||||
|
let path = dir.join("triple-c.log");
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| (path, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut dispatch = fern::Dispatch::new()
|
||||||
|
.format(|out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{} {} {}] {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
|
if let Some((_path, file)) = &log_file_path {
|
||||||
|
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = dispatch.apply() {
|
||||||
|
eprintln!("Failed to initialise logger: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install a panic hook that writes to the log file so crashes are captured.
|
||||||
|
let crash_log_dir = log_dir();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let msg = format!(
|
||||||
|
"[{} PANIC] {}\nBacktrace:\n{:?}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
info,
|
||||||
|
std::backtrace::Backtrace::force_capture(),
|
||||||
|
);
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(ref dir) = crash_log_dir {
|
||||||
|
let crash_path = dir.join("triple-c.log");
|
||||||
|
let _ = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&crash_path)
|
||||||
|
.and_then(|mut f| {
|
||||||
|
use std::io::Write;
|
||||||
|
writeln!(f, "{}", msg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Some((ref path, _)) = log_file_path {
|
||||||
|
log::info!("Logging to {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::project::EnvVar;
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_global_instructions() -> Option<String> {
|
||||||
|
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImageSource {
|
pub enum ImageSource {
|
||||||
@@ -50,6 +60,18 @@ 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 = "default_global_instructions")]
|
||||||
|
pub global_claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_custom_env_vars: Vec<EnvVar>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_check_updates: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dismissed_update_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_microphone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -62,6 +84,12 @@ 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_claude_instructions: default_global_instructions(),
|
||||||
|
global_custom_env_vars: Vec::new(),
|
||||||
|
auto_check_updates: true,
|
||||||
|
dismissed_update_version: None,
|
||||||
|
timezone: None,
|
||||||
|
default_microphone: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
app/src-tauri/src/models/mcp_server.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
pub mod project;
|
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 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 mcp_server::*;
|
||||||
|
|||||||
@@ -1,19 +1,52 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EnvVar {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProjectPath {
|
||||||
|
pub host_path: String,
|
||||||
|
pub mount_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PortMapping {
|
||||||
|
pub host_port: u16,
|
||||||
|
pub container_port: u16,
|
||||||
|
#[serde(default = "default_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocol() -> String {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
pub git_user_email: Option<String>,
|
pub git_user_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_env_vars: Vec<EnvVar>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port_mappings: Vec<PortMapping>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled_mcp_servers: Vec<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -29,20 +62,21 @@ pub enum ProjectStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// How the project authenticates with Claude.
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
/// persisted in the config volume)
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||||
#[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 AuthMode {
|
||||||
Login,
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
ApiKey,
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for AuthMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Login
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,22 +100,26 @@ impl Default for BedrockAuthMethod {
|
|||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
pub auth_method: BedrockAuthMethod,
|
pub auth_method: BedrockAuthMethod,
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_session_token: Option<String>,
|
pub aws_session_token: Option<String>,
|
||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
|
#[serde(skip_serializing, default)]
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
pub fn new(name: String, path: String) -> Self {
|
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
Self {
|
Self {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name,
|
name,
|
||||||
path,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
@@ -91,6 +129,10 @@ impl Project {
|
|||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
|
custom_env_vars: Vec::new(),
|
||||||
|
port_mappings: Vec::new(),
|
||||||
|
claude_instructions: None,
|
||||||
|
enabled_mcp_servers: Vec::new(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -99,4 +141,29 @@ impl Project {
|
|||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrate a project JSON value from old single-`path` format to new `paths` format.
|
||||||
|
/// If the value already has `paths`, it is returned unchanged.
|
||||||
|
pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value {
|
||||||
|
if let Some(obj) = val.as_object_mut() {
|
||||||
|
if obj.contains_key("paths") {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
if let Some(path_val) = obj.remove("path") {
|
||||||
|
let path_str = path_val.as_str().unwrap_or("").to_string();
|
||||||
|
let mount_name = path_str
|
||||||
|
.trim_end_matches(['/', '\\'])
|
||||||
|
.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("workspace")
|
||||||
|
.to_string();
|
||||||
|
let project_path = serde_json::json!([{
|
||||||
|
"host_path": path_str,
|
||||||
|
"mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name },
|
||||||
|
}]);
|
||||||
|
obj.insert("paths".to_string(), project_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/src-tauri/src/models/update_info.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Info returned to the frontend about an available update.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub tag_name: String,
|
||||||
|
pub release_url: String,
|
||||||
|
pub body: String,
|
||||||
|
pub assets: Vec<ReleaseAsset>,
|
||||||
|
pub published_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReleaseAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gitea API release response (internal).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GiteaRelease {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub html_url: String,
|
||||||
|
pub body: String,
|
||||||
|
pub assets: Vec<GiteaAsset>,
|
||||||
|
pub published_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gitea API asset response (internal).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GiteaAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
106
app/src-tauri/src/storage/mcp_store.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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,9 @@
|
|||||||
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;
|
||||||
|
|
||||||
pub use projects_store::*;
|
pub use projects_store::*;
|
||||||
pub use secure::*;
|
pub use secure::*;
|
||||||
pub use settings_store::*;
|
pub use settings_store::*;
|
||||||
|
pub use mcp_store::*;
|
||||||
|
|||||||
@@ -10,42 +10,102 @@ pub struct ProjectsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectsStore {
|
impl ProjectsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Result<Self, String> {
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
||||||
.join("triple-c");
|
.join("triple-c");
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
fs::create_dir_all(&data_dir).ok();
|
||||||
|
|
||||||
let file_path = data_dir.join("projects.json");
|
let file_path = data_dir.join("projects.json");
|
||||||
|
|
||||||
let projects = if file_path.exists() {
|
let (projects, needs_save) = if file_path.exists() {
|
||||||
match fs::read_to_string(&file_path) {
|
match fs::read_to_string(&file_path) {
|
||||||
Ok(data) => match serde_json::from_str(&data) {
|
Ok(data) => {
|
||||||
Ok(parsed) => parsed,
|
// First try to parse as Vec<Value> to run migration
|
||||||
Err(e) => {
|
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
Ok(raw_values) => {
|
||||||
// Back up the corrupted file
|
let mut migrated = false;
|
||||||
let backup = file_path.with_extension("json.bak");
|
let migrated_values: Vec<serde_json::Value> = raw_values
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
.into_iter()
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
.map(|v| {
|
||||||
|
let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths"));
|
||||||
|
if has_path {
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
crate::models::Project::migrate_from_value(v)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Now deserialize the migrated values
|
||||||
|
let json_str = serde_json::to_string(&migrated_values).unwrap_or_default();
|
||||||
|
match serde_json::from_str::<Vec<crate::models::Project>>(&json_str) {
|
||||||
|
Ok(parsed) => (parsed, migrated),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse migrated projects.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 projects.json: {}", be);
|
||||||
|
}
|
||||||
|
(Vec::new(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse projects.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 projects.json: {}", be);
|
||||||
|
}
|
||||||
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to read projects.json: {}", e);
|
log::error!("Failed to read projects.json: {}", e);
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||||
|
// operations can be in flight, so Starting/Stopping are always stale.
|
||||||
|
let mut projects = projects;
|
||||||
|
let mut needs_save = needs_save;
|
||||||
|
for p in projects.iter_mut() {
|
||||||
|
match p.status {
|
||||||
|
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
|
||||||
|
log::warn!(
|
||||||
|
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
|
||||||
|
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
|
||||||
|
p.name,
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
p.status = crate::models::ProjectStatus::Stopped;
|
||||||
|
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
needs_save = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = Self {
|
||||||
projects: Mutex::new(projects),
|
projects: Mutex::new(projects),
|
||||||
file_path,
|
file_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist migrated/reconciled format back to disk
|
||||||
|
if needs_save {
|
||||||
|
log::info!("Saving reconciled/migrated projects.json to disk");
|
||||||
|
let projects = store.lock();
|
||||||
|
if let Err(e) = store.save(&projects) {
|
||||||
|
log::error!("Failed to save projects: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
const SERVICE_NAME: &str = "triple-c";
|
/// Store a per-project secret in the OS keychain.
|
||||||
const API_KEY_USER: &str = "anthropic-api-key";
|
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);
|
||||||
pub fn store_api_key(key: &str) -> Result<(), String> {
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
entry
|
entry
|
||||||
.set_password(key)
|
.set_password(value)
|
||||||
.map_err(|e| format!("Failed to store API key: {}", e))
|
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_api_key() -> Result<Option<String>, String> {
|
/// Retrieve a per-project secret from the OS keychain.
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
||||||
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(key) => Ok(Some(key)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
|
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_api_key() -> Result<(), String> {
|
/// Delete all known secrets for a project from the OS keychain.
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
let secret_keys = [
|
||||||
match entry.delete_credential() {
|
"git-token",
|
||||||
Ok(()) => Ok(()),
|
"aws-access-key-id",
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
"aws-secret-access-key",
|
||||||
Err(e) => Err(format!("Failed to delete API key: {}", e)),
|
"aws-session-token",
|
||||||
}
|
"aws-bearer-token",
|
||||||
}
|
];
|
||||||
|
for key_name in &secret_keys {
|
||||||
pub fn has_api_key() -> Result<bool, String> {
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
match get_api_key() {
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
Ok(Some(_)) => Ok(true),
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
Ok(None) => Ok(false),
|
match entry.delete_credential() {
|
||||||
Err(e) => Err(e),
|
Ok(()) => {}
|
||||||
|
Err(keyring::Error::NoEntry) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ pub struct SettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsStore {
|
impl SettingsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Result<Self, String> {
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
||||||
.join("triple-c");
|
.join("triple-c");
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
fs::create_dir_all(&data_dir).ok();
|
||||||
@@ -41,10 +41,10 @@ impl SettingsStore {
|
|||||||
AppSettings::default()
|
AppSettings::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
settings: Mutex::new(settings),
|
settings: Mutex::new(settings),
|
||||||
file_path,
|
file_path,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
||||||
|
|||||||
@@ -2,7 +2,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.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.triple-c.app",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
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";
|
||||||
@@ -6,21 +7,43 @@ import TerminalView from "./components/terminal/TerminalView";
|
|||||||
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 { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { checkApiKey, loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { sessions, activeSessionId } = useAppState();
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
|
const { sessions, activeSessionId } = useAppState(
|
||||||
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
checkDocker();
|
let stopPolling: (() => void) | undefined;
|
||||||
checkImage();
|
checkDocker().then((available) => {
|
||||||
checkApiKey();
|
if (available) {
|
||||||
|
checkImage();
|
||||||
|
} else {
|
||||||
|
stopPolling = startDockerPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
refresh();
|
refresh();
|
||||||
|
refreshMcp();
|
||||||
|
|
||||||
|
// Update detection
|
||||||
|
loadVersion();
|
||||||
|
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
||||||
|
const cleanup = startPeriodicCheck();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(updateTimer);
|
||||||
|
cleanup?.();
|
||||||
|
stopPolling?.();
|
||||||
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { listSiblingContainers } from "../../lib/tauri-commands";
|
|
||||||
import type { SiblingContainer } from "../../lib/types";
|
|
||||||
|
|
||||||
export default function SiblingContainers() {
|
|
||||||
const [containers, setContainers] = useState<SiblingContainer[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const list = await listSiblingContainers();
|
|
||||||
setContainers(list);
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-medium">Sibling Containers</h3>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "..." : "Refresh"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{containers.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No other containers found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{containers.map((c) => (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className="px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
|
||||||
c.state === "running"
|
|
||||||
? "bg-[var(--success)]"
|
|
||||||
: "bg-[var(--text-secondary)]"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="font-medium truncate">
|
|
||||||
{c.names?.[0]?.replace(/^\//, "") ?? c.id.slice(0, 12)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[var(--text-secondary)] mt-0.5 ml-4">
|
|
||||||
{c.image} — {c.status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
57
app/src/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
|
// Mock zustand store
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: vi.fn((selector) =>
|
||||||
|
selector({
|
||||||
|
sidebarView: "projects",
|
||||||
|
setSidebarView: vi.fn(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock child components to isolate Sidebar layout testing
|
||||||
|
vi.mock("../projects/ProjectList", () => ({
|
||||||
|
default: () => <div data-testid="project-list">ProjectList</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("../mcp/McpPanel", () => ({
|
||||||
|
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Sidebar", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the sidebar with content area", () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
expect(contentArea).not.toBeNull();
|
||||||
|
expect(contentArea!.className).toContain("min-w-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content area has overflow-x-hidden to prevent horizontal scroll", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
expect(contentArea).not.toBeNull();
|
||||||
|
expect(contentArea!.className).toContain("overflow-x-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebar outer container has overflow-hidden", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const sidebar = container.firstElementChild;
|
||||||
|
expect(sidebar).not.toBeNull();
|
||||||
|
expect(sidebar!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,39 +1,45 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { sidebarView, setSidebarView } = useAppState();
|
const { sidebarView, setSidebarView } = useAppState(
|
||||||
|
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabCls = (view: typeof sidebarView) =>
|
||||||
|
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
sidebarView === view
|
||||||
|
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`;
|
||||||
|
|
||||||
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-lg 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
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
onClick={() => setSidebarView("projects")}
|
|
||||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
sidebarView === "projects"
|
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
||||||
onClick={() => setSidebarView("settings")}
|
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
|
||||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
</button>
|
||||||
sidebarView === "settings"
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
{sidebarView === "projects" ? (
|
||||||
|
<ProjectList />
|
||||||
|
) : sidebarView === "mcp" ? (
|
||||||
|
<McpPanel />
|
||||||
|
) : (
|
||||||
|
<SettingsPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
|
||||||
export default function StatusBar() {
|
export default function StatusBar() {
|
||||||
const { projects, sessions } = useAppState();
|
const { projects, sessions } = useAppState(
|
||||||
|
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
||||||
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import TerminalTabs from "../terminal/TerminalTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists } = useAppState();
|
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
updateInfo: s.updateInfo,
|
||||||
|
appVersion: s.appVersion,
|
||||||
|
setUpdateInfo: s.setUpdateInfo,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
if (appSettings && updateInfo) {
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
dismissed_update_version: updateInfo.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setShowUpdateDialog(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-1 overflow-x-auto pl-2">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
<TerminalTabs />
|
<div className="flex-1 overflow-x-auto pl-2">
|
||||||
|
<TerminalTabs />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
|
{updateInfo && (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||||
|
<StatusDot ok={imageExists === true} label="Image" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
{showUpdateDialog && updateInfo && (
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<UpdateDialog
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
updateInfo={updateInfo}
|
||||||
</div>
|
currentVersion={appVersion}
|
||||||
</div>
|
onDismiss={handleDismiss}
|
||||||
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
app/src/components/mcp/McpPanel.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
app/src/components/mcp/McpServerCard.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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 as a container. Leave empty for manual mode.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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 inside the MCP container (default: 3000)
|
||||||
|
</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,98 +1,211 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef, useCallback } 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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PathEntry {
|
||||||
|
host_path: string;
|
||||||
|
mount_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basenameFromPath(p: string): string {
|
||||||
|
return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function AddProjectDialog({ onClose }: Props) {
|
export default function AddProjectDialog({ onClose }: Props) {
|
||||||
const { add } = useProjects();
|
const { add } = useProjects();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [path, setPath] = useState("");
|
const [pathEntries, setPathEntries] = useState<PathEntry[]>([
|
||||||
|
{ host_path: "", mount_name: "" },
|
||||||
|
]);
|
||||||
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 overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
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 selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (typeof selected === "string") {
|
if (typeof selected === "string") {
|
||||||
setPath(selected);
|
const basename = basenameFromPath(selected);
|
||||||
if (!name) {
|
const entries = [...pathEntries];
|
||||||
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
|
entries[index] = {
|
||||||
setName(parts[parts.length - 1]);
|
host_path: selected,
|
||||||
|
mount_name: entries[index].mount_name || basename,
|
||||||
|
};
|
||||||
|
setPathEntries(entries);
|
||||||
|
// Auto-fill project name from first folder
|
||||||
|
if (!name && index === 0) {
|
||||||
|
setName(basename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const updateEntry = (
|
||||||
if (!name.trim() || !path.trim()) {
|
index: number,
|
||||||
setError("Name and path are required");
|
field: keyof PathEntry,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const entries = [...pathEntries];
|
||||||
|
entries[index] = { ...entries[index], [field]: value };
|
||||||
|
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) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Project name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validPaths: ProjectPath[] = pathEntries
|
||||||
|
.filter((p) => p.host_path.trim())
|
||||||
|
.map((p) => ({
|
||||||
|
host_path: p.host_path.trim(),
|
||||||
|
mount_name: p.mount_name.trim() || basenameFromPath(p.host_path),
|
||||||
|
}));
|
||||||
|
if (validPaths.length === 0) {
|
||||||
|
setError("At least one folder path is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mountNames = validPaths.map((p) => p.mount_name);
|
||||||
|
if (new Set(mountNames).size !== mountNames.length) {
|
||||||
|
setError("Mount names must be unique");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await add(name.trim(), path.trim());
|
await add(name.trim(), validPaths);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
setError(String(e));
|
setError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
|
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-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<form onSubmit={handleSubmit}>
|
||||||
Project Name
|
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
</label>
|
Project Name
|
||||||
<input
|
</label>
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
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)]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
|
||||||
Project Path
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<input
|
<input
|
||||||
value={path}
|
ref={nameInputRef}
|
||||||
onChange={(e) => setPath(e.target.value)}
|
value={name}
|
||||||
placeholder="/path/to/project"
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="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)]"
|
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)]"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleBrowse}
|
|
||||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
Folders
|
||||||
)}
|
</label>
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{pathEntries.map((entry, i) => (
|
||||||
|
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
value={entry.host_path}
|
||||||
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
{pathEntries.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(i)}
|
||||||
|
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||||
|
<input
|
||||||
|
value={entry.mount_name}
|
||||||
|
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEntry}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
{error && (
|
||||||
<button
|
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||||
onClick={onClose}
|
)}
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
<div className="flex justify-end gap-2">
|
||||||
Cancel
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={onClose}
|
||||||
onClick={handleSubmit}
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
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"
|
Cancel
|
||||||
>
|
</button>
|
||||||
{loading ? "Adding..." : "Add Project"}
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
80
app/src/components/projects/ClaudeInstructionsModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (instructions: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.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 handleBlur = async () => {
|
||||||
|
try { await onSave(instructions); } catch (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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
operation: "starting" | "stopping" | "resetting";
|
||||||
|
progressMsg: string | null;
|
||||||
|
error: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
onForceStop: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationLabels: Record<string, string> = {
|
||||||
|
starting: "Starting",
|
||||||
|
stopping: "Stopping",
|
||||||
|
resetting: "Resetting",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContainerProgressModal({
|
||||||
|
projectName,
|
||||||
|
operation,
|
||||||
|
progressMsg,
|
||||||
|
error,
|
||||||
|
completed,
|
||||||
|
onForceStop,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-close on success after 800ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (completed && !error) {
|
||||||
|
const timer = setTimeout(onClose, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
// Escape to close (only when completed or error)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && (completed || error)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||||
|
},
|
||||||
|
[completed, error, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inProgress = !completed && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
||||||
|
<h3 className="text-sm font-semibold mb-4">
|
||||||
|
{operationLabels[operation]} “{projectName}”
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Spinner / checkmark / error icon */}
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
{error ? (
|
||||||
|
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||||
|
) : completed ? (
|
||||||
|
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress message */}
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||||
|
{error
|
||||||
|
? <span className="text-[var(--error)]">{error}</span>
|
||||||
|
: completed
|
||||||
|
? "Done!"
|
||||||
|
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{inProgress && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||||
|
>
|
||||||
|
Force Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(completed || error) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/src/components/projects/EnvVarsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(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 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
app/src/components/projects/PortMappingsModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
app/src/components/projects/ProjectCard.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,112 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectCard({ project }: Props) {
|
export default function ProjectCard({ project }: Props) {
|
||||||
const { selectedProjectId, setSelectedProject } = useAppState();
|
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
||||||
|
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
||||||
const { start, stop, rebuild, remove, update } = useProjects();
|
const { start, stop, rebuild, remove, update } = useProjects();
|
||||||
|
const { mcpServers } = useMcpServers();
|
||||||
const { open: openTerminal } = useTerminal();
|
const { open: openTerminal } = useTerminal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||||
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
|
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
|
||||||
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(project.name);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
|
// Local state for text fields (save on blur, not on every keystroke)
|
||||||
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||||
|
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
||||||
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
|
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||||
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
|
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
||||||
|
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
||||||
|
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
|
||||||
|
|
||||||
|
// Bedrock local state for text fields
|
||||||
|
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
|
const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
|
const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
|
const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? "");
|
||||||
|
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
|
||||||
|
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
|
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
||||||
|
|
||||||
|
// Sync local state when project prop changes (e.g., after save or external update)
|
||||||
|
useEffect(() => {
|
||||||
|
setEditName(project.name);
|
||||||
|
setPaths(project.paths ?? []);
|
||||||
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
|
setGitName(project.git_user_name ?? "");
|
||||||
|
setGitEmail(project.git_user_email ?? "");
|
||||||
|
setGitToken(project.git_token ?? "");
|
||||||
|
setClaudeInstructions(project.claude_instructions ?? "");
|
||||||
|
setEnvVars(project.custom_env_vars ?? []);
|
||||||
|
setPortMappings(project.port_mappings ?? []);
|
||||||
|
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
|
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
|
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
|
setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? "");
|
||||||
|
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
||||||
|
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
|
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
// Listen for container progress events
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<{ project_id: string; message: string }>(
|
||||||
|
"container-progress",
|
||||||
|
(event) => {
|
||||||
|
if (event.payload.project_id === project.id) {
|
||||||
|
setProgressMsg(event.payload.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => { unlisten.then((f) => f()); };
|
||||||
|
}, [project.id]);
|
||||||
|
|
||||||
|
// Mark operation completed when status settles
|
||||||
|
useEffect(() => {
|
||||||
|
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
||||||
|
if (activeOperation) {
|
||||||
|
setOperationCompleted(true);
|
||||||
|
}
|
||||||
|
// Clear progress if no modal is managing it
|
||||||
|
if (!activeOperation) {
|
||||||
|
setProgressMsg(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [project.status, activeOperation]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("starting");
|
||||||
try {
|
try {
|
||||||
await start(project.id);
|
await start(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -34,6 +119,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("stopping");
|
||||||
try {
|
try {
|
||||||
await stop(project.id);
|
await stop(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,6 +139,21 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleForceStop = async () => {
|
||||||
|
try {
|
||||||
|
await stop(project.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveOperation(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const defaultBedrockConfig: BedrockConfig = {
|
const defaultBedrockConfig: BedrockConfig = {
|
||||||
auth_method: "static_credentials",
|
auth_method: "static_credentials",
|
||||||
aws_region: "us-east-1",
|
aws_region: "us-east-1",
|
||||||
@@ -79,7 +182,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
try {
|
try {
|
||||||
const current = project.bedrock_config ?? defaultBedrockConfig;
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock config:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowseSSH = async () => {
|
const handleBrowseSSH = async () => {
|
||||||
@@ -93,6 +198,102 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Blur handlers for text fields
|
||||||
|
const handleSshKeyPathBlur = async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, ssh_key_path: sshKeyPath || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update SSH key path:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitNameBlur = async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, git_user_name: gitName || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Git name:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitEmailBlur = async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, git_user_email: gitEmail || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Git email:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitTokenBlur = async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, git_token: gitToken || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Git token:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockRegionBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_region: bedrockRegion } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock region:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockAccessKeyIdBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_access_key_id: bedrockAccessKeyId || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock access key:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockSecretKeyBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_secret_access_key: bedrockSecretKey || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock secret key:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockSessionTokenBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_session_token: bedrockSessionToken || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock session token:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockProfileBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_profile: bedrockProfile || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock profile:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockBearerTokenBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, aws_bearer_token: bedrockBearerToken || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock bearer token:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBedrockModelIdBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, model_id: bedrockModelId || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock model ID:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const statusColor = {
|
const statusColor = {
|
||||||
stopped: "bg-[var(--text-secondary)]",
|
stopped: "bg-[var(--text-secondary)]",
|
||||||
starting: "bg-[var(--warning)]",
|
starting: "bg-[var(--warning)]",
|
||||||
@@ -104,7 +305,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedProject(project.id)}
|
onClick={() => setSelectedProject(project.id)}
|
||||||
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
|
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[var(--bg-tertiary)]"
|
? "bg-[var(--bg-tertiary)]"
|
||||||
: "hover:bg-[var(--bg-tertiary)]"
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
@@ -112,38 +313,64 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
{isEditingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
setIsEditingName(false);
|
||||||
|
const trimmed = editName.trim();
|
||||||
|
if (trimmed && trimmed !== project.name) {
|
||||||
|
try {
|
||||||
|
await update({ ...project, name: trimmed });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename project:", err);
|
||||||
|
setEditName(project.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEditName(project.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium truncate flex-1 cursor-text"
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||||
{project.path}
|
{project.paths.map((pp, i) => (
|
||||||
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="mt-2 ml-4 space-y-2">
|
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||||
{/* Auth mode selector */}
|
{/* Auth mode selector */}
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
project.auth_mode === "login"
|
project.auth_mode === "anthropic"
|
||||||
? "bg-[var(--accent)] text-white"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
/login
|
Anthropic
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
|
||||||
project.auth_mode === "api_key"
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
||||||
} disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
API key
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||||
@@ -166,6 +393,10 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("resetting");
|
||||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
@@ -179,39 +410,153 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<>
|
||||||
{project.status}...
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
</span>
|
{progressMsg ?? `${project.status}...`}
|
||||||
|
</span>
|
||||||
|
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
label={showConfig ? "Hide" : "Config"}
|
label={showConfig ? "Hide" : "Config"}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
{showRemoveConfirm ? (
|
||||||
onClick={async () => {
|
<span className="inline-flex items-center gap-1 text-xs">
|
||||||
if (confirm(`Remove project "${project.name}"?`)) {
|
<span className="text-[var(--text-secondary)]">Remove?</span>
|
||||||
await remove(project.id);
|
<button
|
||||||
}
|
onClick={async (e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
disabled={loading}
|
setShowRemoveConfirm(false);
|
||||||
label="Remove"
|
await remove(project.id);
|
||||||
danger
|
}}
|
||||||
/>
|
className="px-1.5 py-0.5 rounded text-white bg-[var(--error)] hover:opacity-80 transition-colors"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowRemoveConfirm(false); }}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => setShowRemoveConfirm(true)}
|
||||||
|
disabled={loading}
|
||||||
|
label="Remove"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!isStopped && (
|
||||||
|
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change settings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Folder paths */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||||
|
{paths.map((pp, i) => (
|
||||||
|
<div key={i} className="mb-1">
|
||||||
|
<div className="flex gap-1 items-center min-w-0">
|
||||||
|
<input
|
||||||
|
value={pp.host_path}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...paths];
|
||||||
|
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
onBlur={async () => {
|
||||||
|
try { await update({ ...project, paths }); } catch (err) {
|
||||||
|
console.error("Failed to update paths:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="/path/to/folder"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="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)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
const updated = [...paths];
|
||||||
|
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||||
|
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
||||||
|
setPaths(updated);
|
||||||
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||||
|
console.error("Failed to update paths:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="flex-shrink-0 px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
{paths.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const updated = paths.filter((_, j) => j !== i);
|
||||||
|
setPaths(updated);
|
||||||
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||||
|
console.error("Failed to remove path:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 items-center mt-0.5 min-w-0">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">/workspace/</span>
|
||||||
|
<input
|
||||||
|
value={pp.mount_name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...paths];
|
||||||
|
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
onBlur={async () => {
|
||||||
|
try { await update({ ...project, paths }); } catch (err) {
|
||||||
|
console.error("Failed to update paths:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="name"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="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)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const updated = [...paths, { host_path: "", mount_name: "" }];
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SSH Key */}
|
{/* SSH Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={project.ssh_key_path ?? ""}
|
value={sshKeyPath}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||||
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
|
onBlur={handleSshKeyPathBlur}
|
||||||
}}
|
|
||||||
placeholder="~/.ssh"
|
placeholder="~/.ssh"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
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)] disabled:opacity-50"
|
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)] disabled:opacity-50"
|
||||||
@@ -230,10 +575,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||||
<input
|
<input
|
||||||
value={project.git_user_name ?? ""}
|
value={gitName}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitName(e.target.value)}
|
||||||
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
|
onBlur={handleGitNameBlur}
|
||||||
}}
|
|
||||||
placeholder="Your Name"
|
placeholder="Your Name"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="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)] disabled:opacity-50"
|
className="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)] disabled:opacity-50"
|
||||||
@@ -244,10 +588,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||||
<input
|
<input
|
||||||
value={project.git_user_email ?? ""}
|
value={gitEmail}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitEmail(e.target.value)}
|
||||||
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
|
onBlur={handleGitEmailBlur}
|
||||||
}}
|
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="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)] disabled:opacity-50"
|
className="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)] disabled:opacity-50"
|
||||||
@@ -259,10 +602,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={project.git_token ?? ""}
|
value={gitToken}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitToken(e.target.value)}
|
||||||
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
|
onBlur={handleGitTokenBlur}
|
||||||
}}
|
|
||||||
placeholder="ghp_..."
|
placeholder="ghp_..."
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="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)] disabled:opacity-50"
|
className="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)] disabled:opacity-50"
|
||||||
@@ -274,7 +616,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {}
|
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
||||||
|
console.error("Failed to update Docker access setting:", err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||||
@@ -287,6 +631,88 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port Mappings */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPortMappingsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Instructions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Servers */}
|
||||||
|
{mcpServers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mcpServers.map((server) => {
|
||||||
|
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||||
|
const isDocker = !!server.docker_image;
|
||||||
|
return (
|
||||||
|
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onChange={async () => {
|
||||||
|
const updated = enabled
|
||||||
|
? project.enabled_mcp_servers.filter((id) => id !== server.id)
|
||||||
|
: [...project.enabled_mcp_servers, server.id];
|
||||||
|
try {
|
||||||
|
await update({ ...project, enabled_mcp_servers: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update MCP servers:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-[var(--border-color)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
|
||||||
|
<span className={`text-xs px-1 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
||||||
|
{isDocker ? "Docker" : "Manual"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-70">
|
||||||
|
Docker access will be auto-enabled for stdio+Docker MCP servers.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
{project.auth_mode === "bedrock" && (() => {
|
{project.auth_mode === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
@@ -318,8 +744,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_region}
|
value={bedrockRegion}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })}
|
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||||
|
onBlur={handleBedrockRegionBlur}
|
||||||
placeholder="us-east-1"
|
placeholder="us-east-1"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -332,8 +759,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_access_key_id ?? ""}
|
value={bedrockAccessKeyId}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })}
|
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||||
|
onBlur={handleBedrockAccessKeyIdBlur}
|
||||||
placeholder="AKIA..."
|
placeholder="AKIA..."
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -343,8 +771,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_secret_access_key ?? ""}
|
value={bedrockSecretKey}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })}
|
onChange={(e) => setBedrockSecretKey(e.target.value)}
|
||||||
|
onBlur={handleBedrockSecretKeyBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -353,8 +782,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_session_token ?? ""}
|
value={bedrockSessionToken}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })}
|
onChange={(e) => setBedrockSessionToken(e.target.value)}
|
||||||
|
onBlur={handleBedrockSessionTokenBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -367,8 +797,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_profile ?? ""}
|
value={bedrockProfile}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })}
|
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||||
|
onBlur={handleBedrockProfileBlur}
|
||||||
placeholder="default"
|
placeholder="default"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -382,8 +813,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_bearer_token ?? ""}
|
value={bedrockBearerToken}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })}
|
onChange={(e) => setBedrockBearerToken(e.target.value)}
|
||||||
|
onBlur={handleBedrockBearerTokenBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -394,8 +826,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||||
<input
|
<input
|
||||||
value={bc.model_id ?? ""}
|
value={bedrockModelId}
|
||||||
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })}
|
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||||
|
onBlur={handleBedrockModelIdBlur}
|
||||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -412,6 +845,54 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={envVars}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setEnvVars(vars);
|
||||||
|
await update({ ...project, custom_env_vars: vars });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPortMappingsModal && (
|
||||||
|
<PortMappingsModal
|
||||||
|
portMappings={portMappings}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (mappings) => {
|
||||||
|
setPortMappings(mappings);
|
||||||
|
await update({ ...project, port_mappings: mappings });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowPortMappingsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showClaudeInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={claudeInstructions}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setClaudeInstructions(instructions);
|
||||||
|
await update({ ...project, claude_instructions: instructions || null });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeOperation && (
|
||||||
|
<ContainerProgressModal
|
||||||
|
projectName={project.name}
|
||||||
|
operation={activeOperation}
|
||||||
|
progressMsg={progressMsg}
|
||||||
|
error={error}
|
||||||
|
completed={operationCompleted}
|
||||||
|
onForceStop={handleForceStop}
|
||||||
|
onClose={closeModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -443,3 +924,4 @@ function ActionButton({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
|
||||||
|
|
||||||
export default function ApiKeyInput() {
|
export default function ApiKeyInput() {
|
||||||
const { hasKey, saveApiKey, removeApiKey } = useSettings();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!key.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await saveApiKey(key.trim());
|
|
||||||
setKey("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
|
||||||
API Key (for projects using API key mode)
|
|
||||||
</label>
|
|
||||||
{hasKey ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-[var(--success)]">Key configured</span>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try { await removeApiKey(); } catch (e) { setError(String(e)); }
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--error)] hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
className="w-full 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)]"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !key.trim()}
|
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Key"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function AwsSettings() {
|
|||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
||||||
|
Changes here require a container rebuild to take effect.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* AWS Config Path */}
|
{/* AWS Config Path */}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export default function DockerSettings() {
|
|||||||
const handleSourceChange = async (source: ImageSource) => {
|
const handleSourceChange = async (source: ImageSource) => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({ ...appSettings, image_source: source });
|
await saveSettings({ ...appSettings, image_source: source });
|
||||||
// Re-check image existence after changing source
|
await checkImage();
|
||||||
setTimeout(() => checkImage(), 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomChange = async (value: string) => {
|
const handleCustomChange = async (value: string) => {
|
||||||
|
|||||||
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
interface AudioDevice {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MicrophoneSettings() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||||
|
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [permissionNeeded, setPermissionNeeded] = useState(false);
|
||||||
|
|
||||||
|
// Sync local state when appSettings change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(appSettings?.default_microphone ?? "");
|
||||||
|
}, [appSettings?.default_microphone]);
|
||||||
|
|
||||||
|
const enumerateDevices = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setPermissionNeeded(false);
|
||||||
|
try {
|
||||||
|
// Request mic permission first so device labels are available
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
|
||||||
|
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const mics = allDevices
|
||||||
|
.filter((d) => d.kind === "audioinput")
|
||||||
|
.map((d) => ({
|
||||||
|
deviceId: d.deviceId,
|
||||||
|
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
|
||||||
|
}));
|
||||||
|
setDevices(mics);
|
||||||
|
} catch {
|
||||||
|
setPermissionNeeded(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Enumerate devices on mount
|
||||||
|
useEffect(() => {
|
||||||
|
enumerateDevices();
|
||||||
|
}, [enumerateDevices]);
|
||||||
|
|
||||||
|
const handleChange = async (deviceId: string) => {
|
||||||
|
setSelected(deviceId);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Microphone</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Audio input device for Claude Code voice mode (/voice)
|
||||||
|
</p>
|
||||||
|
{permissionNeeded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Microphone permission required
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={enumerateDevices}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Grant Access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<option value="">System Default</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={enumerateDevices}
|
||||||
|
disabled={loading}
|
||||||
|
title="Refresh microphone list"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,55 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const { appVersion, checkForUpdates } = useUpdates();
|
||||||
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
||||||
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
|
||||||
|
// Sync local state when appSettings change
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||||
|
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||||
|
setTimezone(appSettings?.timezone ?? "");
|
||||||
|
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
|
||||||
|
|
||||||
|
// Auto-detect timezone on first load if not yet set
|
||||||
|
useEffect(() => {
|
||||||
|
if (appSettings && !appSettings.timezone) {
|
||||||
|
detectHostTimezone().then((tz) => {
|
||||||
|
setTimezone(tz);
|
||||||
|
saveSettings({ ...appSettings, timezone: tz });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [appSettings?.timezone]);
|
||||||
|
|
||||||
|
const handleCheckNow = async () => {
|
||||||
|
setCheckingUpdates(true);
|
||||||
|
try {
|
||||||
|
await checkForUpdates();
|
||||||
|
} finally {
|
||||||
|
setCheckingUpdates(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoCheckToggle = async () => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
@@ -11,6 +58,124 @@ export default function SettingsPanel() {
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
|
{/* Container Timezone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, timezone: timezone || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="UTC"
|
||||||
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Claude Instructions */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{globalInstructions ? "Configured" : "Not set"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInstructionsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updates section */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Updates</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{appVersion && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Current version: <span className="text-[var(--text-primary)] font-mono">{appVersion}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoCheckToggle}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||||
|
appSettings?.auto_check_updates !== false
|
||||||
|
? "bg-[var(--success)] text-white"
|
||||||
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{appSettings?.auto_check_updates !== false ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckNow}
|
||||||
|
disabled={checkingUpdates}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{checkingUpdates ? "Checking..." : "Check now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={globalInstructions}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setGlobalInstructions(instructions);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={globalEnvVars}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setGlobalEnvVars(vars);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
app/src/components/settings/UpdateDialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import type { UpdateInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
updateInfo: UpdateInfo;
|
||||||
|
currentVersion: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateDialog({
|
||||||
|
updateInfo,
|
||||||
|
currentVersion,
|
||||||
|
onDismiss,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
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 handleDownload = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await openUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open URL:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Update Available</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">{currentVersion}</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">→</span>
|
||||||
|
<span className="text-[var(--accent)] font-semibold">
|
||||||
|
{updateInfo.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateInfo.body && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||||
|
Release Notes
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
|
||||||
|
{updateInfo.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateInfo.assets.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||||
|
Downloads
|
||||||
|
</h3>
|
||||||
|
{updateInfo.assets.map((asset) => (
|
||||||
|
<button
|
||||||
|
key={asset.name}
|
||||||
|
onClick={() => handleDownload(asset.browser_download_url)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="truncate">{asset.name}</span>
|
||||||
|
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||||
|
{formatSize(asset.size)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(updateInfo.release_url)}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
View on Gitea
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
@@ -6,11 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
/** Strip ANSI escape sequences from a string. */
|
import UrlToast from "./UrlToast";
|
||||||
function stripAnsi(s: string): string {
|
|
||||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -19,9 +16,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -68,13 +72,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
// Try WebGL renderer, fall back silently
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||||
try {
|
// to avoid exhausting the browser's limited WebGL context pool.
|
||||||
const webglAddon = new WebglAddon();
|
|
||||||
term.loadAddon(webglAddon);
|
|
||||||
} catch {
|
|
||||||
// WebGL not available, canvas renderer is fine
|
|
||||||
}
|
|
||||||
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
@@ -83,94 +82,221 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Send initial size
|
// Send initial size
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
|
||||||
|
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
||||||
|
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||||||
|
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||||||
|
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||||
|
const idx = data.indexOf(";");
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const payload = data.substring(idx + 1);
|
||||||
|
if (payload === "?") return false; // clipboard read request, not supported
|
||||||
|
try {
|
||||||
|
const decoded = atob(payload);
|
||||||
|
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||||
|
console.error("OSC 52 clipboard write failed:", e),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("OSC 52 decode failed:", e);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Handle user input -> backend
|
// Handle user input -> backend
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── URL accumulator ──────────────────────────────────────────────
|
// Track scroll position to show "Jump to Current" button
|
||||||
// Claude Code login emits a long OAuth URL that gets split across
|
const scrollDisposable = term.onScroll(() => {
|
||||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
const buf = term.buffer.active;
|
||||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||||
// truncated and the link fails when clicked.
|
});
|
||||||
//
|
|
||||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
|
||||||
// debounce check for a URL that spans multiple lines. When found,
|
|
||||||
// write a single clean clickable copy to the terminal.
|
|
||||||
let outputBuffer = "";
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const flushUrlBuffer = () => {
|
// Handle image paste: intercept paste events with image data,
|
||||||
const plain = stripAnsi(outputBuffer);
|
// upload to the container, and inject the file path into terminal input.
|
||||||
// Reassemble: strip hard newlines and carriage returns to join
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
// fragments that were split across terminal lines.
|
const items = e.clipboardData?.items;
|
||||||
const joined = plain.replace(/[\r\n]+/g, "");
|
if (!items) return;
|
||||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
|
||||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
for (const item of Array.from(items)) {
|
||||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
if (item.type.startsWith("image/")) {
|
||||||
if (match) {
|
e.preventDefault();
|
||||||
const url = match[0];
|
e.stopPropagation();
|
||||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
|
||||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
const blob = item.getAsFile();
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
blob.arrayBuffer().then(async (buf) => {
|
||||||
|
try {
|
||||||
|
setImagePasteMsg("Uploading image...");
|
||||||
|
const data = new Uint8Array(buf);
|
||||||
|
const filePath = await pasteImage(sessionId, data);
|
||||||
|
// Inject the file path into terminal stdin
|
||||||
|
sendInput(sessionId, filePath);
|
||||||
|
setImagePasteMsg(`Image saved to ${filePath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Image paste failed:", err);
|
||||||
|
setImagePasteMsg("Image paste failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // Only handle the first image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
outputBuffer = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let unlistenOutput: (() => void) | null = null;
|
let aborted = false;
|
||||||
let unlistenExit: (() => void) | null = null;
|
|
||||||
|
|
||||||
onOutput(sessionId, (data) => {
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||||
|
detectorRef.current = detector;
|
||||||
|
|
||||||
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
detector.feed(data);
|
||||||
// Accumulate for URL detection
|
|
||||||
outputBuffer += data;
|
|
||||||
// Cap buffer size to avoid memory growth
|
|
||||||
if (outputBuffer.length > 8192) {
|
|
||||||
outputBuffer = outputBuffer.slice(-4096);
|
|
||||||
}
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
unlistenOutput = unlisten;
|
if (aborted) unlisten();
|
||||||
|
return unlisten;
|
||||||
});
|
});
|
||||||
|
|
||||||
onExit(sessionId, () => {
|
const exitPromise = onExit(sessionId, () => {
|
||||||
|
if (aborted) return;
|
||||||
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
unlistenExit = unlisten;
|
if (aborted) unlisten();
|
||||||
|
return unlisten;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
||||||
|
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
||||||
|
let resizeRafId: number | null = null;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit();
|
if (resizeRafId !== null) return;
|
||||||
resize(sessionId, term.cols, term.rows);
|
const el = containerRef.current;
|
||||||
|
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||||
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
|
resizeRafId = null;
|
||||||
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
|
fitAddon.fit();
|
||||||
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
aborted = true;
|
||||||
|
detector.dispose();
|
||||||
|
detectorRef.current = null;
|
||||||
|
osc52Disposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
unlistenOutput?.();
|
scrollDisposable.dispose();
|
||||||
unlistenExit?.();
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
|
outputPromise.then((fn) => fn?.());
|
||||||
|
exitPromise.then((fn) => fn?.());
|
||||||
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Re-fit when tab becomes active
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||||
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||||
|
// the browser's limited pool (~8-16 contexts).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && fitRef.current && termRef.current) {
|
const term = termRef.current;
|
||||||
fitRef.current.fit();
|
if (!term) return;
|
||||||
termRef.current.focus();
|
|
||||||
|
if (active) {
|
||||||
|
// Attach WebGL renderer
|
||||||
|
if (!webglRef.current) {
|
||||||
|
try {
|
||||||
|
const addon = new WebglAddon();
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
try { addon.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
});
|
||||||
|
term.loadAddon(addon);
|
||||||
|
webglRef.current = addon;
|
||||||
|
} catch {
|
||||||
|
// WebGL not available, canvas renderer is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.focus();
|
||||||
|
} else {
|
||||||
|
// Release WebGL context for inactive terminals
|
||||||
|
if (webglRef.current) {
|
||||||
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
// Auto-dismiss toast after 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detectedUrl) return;
|
||||||
|
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
// Auto-dismiss image paste message after 3 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imagePasteMsg) return;
|
||||||
|
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [imagePasteMsg]);
|
||||||
|
|
||||||
|
const handleOpenUrl = useCallback(() => {
|
||||||
|
if (detectedUrl) {
|
||||||
|
openUrl(detectedUrl).catch((e) =>
|
||||||
|
console.error("Failed to open URL:", e),
|
||||||
|
);
|
||||||
|
setDetectedUrl(null);
|
||||||
|
}
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
termRef.current?.scrollToBottom();
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={terminalContainerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||||
style={{ padding: "8px" }}
|
>
|
||||||
/>
|
{detectedUrl && (
|
||||||
|
<UrlToast
|
||||||
|
url={detectedUrl}
|
||||||
|
onOpen={handleOpenUrl}
|
||||||
|
onDismiss={() => setDetectedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imagePasteMsg && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
|
||||||
|
onClick={() => setImagePasteMsg(null)}
|
||||||
|
>
|
||||||
|
{imagePasteMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Jump to Current ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ padding: "8px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-slide-down"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 40,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
maxWidth: "min(90%, 600px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Long URL detected
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent)")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-primary)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||||
|
}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||||
|
* Extracted here to validate the decode/write path independently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mirrors the handler registered in TerminalView.tsx
|
||||||
|
function handleOsc52(data: string): string | null {
|
||||||
|
const idx = data.indexOf(";");
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const payload = data.substring(idx + 1);
|
||||||
|
if (payload === "?") return null;
|
||||||
|
try {
|
||||||
|
return atob(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OSC 52 clipboard handler", () => {
|
||||||
|
it("decodes a valid clipboard write sequence", () => {
|
||||||
|
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||||
|
const encoded = btoa("https://example.com");
|
||||||
|
const result = handleOsc52(`c;${encoded}`);
|
||||||
|
expect(result).toBe("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes multi-line content", () => {
|
||||||
|
const text = "line1\nline2\nline3";
|
||||||
|
const encoded = btoa(text);
|
||||||
|
const result = handleOsc52(`c;${encoded}`);
|
||||||
|
expect(result).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles primary selection target (p)", () => {
|
||||||
|
const encoded = btoa("selected text");
|
||||||
|
const result = handleOsc52(`p;${encoded}`);
|
||||||
|
expect(result).toBe("selected text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for clipboard read request (?)", () => {
|
||||||
|
expect(handleOsc52("c;?")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing semicolon", () => {
|
||||||
|
expect(handleOsc52("invalid")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid base64", () => {
|
||||||
|
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty payload after selection target", () => {
|
||||||
|
// btoa("") = ""
|
||||||
|
const result = handleOsc52("c;");
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
@@ -9,7 +10,14 @@ export function useDocker() {
|
|||||||
setDockerAvailable,
|
setDockerAvailable,
|
||||||
imageExists,
|
imageExists,
|
||||||
setImageExists,
|
setImageExists,
|
||||||
} = useAppState();
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
setDockerAvailable: s.setDockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
setImageExists: s.setImageExists,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const checkDocker = useCallback(async () => {
|
const checkDocker = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +59,39 @@ export function useDocker() {
|
|||||||
[setImageExists],
|
[setImageExists],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const startDockerPolling = useCallback(() => {
|
||||||
|
// Don't start if already polling
|
||||||
|
if (pollingRef.current) return () => {};
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const available = await commands.checkDocker();
|
||||||
|
if (available) {
|
||||||
|
clearInterval(interval);
|
||||||
|
pollingRef.current = null;
|
||||||
|
setDockerAvailable(true);
|
||||||
|
// Also check image once Docker is available
|
||||||
|
try {
|
||||||
|
const exists = await commands.checkImageExists();
|
||||||
|
setImageExists(exists);
|
||||||
|
} catch {
|
||||||
|
setImageExists(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still not available, keep polling
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
pollingRef.current = interval;
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
pollingRef.current = null;
|
||||||
|
};
|
||||||
|
}, [setDockerAvailable, setImageExists]);
|
||||||
|
|
||||||
const pullImage = useCallback(
|
const pullImage = useCallback(
|
||||||
async (imageName: string, onProgress?: (msg: string) => void) => {
|
async (imageName: string, onProgress?: (msg: string) => void) => {
|
||||||
const unlisten = onProgress
|
const unlisten = onProgress
|
||||||
@@ -76,5 +117,6 @@ export function useDocker() {
|
|||||||
checkImage,
|
checkImage,
|
||||||
buildImage,
|
buildImage,
|
||||||
pullImage,
|
pullImage,
|
||||||
|
startDockerPolling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
55
app/src/hooks/useMcpServers.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { McpServer } from "../lib/types";
|
||||||
|
|
||||||
|
export function useMcpServers() {
|
||||||
|
const {
|
||||||
|
mcpServers,
|
||||||
|
setMcpServers,
|
||||||
|
updateMcpServerInList,
|
||||||
|
removeMcpServerFromList,
|
||||||
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
mcpServers: s.mcpServers,
|
||||||
|
setMcpServers: s.setMcpServers,
|
||||||
|
updateMcpServerInList: s.updateMcpServerInList,
|
||||||
|
removeMcpServerFromList: s.removeMcpServerFromList,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const list = await commands.listMcpServers();
|
||||||
|
setMcpServers(list);
|
||||||
|
}, [setMcpServers]);
|
||||||
|
|
||||||
|
const add = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const server = await commands.addMcpServer(name);
|
||||||
|
const list = await commands.listMcpServers();
|
||||||
|
setMcpServers(list);
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
[setMcpServers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
async (server: McpServer) => {
|
||||||
|
const updated = await commands.updateMcpServer(server);
|
||||||
|
updateMcpServerInList(updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
[updateMcpServerInList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await commands.removeMcpServer(id);
|
||||||
|
removeMcpServerFromList(id);
|
||||||
|
},
|
||||||
|
[removeMcpServerFromList],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { mcpServers, refresh, add, update, remove };
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { ProjectPath } from "../lib/types";
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const {
|
const {
|
||||||
@@ -10,7 +12,16 @@ export function useProjects() {
|
|||||||
setSelectedProject,
|
setSelectedProject,
|
||||||
updateProjectInList,
|
updateProjectInList,
|
||||||
removeProjectFromList,
|
removeProjectFromList,
|
||||||
} = useAppState();
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
projects: s.projects,
|
||||||
|
selectedProjectId: s.selectedProjectId,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setSelectedProject: s.setSelectedProject,
|
||||||
|
updateProjectInList: s.updateProjectInList,
|
||||||
|
removeProjectFromList: s.removeProjectFromList,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
||||||
|
|
||||||
@@ -20,8 +31,8 @@ export function useProjects() {
|
|||||||
}, [setProjects]);
|
}, [setProjects]);
|
||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
async (name: string, path: string) => {
|
async (name: string, paths: ProjectPath[]) => {
|
||||||
const project = await commands.addProject(name, path);
|
const project = await commands.addProject(name, paths);
|
||||||
// Refresh from backend to avoid stale closure issues
|
// Refresh from backend to avoid stale closure issues
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
@@ -39,31 +50,45 @@ export function useProjects() {
|
|||||||
[removeProjectFromList],
|
[removeProjectFromList],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setOptimisticStatus = useCallback(
|
||||||
|
(id: string, status: "starting" | "stopping") => {
|
||||||
|
const { projects } = useAppState.getState();
|
||||||
|
const project = projects.find((p) => p.id === id);
|
||||||
|
if (project) {
|
||||||
|
updateProjectInList({ ...project, status });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateProjectInList],
|
||||||
|
);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.startProjectContainer(id);
|
const updated = await commands.startProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(
|
const stop = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "stopping");
|
||||||
await commands.stopProjectContainer(id);
|
await commands.stopProjectContainer(id);
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
},
|
},
|
||||||
[setProjects],
|
[setProjects, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rebuild = useCallback(
|
const rebuild = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.rebuildProjectContainer(id);
|
const updated = await commands.rebuildProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState();
|
const { appSettings, setAppSettings } = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
const checkApiKey = useCallback(async () => {
|
appSettings: s.appSettings,
|
||||||
try {
|
setAppSettings: s.setAppSettings,
|
||||||
const has = await commands.hasApiKey();
|
}))
|
||||||
setHasKey(has);
|
|
||||||
return has;
|
|
||||||
} catch {
|
|
||||||
setHasKey(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const saveApiKey = useCallback(
|
|
||||||
async (key: string) => {
|
|
||||||
await commands.setApiKey(key);
|
|
||||||
setHasKey(true);
|
|
||||||
},
|
|
||||||
[setHasKey],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeApiKey = useCallback(async () => {
|
|
||||||
await commands.deleteApiKey();
|
|
||||||
setHasKey(false);
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await commands.getSettings();
|
const settings = await commands.getSettings();
|
||||||
@@ -51,10 +33,6 @@ export function useSettings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasKey,
|
|
||||||
checkApiKey,
|
|
||||||
saveApiKey,
|
|
||||||
removeApiKey,
|
|
||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||||
useAppState();
|
useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
sessions: s.sessions,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
addSession: s.addSession,
|
||||||
|
removeSession: s.removeSession,
|
||||||
|
setActiveSession: s.setActiveSession,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
async (projectId: string, projectName: string) => {
|
async (projectId: string, projectName: string) => {
|
||||||
@@ -40,6 +49,14 @@ export function useTerminal() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pasteImage = useCallback(
|
||||||
|
async (sessionId: string, imageData: Uint8Array) => {
|
||||||
|
const bytes = Array.from(imageData);
|
||||||
|
return commands.pasteImageToTerminal(sessionId, bytes);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onOutput = useCallback(
|
const onOutput = useCallback(
|
||||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||||
const eventName = `terminal-output-${sessionId}`;
|
const eventName = `terminal-output-${sessionId}`;
|
||||||
@@ -67,6 +84,7 @@ export function useTerminal() {
|
|||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
sendInput,
|
sendInput,
|
||||||
|
pasteImage,
|
||||||
resize,
|
resize,
|
||||||
onOutput,
|
onOutput,
|
||||||
onExit,
|
onExit,
|
||||||
|
|||||||
72
app/src/hooks/useUpdates.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export function useUpdates() {
|
||||||
|
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
|
||||||
|
useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
updateInfo: s.updateInfo,
|
||||||
|
setUpdateInfo: s.setUpdateInfo,
|
||||||
|
appVersion: s.appVersion,
|
||||||
|
setAppVersion: s.setAppVersion,
|
||||||
|
appSettings: s.appSettings,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const loadVersion = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const version = await commands.getAppVersion();
|
||||||
|
setAppVersion(version);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load app version:", e);
|
||||||
|
}
|
||||||
|
}, [setAppVersion]);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const info = await commands.checkForUpdates();
|
||||||
|
if (info) {
|
||||||
|
// Respect dismissed version
|
||||||
|
const dismissed = appSettings?.dismissed_update_version;
|
||||||
|
if (dismissed && dismissed === info.version) {
|
||||||
|
setUpdateInfo(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUpdateInfo(info);
|
||||||
|
return info;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to check for updates:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
|
||||||
|
|
||||||
|
const startPeriodicCheck = useCallback(() => {
|
||||||
|
if (intervalRef.current) return;
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
if (appSettings?.auto_check_updates !== false) {
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [checkForUpdates, appSettings?.auto_check_updates]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateInfo,
|
||||||
|
appVersion,
|
||||||
|
loadVersion,
|
||||||
|
checkForUpdates,
|
||||||
|
startPeriodicCheck,
|
||||||
|
};
|
||||||
|
}
|
||||||
103
app/src/hooks/useVoice.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
|
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||||
|
|
||||||
|
export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||||
|
const [state, setState] = useState<VoiceState>("inactive");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (state === "active" || state === "starting") return;
|
||||||
|
setState("starting");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||||
|
await commands.startAudioBridge(sessionId);
|
||||||
|
|
||||||
|
// 2. Get microphone access (use specific device if configured)
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
};
|
||||||
|
if (deviceId) {
|
||||||
|
audioConstraints.deviceId = { exact: deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: audioConstraints,
|
||||||
|
});
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
// 3. Create AudioContext at 16kHz (browser handles resampling)
|
||||||
|
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
|
// 4. Load AudioWorklet processor
|
||||||
|
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
|
||||||
|
|
||||||
|
// 5. Connect: mic → worklet → (silent) destination
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
||||||
|
workletRef.current = processor;
|
||||||
|
|
||||||
|
// 6. Handle PCM chunks from the worklet
|
||||||
|
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||||
|
const bytes = Array.from(new Uint8Array(event.data));
|
||||||
|
commands.sendAudioData(sessionId, bytes).catch(() => {
|
||||||
|
// Audio bridge may have been closed — ignore send errors
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
setState("active");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
setError(msg);
|
||||||
|
setState("error");
|
||||||
|
// Clean up on failure
|
||||||
|
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [sessionId, state, deviceId]);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
// Tear down audio pipeline
|
||||||
|
workletRef.current?.disconnect();
|
||||||
|
workletRef.current = null;
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
await audioContextRef.current.close().catch(() => {});
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the container-side audio bridge
|
||||||
|
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||||
|
|
||||||
|
setState("inactive");
|
||||||
|
setError(null);
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const toggle = useCallback(async () => {
|
||||||
|
if (state === "active") {
|
||||||
|
await stop();
|
||||||
|
} else {
|
||||||
|
await start();
|
||||||
|
}
|
||||||
|
}, [state, start, stop]);
|
||||||
|
|
||||||
|
return { state, error, start, stop, toggle };
|
||||||
|
}
|
||||||
@@ -46,3 +46,10 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast slide-down animation */
|
||||||
|
@keyframes slide-down {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -12,8 +12,8 @@ export const listSiblingContainers = () =>
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||||
export const addProject = (name: string, path: string) =>
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||||
invoke<Project>("add_project", { name, path });
|
invoke<Project>("add_project", { name, paths });
|
||||||
export const removeProject = (projectId: string) =>
|
export const removeProject = (projectId: string) =>
|
||||||
invoke<void>("remove_project", { projectId });
|
invoke<void>("remove_project", { projectId });
|
||||||
export const updateProject = (project: Project) =>
|
export const updateProject = (project: Project) =>
|
||||||
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<Project>("rebuild_project_container", { projectId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const setApiKey = (key: string) =>
|
|
||||||
invoke<void>("set_api_key", { key });
|
|
||||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
|
||||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
invoke<AppSettings>("update_settings", { settings });
|
invoke<AppSettings>("update_settings", { settings });
|
||||||
@@ -39,6 +35,8 @@ export const detectAwsConfig = () =>
|
|||||||
invoke<string | null>("detect_aws_config");
|
invoke<string | null>("detect_aws_config");
|
||||||
export const listAwsProfiles = () =>
|
export const listAwsProfiles = () =>
|
||||||
invoke<string[]>("list_aws_profiles");
|
invoke<string[]>("list_aws_profiles");
|
||||||
|
export const detectHostTimezone = () =>
|
||||||
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||||
@@ -49,3 +47,25 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|||||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||||
export const closeTerminalSession = (sessionId: string) =>
|
export const closeTerminalSession = (sessionId: string) =>
|
||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||||
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||||
|
export const startAudioBridge = (sessionId: string) =>
|
||||||
|
invoke<void>("start_audio_bridge", { sessionId });
|
||||||
|
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||||
|
invoke<void>("send_audio_data", { sessionId, data });
|
||||||
|
export const stopAudioBridge = (sessionId: string) =>
|
||||||
|
invoke<void>("stop_audio_bridge", { sessionId });
|
||||||
|
|
||||||
|
// MCP Servers
|
||||||
|
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||||
|
export const addMcpServer = (name: string) =>
|
||||||
|
invoke<McpServer>("add_mcp_server", { name });
|
||||||
|
export const updateMcpServer = (server: McpServer) =>
|
||||||
|
invoke<McpServer>("update_mcp_server", { server });
|
||||||
|
export const removeMcpServer = (serverId: string) =>
|
||||||
|
invoke<void>("remove_mcp_server", { serverId });
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
export const checkForUpdates = () =>
|
||||||
|
invoke<UpdateInfo | null>("check_for_updates");
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
export interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPath {
|
||||||
|
host_path: string;
|
||||||
|
mount_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortMapping {
|
||||||
|
host_port: number;
|
||||||
|
container_port: number;
|
||||||
|
protocol: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
paths: ProjectPath[];
|
||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
auth_mode: AuthMode;
|
auth_mode: AuthMode;
|
||||||
@@ -11,6 +27,10 @@ export interface Project {
|
|||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
|
custom_env_vars: EnvVar[];
|
||||||
|
port_mappings: PortMapping[];
|
||||||
|
claude_instructions: string | null;
|
||||||
|
enabled_mcp_servers: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -22,7 +42,7 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AuthMode = "login" | "api_key" | "bedrock";
|
export type AuthMode = "anthropic" | "bedrock";
|
||||||
|
|
||||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
@@ -75,4 +95,42 @@ export interface AppSettings {
|
|||||||
image_source: ImageSource;
|
image_source: ImageSource;
|
||||||
custom_image_name: string | null;
|
custom_image_name: string | null;
|
||||||
global_aws: GlobalAwsSettings;
|
global_aws: GlobalAwsSettings;
|
||||||
|
global_claude_instructions: string | null;
|
||||||
|
global_custom_env_vars: EnvVar[];
|
||||||
|
auto_check_updates: boolean;
|
||||||
|
dismissed_update_version: string | null;
|
||||||
|
timezone: string | null;
|
||||||
|
default_microphone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
version: string;
|
||||||
|
tag_name: string;
|
||||||
|
release_url: string;
|
||||||
|
body: string;
|
||||||
|
assets: ReleaseAsset[];
|
||||||
|
published_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpTransportType = "stdio" | "http";
|
||||||
|
|
||||||
|
export interface McpServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport_type: McpTransportType;
|
||||||
|
command: string | null;
|
||||||
|
args: string[];
|
||||||
|
env: Record<string, string>;
|
||||||
|
url: string | null;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
docker_image: string | null;
|
||||||
|
container_port: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
127
app/src/lib/urlDetector.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
||||||
|
*
|
||||||
|
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
||||||
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||||
|
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||||
|
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||||
|
*
|
||||||
|
* When a URL match extends to the end of the flattened buffer, emission is
|
||||||
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||||
|
* the pending URL if no further data arrives within 500 ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANSI_RE =
|
||||||
|
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||||
|
|
||||||
|
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
||||||
|
const MIN_URL_LENGTH = 100;
|
||||||
|
|
||||||
|
export type UrlCallback = (url: string) => void;
|
||||||
|
|
||||||
|
export class UrlDetector {
|
||||||
|
private decoder = new TextDecoder();
|
||||||
|
private buffer = "";
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastEmitted = "";
|
||||||
|
private pendingUrl: string | null = null;
|
||||||
|
private callback: UrlCallback;
|
||||||
|
|
||||||
|
constructor(callback: UrlCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed raw PTY output chunks. */
|
||||||
|
feed(data: Uint8Array): void {
|
||||||
|
this.buffer += this.decoder.decode(data, { stream: true });
|
||||||
|
|
||||||
|
// Cap buffer to avoid unbounded growth
|
||||||
|
if (this.buffer.length > MAX_BUFFER) {
|
||||||
|
this.buffer = this.buffer.slice(-MAX_BUFFER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel pending timers — new data arrived, rescan from scratch
|
||||||
|
if (this.timer !== null) clearTimeout(this.timer);
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce — scan after 300 ms of silence
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.scan();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scan(): void {
|
||||||
|
// 1. Strip ANSI escape sequences
|
||||||
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
|
|
||||||
|
// 2. Flatten the buffer:
|
||||||
|
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||||
|
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||||
|
const flat = clean
|
||||||
|
.replace(/(\r?\n){2,}/g, " ")
|
||||||
|
.replace(/[\r\n]/g, "");
|
||||||
|
|
||||||
|
if (!flat) return;
|
||||||
|
|
||||||
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||||
|
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = urlRe.exec(flat)) !== null) {
|
||||||
|
const url = m[0];
|
||||||
|
|
||||||
|
// 4. Filter by length
|
||||||
|
if (url.length < MIN_URL_LENGTH) continue;
|
||||||
|
|
||||||
|
// 5. If the match extends to the very end of the flattened string,
|
||||||
|
// more chunks may still be arriving — defer emission.
|
||||||
|
if (m.index + url.length >= flat.length) {
|
||||||
|
this.pendingUrl = url;
|
||||||
|
this.confirmTimer = setTimeout(() => {
|
||||||
|
this.confirmTimer = null;
|
||||||
|
this.emitPending();
|
||||||
|
}, CONFIRM_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
|
this.pendingUrl = null;
|
||||||
|
if (url !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = url;
|
||||||
|
this.callback(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan finished without a URL at the buffer end.
|
||||||
|
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
||||||
|
if (this.pendingUrl) {
|
||||||
|
this.emitPending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitPending(): void {
|
||||||
|
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = this.pendingUrl;
|
||||||
|
this.callback(this.pendingUrl);
|
||||||
|
}
|
||||||
|
this.pendingUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -17,19 +17,28 @@ interface AppState {
|
|||||||
removeSession: (id: string) => void;
|
removeSession: (id: string) => void;
|
||||||
setActiveSession: (id: string | null) => void;
|
setActiveSession: (id: string | null) => void;
|
||||||
|
|
||||||
|
// MCP servers
|
||||||
|
mcpServers: McpServer[];
|
||||||
|
setMcpServers: (servers: McpServer[]) => void;
|
||||||
|
updateMcpServerInList: (server: McpServer) => void;
|
||||||
|
removeMcpServerFromList: (id: string) => void;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
sidebarView: "projects" | "settings";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
dockerAvailable: boolean | null;
|
dockerAvailable: boolean | null;
|
||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
setImageExists: (exists: boolean | null) => void;
|
setImageExists: (exists: boolean | null) => void;
|
||||||
hasKey: boolean | null;
|
|
||||||
setHasKey: (has: boolean | null) => void;
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||||
|
appVersion: string;
|
||||||
|
setAppVersion: (version: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
@@ -72,6 +81,20 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
|
|
||||||
|
// MCP servers
|
||||||
|
mcpServers: [],
|
||||||
|
setMcpServers: (servers) => set({ mcpServers: servers }),
|
||||||
|
updateMcpServerInList: (server) =>
|
||||||
|
set((state) => ({
|
||||||
|
mcpServers: state.mcpServers.map((s) =>
|
||||||
|
s.id === server.id ? server : s,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
removeMcpServerFromList: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
mcpServers: state.mcpServers.filter((s) => s.id !== id),
|
||||||
|
})),
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
@@ -79,10 +102,13 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
setImageExists: (exists) => set({ imageExists: exists }),
|
setImageExists: (exists) => set({ imageExists: exists }),
|
||||||
hasKey: null,
|
|
||||||
setHasKey: (has) => set({ hasKey: has }),
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: null,
|
||||||
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||||
|
appVersion: "",
|
||||||
|
setAppVersion: (version) => set({ appVersion: version }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
36
app/src/test/icon-config.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
describe("Window icon configuration", () => {
|
||||||
|
const srcTauriDir = resolve(__dirname, "../../src-tauri");
|
||||||
|
|
||||||
|
it("lib.rs sets window icon using set_icon in setup hook", () => {
|
||||||
|
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
|
||||||
|
expect(libRs).toContain("set_icon");
|
||||||
|
expect(libRs).toContain("icon.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cargo.toml enables image-png feature for icon loading", () => {
|
||||||
|
const cargoToml = readFileSync(resolve(srcTauriDir, "Cargo.toml"), "utf-8");
|
||||||
|
expect(cargoToml).toContain("image-png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("icon.png exists in the icons directory", () => {
|
||||||
|
const iconPath = resolve(srcTauriDir, "icons/icon.png");
|
||||||
|
expect(existsSync(iconPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("icon.ico exists in the icons directory for Windows", () => {
|
||||||
|
const icoPath = resolve(srcTauriDir, "icons/icon.ico");
|
||||||
|
expect(existsSync(icoPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tauri.conf.json includes icon.ico in bundle icons", () => {
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
|
||||||
|
);
|
||||||
|
expect(config.bundle.icon).toContain("icons/icon.ico");
|
||||||
|
expect(config.bundle.icon).toContain("icons/icon.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
1
app/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
|
||||||
# Avoid interactive prompts during package install
|
# Avoid interactive prompts during package install
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
unzip \
|
unzip \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
cron \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||||
@@ -50,9 +52,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-venv \
|
python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
|
||||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
|
||||||
|
|
||||||
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
|
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
|
||||||
RUN install -m 0755 -d /etc/apt/keyrings \
|
RUN install -m 0755 -d /etc/apt/keyrings \
|
||||||
@@ -65,8 +65,11 @@ RUN install -m 0755 -d /etc/apt/keyrings \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
||||||
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
|
RUN ARCH=$(uname -m) && \
|
||||||
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
|
curl "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o "awscliv2.zip" && \
|
||||||
|
unzip -q awscliv2.zip && \
|
||||||
|
./aws/install && \
|
||||||
|
rm -rf awscliv2.zip aws
|
||||||
|
|
||||||
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
||||||
RUN useradd -m -s /bin/bash -u 1000 claude \
|
RUN useradd -m -s /bin/bash -u 1000 claude \
|
||||||
@@ -83,7 +86,7 @@ WORKDIR /home/claude
|
|||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
ENV PATH="/home/claude/.cargo/bin:${PATH}"
|
ENV PATH="/home/claude/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
# Add uv/ruff to PATH (installed to /root by default, reinstall for claude user)
|
# Install uv and ruff for claude user
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
||||||
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"
|
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"
|
||||||
@@ -98,7 +101,29 @@ WORKDIR /workspace
|
|||||||
|
|
||||||
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
# ── OSC 52 clipboard support ─────────────────────────────────────────────
|
||||||
|
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
|
||||||
|
# allowing programs inside the container to copy to the host clipboard.
|
||||||
|
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
|
||||||
|
RUN chmod +x /usr/local/bin/osc52-clipboard \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
|
||||||
|
|
||||||
|
# ── Audio capture shim (voice mode) ────────────────────────────────────────
|
||||||
|
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
|
||||||
|
# allowing Claude Code voice mode to work inside the container.
|
||||||
|
COPY audio-shim /usr/local/bin/audio-shim
|
||||||
|
RUN chmod +x /usr/local/bin/audio-shim \
|
||||||
|
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||||
|
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||||
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-scheduler
|
||||||
|
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-task-runner
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|||||||
16
container/audio-shim
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Audio capture shim for Triple-C voice mode.
|
||||||
|
# Claude Code spawns `rec` or `arecord` to capture mic audio.
|
||||||
|
# Inside Docker there is no mic, so this shim reads PCM data from a
|
||||||
|
# FIFO that the Tauri host app writes to, and outputs it on stdout.
|
||||||
|
|
||||||
|
FIFO=/tmp/triple-c-audio-input
|
||||||
|
|
||||||
|
# Create the FIFO if it doesn't already exist
|
||||||
|
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
|
||||||
|
|
||||||
|
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
|
||||||
|
trap 'exit 0' TERM INT
|
||||||
|
|
||||||
|
# Stream PCM from the FIFO to stdout until we get a signal or EOF
|
||||||
|
cat "$FIFO"
|
||||||
@@ -73,6 +73,19 @@ su -s /bin/bash claude -c '
|
|||||||
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
||||||
'
|
'
|
||||||
|
|
||||||
|
# ── AWS config setup ──────────────────────────────────────────────────────────
|
||||||
|
# Host AWS dir is mounted read-only at /tmp/.host-aws.
|
||||||
|
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
|
||||||
|
if [ -d /tmp/.host-aws ]; then
|
||||||
|
rm -rf /home/claude/.aws
|
||||||
|
cp -a /tmp/.host-aws /home/claude/.aws
|
||||||
|
chown -R claude:claude /home/claude/.aws
|
||||||
|
chmod 700 /home/claude/.aws
|
||||||
|
# Ensure writable cache directories exist
|
||||||
|
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
|
||||||
|
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||||
if [ -n "$GIT_TOKEN" ]; then
|
if [ -n "$GIT_TOKEN" ]; then
|
||||||
CRED_FILE="/home/claude/.git-credentials"
|
CRED_FILE="/home/claude/.git-credentials"
|
||||||
@@ -82,16 +95,46 @@ if [ -n "$GIT_TOKEN" ]; then
|
|||||||
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE"
|
||||||
echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE"
|
||||||
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
|
||||||
su -s /bin/bash claude -c "git config --global credential.helper 'store --file=$CRED_FILE'"
|
git config --global --file /home/claude/.gitconfig credential.helper "store --file=$CRED_FILE"
|
||||||
unset GIT_TOKEN
|
unset GIT_TOKEN
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Git user config ──────────────────────────────────────────────────────────
|
# ── Git user config ──────────────────────────────────────────────────────────
|
||||||
if [ -n "$GIT_USER_NAME" ]; then
|
if [ -n "$GIT_USER_NAME" ]; then
|
||||||
su -s /bin/bash claude -c "git config --global user.name '$GIT_USER_NAME'"
|
git config --global --file /home/claude/.gitconfig user.name "$GIT_USER_NAME"
|
||||||
fi
|
fi
|
||||||
if [ -n "$GIT_USER_EMAIL" ]; then
|
if [ -n "$GIT_USER_EMAIL" ]; then
|
||||||
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'"
|
git config --global --file /home/claude/.gitconfig user.email "$GIT_USER_EMAIL"
|
||||||
|
fi
|
||||||
|
chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Claude instructions ──────────────────────────────────────────────────────
|
||||||
|
if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||||
|
mkdir -p /home/claude/.claude
|
||||||
|
printf '%s\n' "$CLAUDE_INSTRUCTIONS" > /home/claude/.claude/CLAUDE.md
|
||||||
|
chown claude:claude /home/claude/.claude/CLAUDE.md
|
||||||
|
unset CLAUDE_INSTRUCTIONS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── MCP server configuration ────────────────────────────────────────────────
|
||||||
|
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||||
|
# OAuth tokens). Creates the file if it doesn't exist.
|
||||||
|
if [ -n "$MCP_SERVERS_JSON" ]; then
|
||||||
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
|
# Merge: existing config + MCP config (MCP keys override on conflict)
|
||||||
|
MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null)
|
||||||
|
if [ -n "$MERGED" ]; then
|
||||||
|
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON"
|
||||||
|
fi
|
||||||
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
|
chmod 600 "$CLAUDE_JSON"
|
||||||
|
unset MCP_SERVERS_JSON
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
@@ -104,6 +147,59 @@ if [ -S /var/run/docker.sock ]; then
|
|||||||
usermod -aG "$DOCKER_GROUP" claude
|
usermod -aG "$DOCKER_GROUP" claude
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Timezone setup ───────────────────────────────────────────────────────────
|
||||||
|
if [ -n "${TZ:-}" ]; then
|
||||||
|
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||||
|
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
echo "entrypoint: timezone set to $TZ"
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Scheduler setup ─────────────────────────────────────────────────────────
|
||||||
|
SCHEDULER_DIR="/home/claude/.claude/scheduler"
|
||||||
|
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
|
||||||
|
chown -R claude:claude "$SCHEDULER_DIR"
|
||||||
|
|
||||||
|
# Start cron daemon (runs as root, executes jobs per user crontab)
|
||||||
|
cron
|
||||||
|
|
||||||
|
# Save environment variables for cron jobs (cron runs with a minimal env)
|
||||||
|
ENV_FILE="$SCHEDULER_DIR/.env"
|
||||||
|
: > "$ENV_FILE"
|
||||||
|
env | while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
|
||||||
|
# Escape single quotes in value and write as KEY='VALUE'
|
||||||
|
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||||
|
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
chown claude:claude "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# Restore crontab from persisted task JSON files (survives container recreation)
|
||||||
|
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
|
||||||
|
CRON_TMP=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
|
||||||
|
echo "" >> "$CRON_TMP"
|
||||||
|
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
|
||||||
|
done
|
||||||
|
crontab -u claude "$CRON_TMP" 2>/dev/null || true
|
||||||
|
rm -f "$CRON_TMP"
|
||||||
|
echo "entrypoint: restored crontab from persisted tasks"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
||||||
echo "Triple-C container ready."
|
echo "Triple-C container ready."
|
||||||
exec su -s /bin/bash claude -c "exec sleep infinity"
|
exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||||
|
|||||||
26
container/osc52-clipboard
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
|
||||||
|
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
|
||||||
|
# programs inside the container (e.g. Claude Code) can copy to clipboard.
|
||||||
|
#
|
||||||
|
# Supports common invocations:
|
||||||
|
# echo "text" | xclip -selection clipboard
|
||||||
|
# echo "text" | xsel --clipboard --input
|
||||||
|
# echo "text" | pbcopy
|
||||||
|
#
|
||||||
|
# Paste/output requests exit silently (not supported via OSC 52).
|
||||||
|
|
||||||
|
# Detect paste/output mode — exit silently since we can't read the host clipboard
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-o|--output) exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read all input from stdin
|
||||||
|
data=$(cat)
|
||||||
|
[ -z "$data" ] && exit 0
|
||||||
|
|
||||||
|
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
|
||||||
|
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
|
||||||
|
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null
|
||||||
436
container/triple-c-scheduler
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
|
||||||
|
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_id() {
|
||||||
|
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_crontab() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
# Header
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
|
||||||
|
echo "" >> "$tmp"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
local enabled schedule id
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
|
||||||
|
done
|
||||||
|
|
||||||
|
crontab "$tmp" 2>/dev/null || true
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: triple-c-scheduler <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add Add a new scheduled task
|
||||||
|
remove Remove a task
|
||||||
|
enable Enable a disabled task
|
||||||
|
disable Disable a task
|
||||||
|
list List all tasks
|
||||||
|
logs Show execution logs
|
||||||
|
run Manually trigger a task now
|
||||||
|
notifications Show or clear completion notifications
|
||||||
|
|
||||||
|
Add options:
|
||||||
|
--name NAME Task name (required)
|
||||||
|
--prompt "TASK" Task prompt for Claude (required)
|
||||||
|
--schedule "CRON" Cron schedule expression (for recurring tasks)
|
||||||
|
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
|
||||||
|
--working-dir DIR Working directory (default: /workspace)
|
||||||
|
|
||||||
|
Remove/Enable/Disable/Run options:
|
||||||
|
--id ID Task ID (required)
|
||||||
|
|
||||||
|
Logs options:
|
||||||
|
--id ID Show logs for a specific task (optional)
|
||||||
|
--tail N Show last N lines (default: 50)
|
||||||
|
|
||||||
|
Notifications options:
|
||||||
|
--clear Clear all notifications
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
|
||||||
|
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
|
||||||
|
triple-c-scheduler list
|
||||||
|
triple-c-scheduler logs --id a1b2c3d4 --tail 20
|
||||||
|
triple-c-scheduler run --id a1b2c3d4
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_add() {
|
||||||
|
local name="" prompt="" schedule="" at="" working_dir="/workspace"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) name="$2"; shift 2 ;;
|
||||||
|
--prompt) prompt="$2"; shift 2 ;;
|
||||||
|
--schedule) schedule="$2"; shift 2 ;;
|
||||||
|
--at) at="$2"; shift 2 ;;
|
||||||
|
--working-dir) working_dir="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo "Error: --name is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$prompt" ]; then
|
||||||
|
echo "Error: --prompt is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$schedule" ] && [ -z "$at" ]; then
|
||||||
|
echo "Error: either --schedule or --at is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$schedule" ] && [ -n "$at" ]; then
|
||||||
|
echo "Error: use either --schedule or --at, not both" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local id task_type cron_expr
|
||||||
|
id=$(generate_id)
|
||||||
|
|
||||||
|
if [ -n "$at" ]; then
|
||||||
|
task_type="once"
|
||||||
|
# Parse "YYYY-MM-DD HH:MM" into cron expression
|
||||||
|
local year month day hour minute
|
||||||
|
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
|
||||||
|
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
year="${BASH_REMATCH[1]}"
|
||||||
|
month="${BASH_REMATCH[2]}"
|
||||||
|
day="${BASH_REMATCH[3]}"
|
||||||
|
hour="${BASH_REMATCH[4]}"
|
||||||
|
minute="${BASH_REMATCH[5]}"
|
||||||
|
# Remove leading zeros for cron
|
||||||
|
month=$((10#$month))
|
||||||
|
day=$((10#$day))
|
||||||
|
hour=$((10#$hour))
|
||||||
|
minute=$((10#$minute))
|
||||||
|
cron_expr="$minute $hour $day $month *"
|
||||||
|
else
|
||||||
|
task_type="recurring"
|
||||||
|
cron_expr="$schedule"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local created_at
|
||||||
|
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
local task_json
|
||||||
|
task_json=$(jq -n \
|
||||||
|
--arg id "$id" \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg prompt "$prompt" \
|
||||||
|
--arg schedule "$cron_expr" \
|
||||||
|
--arg type "$task_type" \
|
||||||
|
--arg at "$at" \
|
||||||
|
--arg created_at "$created_at" \
|
||||||
|
--argjson enabled true \
|
||||||
|
--arg working_dir "$working_dir" \
|
||||||
|
'{
|
||||||
|
id: $id,
|
||||||
|
name: $name,
|
||||||
|
prompt: $prompt,
|
||||||
|
schedule: $schedule,
|
||||||
|
type: $type,
|
||||||
|
at: $at,
|
||||||
|
created_at: $created_at,
|
||||||
|
enabled: $enabled,
|
||||||
|
working_dir: $working_dir
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "$task_json" > "$TASKS_DIR/${id}.json"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
echo "Task created:"
|
||||||
|
echo " ID: $id"
|
||||||
|
echo " Name: $name"
|
||||||
|
echo " Type: $task_type"
|
||||||
|
if [ "$task_type" = "once" ]; then
|
||||||
|
echo " At: $at"
|
||||||
|
fi
|
||||||
|
echo " Schedule: $cron_expr"
|
||||||
|
echo " Prompt: $prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_remove() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
rm -f "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
echo "Removed task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_enable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Enabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_disable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Disabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local found=false
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
found=true
|
||||||
|
local id name type enabled schedule at prompt
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
type=$(jq -r '.type' "$task_file")
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
at=$(jq -r '.at // ""' "$task_file")
|
||||||
|
prompt=$(jq -r '.prompt' "$task_file")
|
||||||
|
|
||||||
|
local display_schedule="$schedule"
|
||||||
|
if [ "$type" = "once" ] && [ -n "$at" ]; then
|
||||||
|
display_schedule="at $at"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Truncate long fields for display
|
||||||
|
[ ${#name} -gt 20 ] && name="${name:0:17}..."
|
||||||
|
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
|
||||||
|
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
|
||||||
|
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No scheduled tasks."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local id="" tail_n=50
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
--tail) tail_n="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
local log_dir="$LOGS_DIR/$id"
|
||||||
|
if [ ! -d "$log_dir" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Show the most recent log file
|
||||||
|
local latest
|
||||||
|
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "=== Latest log for task $id: $(basename "$latest") ==="
|
||||||
|
tail -n "$tail_n" "$latest"
|
||||||
|
else
|
||||||
|
# Show recent logs across all tasks
|
||||||
|
local all_logs
|
||||||
|
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
|
||||||
|
if [ -z "$all_logs" ]; then
|
||||||
|
echo "No logs found."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for log_file in $all_logs; do
|
||||||
|
local task_id
|
||||||
|
task_id=$(basename "$(dirname "$log_file")")
|
||||||
|
echo "=== Task $task_id: $(basename "$log_file") ==="
|
||||||
|
tail -n 5 "$log_file"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Manually triggering task '$name' ($id)..."
|
||||||
|
/usr/local/bin/triple-c-task-runner "$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_notifications() {
|
||||||
|
local clear=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--clear) clear=true; shift ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$clear" = "true" ]; then
|
||||||
|
rm -f "$NOTIFICATIONS_DIR"/*.notify
|
||||||
|
echo "Notifications cleared."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=false
|
||||||
|
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
|
||||||
|
[ -f "$notify_file" ] || continue
|
||||||
|
found=true
|
||||||
|
cat "$notify_file"
|
||||||
|
echo "---"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No notifications."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
add) cmd_add "$@" ;;
|
||||||
|
remove) cmd_remove "$@" ;;
|
||||||
|
enable) cmd_enable "$@" ;;
|
||||||
|
disable) cmd_disable "$@" ;;
|
||||||
|
list) cmd_list ;;
|
||||||
|
logs) cmd_logs "$@" ;;
|
||||||
|
run) cmd_run "$@" ;;
|
||||||
|
notifications) cmd_notifications "$@" ;;
|
||||||
|
help|--help|-h) usage ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $command" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
142
container/triple-c-task-runner
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
|
||||||
|
# Called by cron with a task ID argument. Handles locking, logging,
|
||||||
|
# notifications, one-time task cleanup, and log pruning.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
ENV_FILE="${SCHEDULER_DIR}/.env"
|
||||||
|
|
||||||
|
TASK_ID="${1:-}"
|
||||||
|
|
||||||
|
if [ -z "$TASK_ID" ]; then
|
||||||
|
echo "Usage: triple-c-task-runner <task-id>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
|
||||||
|
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
|
||||||
|
|
||||||
|
if [ ! -f "$TASK_FILE" ]; then
|
||||||
|
echo "Task file not found: $TASK_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
|
||||||
|
exec 200>"$LOCK_FILE"
|
||||||
|
if ! flock -n 200; then
|
||||||
|
echo "Task $TASK_ID is already running, skipping." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Source saved environment ─────────────────────────────────────────────────
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Read task definition ────────────────────────────────────────────────────
|
||||||
|
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
|
||||||
|
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
|
||||||
|
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
||||||
|
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
|
||||||
|
|
||||||
|
# ── Prepare log directory ───────────────────────────────────────────────────
|
||||||
|
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
||||||
|
mkdir -p "$TASK_LOG_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||||
|
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
|
||||||
|
|
||||||
|
# ── Execute Claude agent ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
|
||||||
|
echo "=== Started: $(date) ==="
|
||||||
|
echo "=== Working dir: $WORKING_DIR ==="
|
||||||
|
echo "=== Prompt: $PROMPT ==="
|
||||||
|
echo ""
|
||||||
|
} > "$LOG_FILE"
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
if [ -d "$WORKING_DIR" ]; then
|
||||||
|
cd "$WORKING_DIR"
|
||||||
|
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
|
||||||
|
else
|
||||||
|
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "=== Finished: $(date) ==="
|
||||||
|
echo "=== Exit code: $EXIT_CODE ==="
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── Write notification ──────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$NOTIFICATIONS_DIR"
|
||||||
|
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
||||||
|
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS="SUCCESS"
|
||||||
|
else
|
||||||
|
STATUS="FAILED (exit code $EXIT_CODE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract a summary (last 10 meaningful lines before the footer)
|
||||||
|
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
|
||||||
|
|
||||||
|
cat > "$NOTIFY_FILE" <<NOTIFY
|
||||||
|
Task: $TASK_NAME ($TASK_ID)
|
||||||
|
Status: $STATUS
|
||||||
|
Time: $(date)
|
||||||
|
Type: $TASK_TYPE
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
$SUMMARY
|
||||||
|
NOTIFY
|
||||||
|
|
||||||
|
# ── One-time task cleanup ───────────────────────────────────────────────────
|
||||||
|
if [ "$TASK_TYPE" = "once" ]; then
|
||||||
|
rm -f "$TASK_FILE"
|
||||||
|
# Rebuild crontab to remove the completed one-time task
|
||||||
|
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
|
||||||
|
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
|
||||||
|
TMP_CRON=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
|
||||||
|
echo "" >> "$TMP_CRON"
|
||||||
|
for tf in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$tf" ] || continue
|
||||||
|
local_enabled=$(jq -r '.enabled' "$tf")
|
||||||
|
[ "$local_enabled" = "true" ] || continue
|
||||||
|
local_schedule=$(jq -r '.schedule' "$tf")
|
||||||
|
local_id=$(jq -r '.id' "$tf")
|
||||||
|
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
|
||||||
|
done
|
||||||
|
crontab "$TMP_CRON" 2>/dev/null || true
|
||||||
|
rm -f "$TMP_CRON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
|
||||||
|
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$LOG_COUNT" -gt 20 ]; then
|
||||||
|
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||||
|
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||||
|
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release lock
|
||||||
|
flock -u 200
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
BIN
triple-c-app-logov2.png
Normal file
|
After Width: | Height: | Size: 191 KiB |