Merge pull request 'feature/docker-install-helper' (#3) from feature/docker-install-helper into main
Some checks failed
Build App / compute-version (push) Successful in 2s
Build Container / build-container (push) Successful in 1m1s
Build App / build-linux (push) Failing after 2m0s
Build App / build-macos (push) Failing after 3m27s
Build App / build-windows (push) Successful in 4m8s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Some checks failed
Build App / compute-version (push) Successful in 2s
Build Container / build-container (push) Successful in 1m1s
Build App / build-linux (push) Failing after 2m0s
Build App / build-macos (push) Failing after 3m27s
Build App / build-windows (push) Successful in 4m8s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
317
.gitea/workflows/build-app-preview.yml
Normal file
317
.gitea/workflows/build-app-preview.yml
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
name: Build App (Preview)
|
||||||
|
|
||||||
|
# Builds the Tauri app for branches other than main and exposes the bundles as
|
||||||
|
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
||||||
|
# smoke-testing of feature branches before they merge.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compute-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute preview version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js 22
|
||||||
|
run: |
|
||||||
|
NEED_INSTALL=false
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
OLD_NODE_DIR=$(dirname "$(which node)")
|
||||||
|
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
||||||
|
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
||||||
|
hash -r
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Node.js not found, installing 22..."
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
hash -r
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
# Tauri / Cargo require a strict semver; strip the preview suffix for
|
||||||
|
# the bundle version but keep it in the artifact filename.
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxdo-dev \
|
||||||
|
patchelf \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Install Tauri CLI
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js 22
|
||||||
|
run: |
|
||||||
|
NEED_INSTALL=false
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
brew install node@22
|
||||||
|
brew link --overwrite node@22
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${BASE_VERSION}"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Install Tauri CLI
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri --version || npm install @tauri-apps/cli
|
||||||
|
|
||||||
|
- name: Build Tauri app (universal)
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
npx tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
|
ls -la artifacts/
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: [compute-version]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: cmd
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$raw = "${{ needs.compute-version.outputs.version }}"
|
||||||
|
$version = $raw.Split('-')[0]
|
||||||
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
where rustup >nul 2>&1 && (
|
||||||
|
rustup update stable
|
||||||
|
rustup default stable
|
||||||
|
) || (
|
||||||
|
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
|
||||||
|
rustup-init.exe -y --default-toolchain stable
|
||||||
|
del rustup-init.exe
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
where node >nul 2>&1 && (
|
||||||
|
node --version
|
||||||
|
) || (
|
||||||
|
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
||||||
|
msiexec /i node-install.msi /quiet /norestart
|
||||||
|
del node-install.msi
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Verify tools
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Install Tauri CLI via cargo
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
cargo install tauri-cli --version "^2"
|
||||||
|
|
||||||
|
- name: Fix npm platform detection
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm config set os win32
|
||||||
|
npm config list
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
if exist node_modules rmdir /s /q node_modules
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: ./app
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
cargo tauri build
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
run: |
|
||||||
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
mkdir artifacts
|
||||||
|
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
|
||||||
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
|
dir artifacts\
|
||||||
|
|
||||||
|
- name: Upload Windows artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
File diff suppressed because one or more lines are too long
@@ -2313,22 +2313,22 @@
|
|||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"const": "dialog:default",
|
||||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the ask command without any pre-configured scope.",
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"const": "dialog:allow-ask",
|
||||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the confirm command without any pre-configured scope.",
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"const": "dialog:allow-confirm",
|
||||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the message command without any pre-configured scope.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2349,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the ask command without any pre-configured scope.",
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"const": "dialog:deny-ask",
|
||||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the confirm command without any pre-configured scope.",
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"const": "dialog:deny-confirm",
|
||||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the message command without any pre-configured scope.",
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
|||||||
@@ -2313,22 +2313,22 @@
|
|||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:default",
|
"const": "dialog:default",
|
||||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the ask command without any pre-configured scope.",
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-ask",
|
"const": "dialog:allow-ask",
|
||||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the confirm command without any pre-configured scope.",
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:allow-confirm",
|
"const": "dialog:allow-confirm",
|
||||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the message command without any pre-configured scope.",
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
@@ -2349,16 +2349,16 @@
|
|||||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the ask command without any pre-configured scope.",
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-ask",
|
"const": "dialog:deny-ask",
|
||||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the confirm command without any pre-configured scope.",
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-confirm",
|
"const": "dialog:deny-confirm",
|
||||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the message command without any pre-configured scope.",
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
|||||||
11
app/src-tauri/src/commands/install_helper_commands.rs
Normal file
11
app/src-tauri/src/commands/install_helper_commands.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::install_helper::{self, InstallOptions};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_install_options() -> Result<InstallOptions, String> {
|
||||||
|
Ok(install_helper::detect_install_options())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
install_helper::platform::run_install(&app_handle).await
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod aws_commands;
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
pub mod help_commands;
|
pub mod help_commands;
|
||||||
|
pub mod install_helper_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
|||||||
@@ -88,6 +88,40 @@ This project uses **Flight Control** (bundled with Triple-C) for structured deve
|
|||||||
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||||
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
|
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
|
||||||
|
|
||||||
|
const SANDBOX_INSTRUCTIONS: &str = r#"## Sandbox Mode
|
||||||
|
|
||||||
|
This container has Claude Code's bash sandbox enabled, managed by Triple-C
|
||||||
|
(toggle it from the project's "Sandbox mode" switch in the Triple-C UI).
|
||||||
|
Bash commands run inside `bubblewrap` with filesystem and network isolation
|
||||||
|
(`enableWeakerNestedSandbox` is on because we are inside Docker).
|
||||||
|
|
||||||
|
### When a command fails because of sandbox restrictions
|
||||||
|
|
||||||
|
Triple-C disables the `dangerouslyDisableSandbox` escape hatch
|
||||||
|
(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the
|
||||||
|
sandbox at runtime. To make a blocked command work, edit
|
||||||
|
`~/.claude/settings.json` and restart Claude Code:
|
||||||
|
|
||||||
|
| Need | Setting |
|
||||||
|
|---|---|
|
||||||
|
| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` |
|
||||||
|
| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` |
|
||||||
|
| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `"docker *"`) to `sandbox.excludedCommands` |
|
||||||
|
|
||||||
|
### Docker commands
|
||||||
|
|
||||||
|
The `docker` CLI does not work inside the sandbox. If this project has
|
||||||
|
"Allow container spawning" enabled in Triple-C and you need to run
|
||||||
|
`docker` commands, add `"docker *"` to `sandbox.excludedCommands` in
|
||||||
|
`~/.claude/settings.json`. Other tools known to be sandbox-incompatible
|
||||||
|
include `watchman` — pass `--no-watchman` to `jest`.
|
||||||
|
|
||||||
|
### Disabling sandbox mode
|
||||||
|
|
||||||
|
Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
|
||||||
|
on every container start. To turn sandbox off, stop the container in
|
||||||
|
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
|
||||||
|
|
||||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||||
@@ -97,6 +131,7 @@ fn build_claude_instructions(
|
|||||||
project_instructions: Option<&str>,
|
project_instructions: Option<&str>,
|
||||||
port_mappings: &[PortMapping],
|
port_mappings: &[PortMapping],
|
||||||
mission_control_enabled: bool,
|
mission_control_enabled: bool,
|
||||||
|
sandbox_enabled: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut combined = merge_claude_instructions(
|
let mut combined = merge_claude_instructions(
|
||||||
global_instructions,
|
global_instructions,
|
||||||
@@ -126,6 +161,13 @@ fn build_claude_instructions(
|
|||||||
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if sandbox_enabled {
|
||||||
|
combined = Some(match combined {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
|
||||||
|
None => SANDBOX_INSTRUCTIONS.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
combined
|
combined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +269,7 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
|
|||||||
bedrock.aws_bearer_token.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(),
|
bedrock.model_id.as_deref().unwrap_or("").to_string(),
|
||||||
format!("{}", bedrock.disable_prompt_caching),
|
format!("{}", bedrock.disable_prompt_caching),
|
||||||
|
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
|
||||||
];
|
];
|
||||||
sha256_hex(&parts.join("|"))
|
sha256_hex(&parts.join("|"))
|
||||||
} else {
|
} else {
|
||||||
@@ -312,8 +355,16 @@ fn merge_claude_code_settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
||||||
fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String {
|
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
|
||||||
match settings {
|
/// a container recreation (re-injecting the merged settings.json). When
|
||||||
|
/// sandbox is off the historical fingerprint is preserved unchanged so that
|
||||||
|
/// upgrading triple-c does not spuriously flag every existing container for
|
||||||
|
/// recreation.
|
||||||
|
fn compute_claude_code_settings_fingerprint(
|
||||||
|
settings: Option<&ClaudeCodeSettings>,
|
||||||
|
sandbox_enabled: bool,
|
||||||
|
) -> String {
|
||||||
|
let base_fp = match settings {
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
let parts = vec![
|
let parts = vec![
|
||||||
@@ -328,30 +379,59 @@ fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings
|
|||||||
];
|
];
|
||||||
sha256_hex(&parts.join("|"))
|
sha256_hex(&parts.join("|"))
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if sandbox_enabled {
|
||||||
|
sha256_hex(&format!("{}|sandbox=true", base_fp))
|
||||||
|
} else {
|
||||||
|
base_fp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the settings.json content for Claude Code from ClaudeCodeSettings.
|
/// Build the settings.json content for Claude Code.
|
||||||
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
|
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
|
||||||
fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option<String> {
|
/// Always emits a `sandbox.enabled` key reflecting the current per-project
|
||||||
|
/// toggle so that flipping it off in triple-c overrides any prior on-state
|
||||||
|
/// stored in the persisted settings.json (which lives in a named volume).
|
||||||
|
fn build_claude_code_settings_json(
|
||||||
|
settings: Option<&ClaudeCodeSettings>,
|
||||||
|
sandbox_enabled: bool,
|
||||||
|
) -> Option<String> {
|
||||||
let mut map = serde_json::Map::new();
|
let mut map = serde_json::Map::new();
|
||||||
|
|
||||||
if let Some(ref tui) = settings.tui_mode {
|
if let Some(s) = settings {
|
||||||
map.insert("tui".to_string(), serde_json::json!(tui));
|
if let Some(ref tui) = s.tui_mode {
|
||||||
}
|
map.insert("tui".to_string(), serde_json::json!(tui));
|
||||||
if let Some(ref effort) = settings.effort {
|
}
|
||||||
map.insert("effort".to_string(), serde_json::json!(effort));
|
if let Some(ref effort) = s.effort {
|
||||||
}
|
map.insert("effort".to_string(), serde_json::json!(effort));
|
||||||
if settings.auto_scroll_disabled {
|
}
|
||||||
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
|
if s.auto_scroll_disabled {
|
||||||
}
|
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
|
||||||
if settings.focus_mode {
|
}
|
||||||
map.insert("focusMode".to_string(), serde_json::json!(true));
|
if s.focus_mode {
|
||||||
}
|
map.insert("focusMode".to_string(), serde_json::json!(true));
|
||||||
if settings.show_thinking_summaries {
|
}
|
||||||
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
|
if s.show_thinking_summaries {
|
||||||
|
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
|
||||||
|
// off in triple-c clears any prior on-state in the persisted
|
||||||
|
// settings.json (which lives in a named volume that survives recreation).
|
||||||
|
// Inside a Docker container we can't rely on privileged user namespaces,
|
||||||
|
// so `enableWeakerNestedSandbox` is required when sandbox is on.
|
||||||
|
let sandbox_obj = if sandbox_enabled {
|
||||||
|
serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"enableWeakerNestedSandbox": true,
|
||||||
|
"allowUnsandboxedCommands": false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
serde_json::json!({ "enabled": false })
|
||||||
|
};
|
||||||
|
map.insert("sandbox".to_string(), sandbox_obj);
|
||||||
|
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -586,6 +666,13 @@ pub async fn create_container(
|
|||||||
if bedrock.disable_prompt_caching {
|
if bedrock.disable_prompt_caching {
|
||||||
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
|
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref tier) = bedrock.service_tier {
|
||||||
|
let trimmed = tier.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +739,7 @@ pub async fn create_container(
|
|||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
project.mission_control_enabled,
|
project.mission_control_enabled,
|
||||||
|
project.sandbox_mode_enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref instructions) = combined_instructions {
|
if let Some(ref instructions) = combined_instructions {
|
||||||
@@ -683,11 +771,16 @@ pub async fn create_container(
|
|||||||
if cc.prompt_caching_1h {
|
if cc.prompt_caching_1h {
|
||||||
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
|
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// settings.json-based settings (written by the entrypoint)
|
// settings.json-based settings (written by the entrypoint).
|
||||||
if let Some(settings_json) = build_claude_code_settings_json(cc) {
|
// Always invoked so per-project sandbox state is injected even when no
|
||||||
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
|
// ClaudeCodeSettings struct is present.
|
||||||
}
|
if let Some(settings_json) = build_claude_code_settings_json(
|
||||||
|
merged_cc_settings.as_ref(),
|
||||||
|
project.sandbox_mode_enabled,
|
||||||
|
) {
|
||||||
|
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts: Vec<Mount> = Vec::new();
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
@@ -821,7 +914,7 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||||
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||||
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
|
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
|
||||||
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref()));
|
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
|
||||||
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||||
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
||||||
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
|
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
|
||||||
@@ -1179,6 +1272,7 @@ pub async fn container_needs_recreation(
|
|||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
project.mission_control_enabled,
|
project.mission_control_enabled,
|
||||||
|
project.sandbox_mode_enabled,
|
||||||
);
|
);
|
||||||
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
|
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
|
||||||
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
|
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
|
||||||
@@ -1192,7 +1286,7 @@ pub async fn container_needs_recreation(
|
|||||||
global_claude_code_settings,
|
global_claude_code_settings,
|
||||||
project.claude_code_settings.as_ref(),
|
project.claude_code_settings.as_ref(),
|
||||||
);
|
);
|
||||||
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref());
|
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref(), project.sandbox_mode_enabled);
|
||||||
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
|
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
|
||||||
if container_cc_fp != expected_cc_fp {
|
if container_cc_fp != expected_cc_fp {
|
||||||
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
|
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
|
||||||
|
|||||||
53
app/src-tauri/src/install_helper/mod.rs
Normal file
53
app/src-tauri/src/install_helper/mod.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
|
||||||
|
// installed on the host and, when missing, offering to install it for the user.
|
||||||
|
//
|
||||||
|
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
|
||||||
|
// Windows. On every platform we also surface an official documentation URL so
|
||||||
|
// users without a recognised package manager can install manually.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod platform;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InstallOptions {
|
||||||
|
/// "linux" | "macos" | "windows" | "unknown"
|
||||||
|
pub os: String,
|
||||||
|
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
|
||||||
|
pub product_name: String,
|
||||||
|
/// Whether we can kick off a one-click install with what's on this machine.
|
||||||
|
pub can_auto_install: bool,
|
||||||
|
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
|
||||||
|
pub auto_install_method: Option<String>,
|
||||||
|
/// If auto-install isn't possible, a human-readable reason to show the user.
|
||||||
|
pub auto_install_blocker: Option<String>,
|
||||||
|
/// Official documentation URL for manual install.
|
||||||
|
pub docs_url: String,
|
||||||
|
/// Ordered manual install steps (plain text lines).
|
||||||
|
pub manual_steps: Vec<String>,
|
||||||
|
/// Notes to display after a successful auto-install (e.g. log out/back in).
|
||||||
|
pub post_install_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_install_options() -> InstallOptions {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
platform::linux_options()
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
platform::macos_options()
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
platform::windows_options()
|
||||||
|
} else {
|
||||||
|
InstallOptions {
|
||||||
|
os: "unknown".into(),
|
||||||
|
product_name: "Docker".into(),
|
||||||
|
can_auto_install: false,
|
||||||
|
auto_install_method: None,
|
||||||
|
auto_install_blocker: Some("Unsupported operating system".into()),
|
||||||
|
docs_url: "https://docs.docker.com/get-docker/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Visit the Docker documentation and follow the install guide for your OS.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
app/src-tauri/src/install_helper/platform.rs
Normal file
288
app/src-tauri/src/install_helper/platform.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::InstallOptions;
|
||||||
|
|
||||||
|
const PROGRESS_EVENT: &str = "docker-install-progress";
|
||||||
|
|
||||||
|
fn which(cmd: &str) -> bool {
|
||||||
|
find_on_path(cmd).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search PATH for an executable, plus a handful of well-known locations that
|
||||||
|
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
|
||||||
|
fn find_on_path(cmd: &str) -> Option<PathBuf> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let extra: &[&str] = &[
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/bin",
|
||||||
|
];
|
||||||
|
#[cfg(windows)]
|
||||||
|
let extra: &[&str] = &[];
|
||||||
|
|
||||||
|
if let Ok(path) = std::env::var("PATH") {
|
||||||
|
let sep = if cfg!(windows) { ';' } else { ':' };
|
||||||
|
for dir in path.split(sep).chain(extra.iter().copied()) {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
for ext in ["exe", "cmd", "bat"] {
|
||||||
|
let mut with_ext = candidate.clone();
|
||||||
|
with_ext.set_extension(ext);
|
||||||
|
if with_ext.is_file() {
|
||||||
|
return Some(with_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in extra {
|
||||||
|
let candidate = PathBuf::from(dir).join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
let app_out = app.clone();
|
||||||
|
let out_task = tokio::spawn(async move {
|
||||||
|
if let Some(out) = stdout {
|
||||||
|
let mut lines = BufReader::new(out).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_out.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_err = app.clone();
|
||||||
|
let err_task = tokio::spawn(async move {
|
||||||
|
if let Some(err) = stderr {
|
||||||
|
let mut lines = BufReader::new(err).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = app_err.emit(PROGRESS_EVENT, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("install process failed: {}", e))?;
|
||||||
|
let _ = out_task.await;
|
||||||
|
let _ = err_task.await;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"installer exited with status {}",
|
||||||
|
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Linux ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn linux_options() -> InstallOptions {
|
||||||
|
let has_pkexec = which("pkexec");
|
||||||
|
let has_curl = which("curl");
|
||||||
|
|
||||||
|
let (can_auto, blocker) = match (has_pkexec, has_curl) {
|
||||||
|
(true, true) => (true, None),
|
||||||
|
(false, _) => (
|
||||||
|
false,
|
||||||
|
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
(_, false) => (
|
||||||
|
false,
|
||||||
|
Some("curl not found — install curl or follow manual steps.".into()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
InstallOptions {
|
||||||
|
os: "linux".into(),
|
||||||
|
product_name: "Docker Engine".into(),
|
||||||
|
can_auto_install: can_auto,
|
||||||
|
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
|
||||||
|
auto_install_blocker: blocker,
|
||||||
|
docs_url: "https://docs.docker.com/engine/install/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Open a terminal.".into(),
|
||||||
|
"Run: curl -fsSL https://get.docker.com | sh".into(),
|
||||||
|
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
|
||||||
|
"Log out and log back in for group changes to take effect.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
|
||||||
|
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
// Grab the current username so pkexec (which runs as root) can add the
|
||||||
|
// original invoking user to the docker group.
|
||||||
|
let invoking_user = std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("LOGNAME"))
|
||||||
|
.map_err(|_| "could not determine invoking username".to_string())?;
|
||||||
|
|
||||||
|
// Write a self-contained installer script to a temp file. Running the
|
||||||
|
// Docker convenience script then appending the user to the docker group
|
||||||
|
// and enabling the service.
|
||||||
|
let script = format!(
|
||||||
|
r#"#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "[triple-c] Downloading Docker install script..."
|
||||||
|
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Running Docker install script (may take a few minutes)..."
|
||||||
|
sh /tmp/triple-c-get-docker.sh
|
||||||
|
rm -f /tmp/triple-c-get-docker.sh
|
||||||
|
echo "[triple-c] Adding {user} to docker group..."
|
||||||
|
usermod -aG docker "{user}" || true
|
||||||
|
echo "[triple-c] Enabling docker service..."
|
||||||
|
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
|
||||||
|
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
|
||||||
|
"#,
|
||||||
|
user = invoking_user
|
||||||
|
);
|
||||||
|
|
||||||
|
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
|
||||||
|
tokio::fs::write(&script_path, script)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to write install script: {}", e))?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Requesting administrator privileges via pkexec..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
let child = Command::new("pkexec")
|
||||||
|
.arg("sh")
|
||||||
|
.arg(&script_path)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
|
||||||
|
|
||||||
|
let result = stream(app, child).await;
|
||||||
|
let _ = tokio::fs::remove_file(&script_path).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── macOS ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn macos_options() -> InstallOptions {
|
||||||
|
let has_brew = which("brew");
|
||||||
|
InstallOptions {
|
||||||
|
os: "macos".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_brew,
|
||||||
|
auto_install_method: if has_brew { Some("brew".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_brew {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Homebrew not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .dmg from the official site.".into(),
|
||||||
|
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker socket is available, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
|
||||||
|
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let brew = find_on_path("brew")
|
||||||
|
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
format!("Running: {} install --cask rancher", brew.display()),
|
||||||
|
);
|
||||||
|
let child = Command::new(&brew)
|
||||||
|
.args(["install", "--cask", "rancher"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch brew: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Windows ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn windows_options() -> InstallOptions {
|
||||||
|
let has_winget = which("winget");
|
||||||
|
InstallOptions {
|
||||||
|
os: "windows".into(),
|
||||||
|
product_name: "Rancher Desktop".into(),
|
||||||
|
can_auto_install: has_winget,
|
||||||
|
auto_install_method: if has_winget { Some("winget".into()) } else { None },
|
||||||
|
auto_install_blocker: if has_winget {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("winget not found — use the manual download.".into())
|
||||||
|
},
|
||||||
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
||||||
|
manual_steps: vec![
|
||||||
|
"Download the Rancher Desktop .msi from the official site.".into(),
|
||||||
|
"Run the installer and accept the WSL2 prompts if asked.".into(),
|
||||||
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
||||||
|
"Once the Docker engine is running, come back and click Refresh.".into(),
|
||||||
|
],
|
||||||
|
post_install_notes: vec![
|
||||||
|
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
|
||||||
|
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
|
||||||
|
);
|
||||||
|
let child = Command::new("winget")
|
||||||
|
.args([
|
||||||
|
"install",
|
||||||
|
"--id",
|
||||||
|
"SUSE.RancherDesktop",
|
||||||
|
"-e",
|
||||||
|
"--accept-package-agreements",
|
||||||
|
"--accept-source-agreements",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to launch winget: {}", e))?;
|
||||||
|
stream(app, child).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
run_linux_install(app).await
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
run_macos_install(app).await
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
run_windows_install(app).await
|
||||||
|
} else {
|
||||||
|
Err("auto-install is not supported on this OS".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod install_helper;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -197,6 +198,9 @@ pub fn run() {
|
|||||||
commands::update_commands::check_image_update,
|
commands::update_commands::check_image_update,
|
||||||
// Help
|
// Help
|
||||||
commands::help_commands::get_help_content,
|
commands::help_commands::get_help_content,
|
||||||
|
// Install helper
|
||||||
|
commands::install_helper_commands::detect_install_options,
|
||||||
|
commands::install_helper_commands::run_docker_install,
|
||||||
// Web Terminal
|
// Web Terminal
|
||||||
commands::web_terminal_commands::start_web_terminal,
|
commands::web_terminal_commands::start_web_terminal,
|
||||||
commands::web_terminal_commands::stop_web_terminal,
|
commands::web_terminal_commands::stop_web_terminal,
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub struct Project {
|
|||||||
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sandbox_mode_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
pub mission_control_enabled: bool,
|
||||||
#[serde(default = "default_full_permissions")]
|
#[serde(default = "default_full_permissions")]
|
||||||
pub full_permissions: bool,
|
pub full_permissions: bool,
|
||||||
@@ -159,6 +161,10 @@ pub struct BedrockConfig {
|
|||||||
pub aws_bearer_token: Option<String>,
|
pub aws_bearer_token: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub disable_prompt_caching: bool,
|
pub disable_prompt_caching: bool,
|
||||||
|
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
|
||||||
|
/// (e.g. "priority"). Empty/None means leave unset.
|
||||||
|
#[serde(default)]
|
||||||
|
pub service_tier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ollama configuration for a project.
|
/// Ollama configuration for a project.
|
||||||
@@ -199,6 +205,7 @@ impl Project {
|
|||||||
ollama_config: None,
|
ollama_config: None,
|
||||||
openai_compatible_config: None,
|
openai_compatible_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
import { 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";
|
||||||
@@ -21,6 +22,7 @@ export default function App() {
|
|||||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
const { sessions, activeSessionId, setProjects } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
||||||
);
|
);
|
||||||
|
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +40,7 @@ export default function App() {
|
|||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setShowInstallDialog(true);
|
||||||
stopPolling = startDockerPolling();
|
stopPolling = startDockerPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,6 +83,9 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
{showInstallDialog && (
|
||||||
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
211
app/src/components/DockerInstallDialog.tsx
Normal file
211
app/src/components/DockerInstallDialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
|
import { useDocker } from "../hooks/useDocker";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = "idle" | "installing" | "done" | "error";
|
||||||
|
|
||||||
|
export default function DockerInstallDialog({ onClose }: Props) {
|
||||||
|
const { options, loadOptions, runInstall } = useInstallHelper();
|
||||||
|
const { checkDocker } = useDocker();
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOptions();
|
||||||
|
}, [loadOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && phase !== "installing") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose, phase]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current && phase !== "installing") onClose();
|
||||||
|
},
|
||||||
|
[onClose, phase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setPhase("installing");
|
||||||
|
setLog([]);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await runInstall((line) => setLog((prev) => [...prev, line]));
|
||||||
|
setPhase("done");
|
||||||
|
// Re-check Docker so the rest of the app can proceed without a reload.
|
||||||
|
await checkDocker();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDocs = async () => {
|
||||||
|
if (!options) return;
|
||||||
|
try {
|
||||||
|
await openUrl(options.docs_url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open docs URL:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecheck = async () => {
|
||||||
|
const available = await checkDocker();
|
||||||
|
if (available) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
|
||||||
|
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
|
||||||
|
for you, or you can follow the official instructions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{phase === "idle" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.can_auto_install ? (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{installVerb} ({options.auto_install_method})
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||||
|
One-click install unavailable:{" "}
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{options.auto_install_blocker ?? "required tooling missing."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManual((s) => !s)}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenDocs}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Open official documentation ↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "installing" && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Installing… a system password prompt may appear. Do not close this window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "done" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-sm text-[var(--success)]">Install finished.</div>
|
||||||
|
{options.post_install_notes.length > 0 && (
|
||||||
|
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||||
|
{options.post_install_notes.map((note, i) => (
|
||||||
|
<li key={i}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRecheck}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Re-check Docker
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "error" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-sm text-[var(--error)]">Install failed.</div>
|
||||||
|
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPhase("idle")}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenDocs}
|
||||||
|
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Open official docs ↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showManual || phase === "error") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||||
|
Manual install steps
|
||||||
|
</div>
|
||||||
|
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||||
|
{options.manual_steps.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.length > 0 && (
|
||||||
|
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||||
|
{log.map((line, i) => (
|
||||||
|
<div key={i}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "idle" && (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
|
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
|
||||||
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
||||||
|
const [bedrockServiceTier, setBedrockServiceTier] = useState(project.bedrock_config?.service_tier ?? "");
|
||||||
|
|
||||||
// Ollama local state
|
// Ollama local state
|
||||||
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||||
@@ -88,6 +89,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
||||||
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||||
|
setBedrockServiceTier(project.bedrock_config?.service_tier ?? "");
|
||||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||||
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
||||||
@@ -192,6 +194,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
aws_bearer_token: null,
|
aws_bearer_token: null,
|
||||||
model_id: null,
|
model_id: null,
|
||||||
disable_prompt_caching: false,
|
disable_prompt_caching: false,
|
||||||
|
service_tier: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOllamaConfig: OllamaConfig = {
|
const defaultOllamaConfig: OllamaConfig = {
|
||||||
@@ -339,6 +342,16 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBedrockServiceTierBlur = async () => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
const trimmed = bedrockServiceTier.trim();
|
||||||
|
await update({ ...project, bedrock_config: { ...current, service_tier: trimmed || null } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock service tier:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOllamaBaseUrlBlur = async () => {
|
const handleOllamaBaseUrlBlur = async () => {
|
||||||
try {
|
try {
|
||||||
const current = project.ollama_config ?? defaultOllamaConfig;
|
const current = project.ollama_config ?? defaultOllamaConfig;
|
||||||
@@ -692,6 +705,28 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sandbox mode toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">Sandbox mode<Tooltip text="Enables Claude Code's bash sandbox (bubblewrap-based filesystem and network isolation). Triple-C is the source of truth for the on/off state — toggling this overrides any manual /sandbox configuration in the container's settings.json on next start. Uses enableWeakerNestedSandbox since the container runs without privileged user namespaces." /></label>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, sandbox_mode_enabled: !project.sandbox_mode_enabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update sandbox mode setting:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||||
|
project.sandbox_mode_enabled
|
||||||
|
? "bg-[var(--success)] text-white"
|
||||||
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project.sandbox_mode_enabled ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mission Control toggle */}
|
{/* Mission Control toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
|
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
|
||||||
@@ -953,6 +988,19 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Service tier */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Service Tier (optional)<Tooltip text="Sets ANTHROPIC_BEDROCK_SERVICE_TIER. Valid values are determined by AWS Bedrock (e.g. 'priority'). Leave blank for the account default." /></label>
|
||||||
|
<input
|
||||||
|
value={bedrockServiceTier}
|
||||||
|
onChange={(e) => setBedrockServiceTier(e.target.value)}
|
||||||
|
onBlur={handleBedrockServiceTierBlur}
|
||||||
|
placeholder="(default)"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
35
app/src/hooks/useInstallHelper.ts
Normal file
35
app/src/hooks/useInstallHelper.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { InstallOptions } from "../lib/types";
|
||||||
|
|
||||||
|
export function useInstallHelper() {
|
||||||
|
const [options, setOptions] = useState<InstallOptions | null>(null);
|
||||||
|
|
||||||
|
const loadOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const opts = await commands.detectInstallOptions();
|
||||||
|
setOptions(opts);
|
||||||
|
return opts;
|
||||||
|
} catch {
|
||||||
|
setOptions(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runInstall = useCallback(
|
||||||
|
async (onProgress?: (line: string) => void) => {
|
||||||
|
const unlisten = onProgress
|
||||||
|
? await listen<string>("docker-install-progress", (e) => onProgress(e.payload))
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
await commands.runDockerInstall();
|
||||||
|
} finally {
|
||||||
|
unlisten?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { options, loadOptions, runInstall };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -107,3 +107,8 @@ export const buildSttImage = () => invoke<void>("build_stt_image");
|
|||||||
export const pullSttImage = () => invoke<void>("pull_stt_image");
|
export const pullSttImage = () => invoke<void>("pull_stt_image");
|
||||||
export const transcribeAudio = (audioData: number[]) =>
|
export const transcribeAudio = (audioData: number[]) =>
|
||||||
invoke<string>("transcribe_audio", { audioData });
|
invoke<string>("transcribe_audio", { audioData });
|
||||||
|
|
||||||
|
// Docker install helper
|
||||||
|
export const detectInstallOptions = () =>
|
||||||
|
invoke<InstallOptions>("detect_install_options");
|
||||||
|
export const runDockerInstall = () => invoke<void>("run_docker_install");
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface Project {
|
|||||||
ollama_config: OllamaConfig | null;
|
ollama_config: OllamaConfig | null;
|
||||||
openai_compatible_config: OpenAiCompatibleConfig | null;
|
openai_compatible_config: OpenAiCompatibleConfig | null;
|
||||||
allow_docker_access: boolean;
|
allow_docker_access: boolean;
|
||||||
|
sandbox_mode_enabled: boolean;
|
||||||
mission_control_enabled: boolean;
|
mission_control_enabled: boolean;
|
||||||
full_permissions: boolean;
|
full_permissions: boolean;
|
||||||
ssh_key_path: string | null;
|
ssh_key_path: string | null;
|
||||||
@@ -61,6 +62,7 @@ export interface BedrockConfig {
|
|||||||
aws_bearer_token: string | null;
|
aws_bearer_token: string | null;
|
||||||
model_id: string | null;
|
model_id: string | null;
|
||||||
disable_prompt_caching: boolean;
|
disable_prompt_caching: boolean;
|
||||||
|
service_tier: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaConfig {
|
export interface OllamaConfig {
|
||||||
@@ -211,3 +213,14 @@ export interface FileEntry {
|
|||||||
modified: string;
|
modified: string;
|
||||||
permissions: string;
|
permissions: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstallOptions {
|
||||||
|
os: "linux" | "macos" | "windows" | "unknown";
|
||||||
|
product_name: string;
|
||||||
|
can_auto_install: boolean;
|
||||||
|
auto_install_method: string | null;
|
||||||
|
auto_install_blocker: string | null;
|
||||||
|
docs_url: string;
|
||||||
|
manual_steps: string[];
|
||||||
|
post_install_notes: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ RUN for i in 1 2 3 4 5; do \
|
|||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
cron \
|
cron \
|
||||||
|
bubblewrap \
|
||||||
|
socat \
|
||||||
&& 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
|
||||||
|
|||||||
Reference in New Issue
Block a user