Compare commits

..

6 Commits

Author SHA1 Message Date
38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
beae0942a1 Add dynamic versioning with VERSION file and tag-based patch counting
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 6m14s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 10s
- Create VERSION file (currently `0.2`) as the single source of truth for major.minor
- Add compute-version job that reads VERSION and counts commits since the last
  matching v{major.minor}.N tag to derive the patch number
- Patch resets automatically when VERSION is bumped (no matching tags exist yet)
- Add create-tag job that tags the repo after all platform builds succeed,
  so subsequent builds count from the new tag
- All platform jobs now consume the shared version instead of computing their own
- VERSION file changes trigger the build workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:11:04 -07:00
6b49981b3a Update build workflow version from 0.1.x to 0.2.x
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m19s
Build App / build-linux (push) Successful in 6m37s
Build App / sync-to-github (push) Successful in 13s
The computed build version was hardcoded as 0.1.${COMMIT_COUNT} across all
three platform jobs (Linux, macOS, Windows), producing 0.1.x releases even
though the source files were bumped to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:03:53 -07:00
b46b392a9a Update package-lock.json version to 0.2.0
All checks were successful
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m2s
Build App / build-macos (push) Successful in 2m32s
Build App / sync-to-github (push) Successful in 13s
The lock file was still at 0.1.0 after the v0.2.0 version bump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:23:08 -07:00
4889dd974f Change auth mode and Bedrock method selectors from button lists to dropdowns
Some checks failed
Build App / build-macos (push) Failing after 1s
Build App / build-windows (push) Successful in 4m4s
Build App / sync-to-github (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
The horizontal button lists overflowed the viewable area of the card. Replace
with <select> dropdowns for both the main auth mode and the Bedrock sub-method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:18:47 -07:00
b6fd8a557e Clean up compiler warnings and document Ollama/LiteLLM backends
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 4m36s
Build App / sync-to-github (push) Successful in 10s
Remove unused `any_docker_mcp()` function, add `#[allow(unused_imports)]`
and `#[allow(dead_code)]` annotations to suppress false-positive warnings.
Update README.md and HOW-TO-USE.md with Ollama and LiteLLM auth backend
documentation including best-effort compatibility notices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:23:50 -07:00
32 changed files with 632 additions and 172 deletions

View File

@@ -5,11 +5,13 @@ on:
branches: [main] branches: [main]
paths: paths:
- "app/**" - "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml" - ".gitea/workflows/build-app.yml"
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "app/**" - "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml" - ".gitea/workflows/build-app.yml"
workflow_dispatch: workflow_dispatch:
@@ -18,10 +20,43 @@ env:
REPO: ${{ gitea.repository }} REPO: ${{ gitea.repository }}
jobs: jobs:
build-linux: compute-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.version.outputs.VERSION }} version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute version from VERSION file and tags
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -54,17 +89,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version - name: Set app version
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ needs.compute-version.outputs.version }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -133,7 +160,7 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="v${{ steps.version.outputs.VERSION }}" TAG="v${{ needs.compute-version.outputs.version }}"
# Create release # Create release
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
@@ -156,6 +183,7 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version]
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -183,17 +211,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version - name: Set app version
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ needs.compute-version.outputs.version }}"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -243,12 +263,12 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="v${{ steps.version.outputs.VERSION }}-mac" TAG="v${{ needs.compute-version.outputs.version }}-mac"
# 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\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') 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}"
@@ -266,6 +286,7 @@ jobs:
build-windows: build-windows:
runs-on: windows-latest runs-on: windows-latest
needs: [compute-version]
defaults: defaults:
run: run:
shell: cmd shell: cmd
@@ -275,18 +296,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.1.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version - name: Set app version
shell: powershell shell: powershell
run: | run: |
$version = "${{ steps.version.outputs.VERSION }}" $version = "${{ needs.compute-version.outputs.version }}"
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json (Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json (Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml (Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
@@ -367,9 +380,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
set "TAG=v${{ steps.version.outputs.VERSION }}-win" set "TAG=v${{ needs.compute-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\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"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${{ needs.compute-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%
@@ -378,9 +391,36 @@ jobs:
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"
) )
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
echo "Tag ${TAG} created successfully"
sync-to-github: sync-to-github:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-linux, build-macos, build-windows] needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
env: env:
GH_PAT: ${{ secrets.GH_PAT }} GH_PAT: ${{ secrets.GH_PAT }}
@@ -389,7 +429,7 @@ jobs:
- name: Download artifacts from Gitea releases - name: Download artifacts from Gitea releases
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.build-linux.outputs.version }} VERSION: ${{ needs.compute-version.outputs.version }}
run: | run: |
set -e set -e
mkdir -p artifacts mkdir -p artifacts
@@ -418,7 +458,7 @@ jobs:
- name: Create GitHub release and upload artifacts - name: Create GitHub release and upload artifacts
env: env:
VERSION: ${{ needs.build-linux.outputs.version }} VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
set -e set -e

View File

@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming - `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
- `image.rs` — Image build/pull with progress streaming - `image.rs` — Image build/pull with progress streaming
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend. - **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `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` - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
### Container (`container/`) ### Container (`container/`)

View File

@@ -33,6 +33,8 @@ 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 - **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 - **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
--- ---
@@ -84,10 +86,24 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
**AWS Bedrock:** **AWS Bedrock:**
1. Stop the container first (settings can only be changed while stopped). 1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the auth mode to **Bedrock**. 2. In the project card, switch the backend to **Bedrock**.
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below). 3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
4. Start the container again. 4. Start the container again.
**Ollama:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **Ollama**.
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Optionally set a model ID.
4. Start the container again.
**LiteLLM:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **LiteLLM**.
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID.
4. Start the container again.
--- ---
## The Interface ## The Interface
@@ -345,7 +361,7 @@ MCP server configuration is tracked via SHA-256 fingerprints stored as Docker la
## AWS Bedrock Configuration ## AWS Bedrock Configuration
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card. To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card.
### Authentication Methods ### Authentication Methods
@@ -372,6 +388,41 @@ Per-project settings always override these global defaults.
--- ---
## Ollama Configuration
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
### Settings
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
- **Model ID** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
### How It Works
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
---
## LiteLLM Configuration
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
### Settings
- **Base URL** — The URL of your LiteLLM proxy. Defaults to `http://host.docker.internal:4000` for a locally running proxy.
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
- **Model ID** — Optional. Override the model to use.
### How It Works
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
> **Note:** LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the proxy.
---
## Settings ## Settings
Access global settings via the **Settings** tab in the sidebar. Access global settings via the **Settings** tab in the sidebar.

View File

@@ -49,6 +49,10 @@ Each project can independently use one of:
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets. - **Anthropic** (OAuth): 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). SSO sessions are validated before launching Claude for Profile auth. - **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
### Container Spawning (Sibling Containers) ### Container Spawning (Sibling Containers)
@@ -98,7 +102,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators | | `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/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts | | `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/ProjectCard.tsx` | Project config, backend selector, action buttons |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | | `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
@@ -118,7 +122,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | | `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | | `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | | `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, MCP servers, Mission Control) | | `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) |
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | | `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) | | `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) | | `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |

View File

@@ -290,7 +290,7 @@ triple-c/
│ ├── image.rs # Build from Dockerfile, pull from registry │ ├── image.rs # Build from Dockerfile, pull from registry
│ └── network.rs # Per-project bridge networks for MCP │ └── network.rs # Per-project bridge networks for MCP
├── models/ # Data structures ├── models/ # Data structures
│ ├── project.rs # Project, AuthMode, BedrockConfig │ ├── project.rs # Project, Backend, BedrockConfig
│ ├── mcp_server.rs # MCP server configuration │ ├── mcp_server.rs # MCP server configuration
│ ├── app_settings.rs # Global settings (image source, AWS, etc.) │ ├── app_settings.rs # Global settings (image source, AWS, etc.)
│ ├── container_config.rs # Image name resolution │ ├── container_config.rs # Image name resolution

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2

68
app/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "triple-c", "name": "triple-c",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "triple-c", "name": "triple-c",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
@@ -1643,6 +1643,70 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",

View File

@@ -1,7 +1,7 @@
use tauri::{Emitter, State}; use tauri::{Emitter, State};
use crate::docker; use crate::docker;
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus}; use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
@@ -179,27 +179,27 @@ pub async fn start_project_container(
// Resolve enabled MCP servers for this project // Resolve enabled MCP servers for this project
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
// Validate auth mode requirements // Validate backend requirements
if project.auth_mode == AuthMode::Bedrock { if project.backend == Backend::Bedrock {
let bedrock = project.bedrock_config.as_ref() let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?; .ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
// Region can come from per-project or global // Region can come from per-project or global
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() { if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string()); return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
} }
} }
if project.auth_mode == AuthMode::Ollama { if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref() let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama auth mode selected but no Ollama configuration found.".to_string())?; .ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() { if ollama.base_url.is_empty() {
return Err("Ollama base URL is required.".to_string()); return Err("Ollama base URL is required.".to_string());
} }
} }
if project.auth_mode == AuthMode::LiteLlm { if project.backend == Backend::LiteLlm {
let litellm = project.litellm_config.as_ref() let litellm = project.litellm_config.as_ref()
.ok_or_else(|| "LiteLLM auth mode selected but no LiteLLM configuration found.".to_string())?; .ok_or_else(|| "LiteLLM backend selected but no LiteLLM configuration found.".to_string())?;
if litellm.base_url.is_empty() { if litellm.base_url.is_empty() {
return Err("LiteLLM base URL is required.".to_string()); return Err("LiteLLM base URL is required.".to_string());
} }

View File

@@ -1,6 +1,6 @@
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::models::{AuthMode, BedrockAuthMethod, Project}; use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState; use crate::AppState;
/// Build the command to run in the container terminal. /// Build the command to run in the container terminal.
@@ -9,7 +9,7 @@ use crate::AppState;
/// the AWS session first. If the SSO session is expired, runs `aws sso login` /// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). /// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> { fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock let is_bedrock_profile = project.backend == Backend::Bedrock
&& project && project
.bedrock_config .bedrock_config
.as_ref() .as_ref()

View File

@@ -1,8 +1,16 @@
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo}; use tauri::State;
use crate::docker;
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState;
const RELEASES_URL: &str = const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases"; "https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
/// Gitea container-registry tag object (v2 manifest).
const REGISTRY_API_BASE: &str =
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox";
#[tauri::command] #[tauri::command]
pub fn get_app_version() -> String { pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string() env!("CARGO_PKG_VERSION").to_string()
@@ -115,3 +123,96 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
None None
} }
} }
/// Check whether a newer container image is available in the registry.
///
/// Compares the local image digest with the remote registry digest using the
/// Docker Registry HTTP API v2. Only applies when the image source is
/// "registry" (the default); for local builds or custom images we cannot
/// meaningfully check for remote updates.
#[tauri::command]
pub async fn check_image_update(
state: State<'_, AppState>,
) -> Result<Option<ImageUpdateInfo>, String> {
let settings = state.settings_store.get();
// Only check for registry images
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
return Ok(None);
}
let image_name =
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the Gitea container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare
let remote_digest = match remote_digest {
Some(d) => d,
None => return Ok(None),
};
// If local digest matches remote, no update
if let Some(ref local) = local_digest {
if *local == remote_digest {
return Ok(None);
}
}
// There's a difference (or no local image at all)
Ok(Some(ImageUpdateInfo {
remote_digest,
local_digest,
remote_updated_at: None,
}))
}
/// Fetch the digest of a tag from the Gitea container registry using the
/// OCI / Docker Registry HTTP API v2.
///
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the
/// `Docker-Content-Digest` header that the registry returns.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.head(&url)
.header(
"Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
)
.send()
.await;
match response {
Ok(resp) => {
if !resp.status().is_success() {
log::warn!(
"Registry returned status {} when checking image digest",
resp.status()
);
return Ok(None);
}
// The digest is returned in the Docker-Content-Digest header
if let Some(digest) = resp.headers().get("docker-content-digest") {
if let Ok(val) = digest.to_str() {
return Ok(Some(val.to_string()));
}
}
Ok(None)
}
Err(e) => {
log::warn!("Failed to check registry for image update: {}", e);
Ok(None)
}
}
}

View File

@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
use super::client::get_docker; use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -453,7 +453,7 @@ pub async fn create_container(
} }
// Bedrock configuration // Bedrock configuration
if project.auth_mode == AuthMode::Bedrock { if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config { if let Some(ref bedrock) = project.bedrock_config {
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string()); env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
@@ -506,7 +506,7 @@ pub async fn create_container(
} }
// Ollama configuration // Ollama configuration
if project.auth_mode == AuthMode::Ollama { if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config { if let Some(ref ollama) = project.ollama_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url)); env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string()); env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
@@ -517,7 +517,7 @@ pub async fn create_container(
} }
// LiteLLM configuration // LiteLLM configuration
if project.auth_mode == AuthMode::LiteLlm { if project.backend == Backend::LiteLlm {
if let Some(ref litellm) = project.litellm_config { if let Some(ref litellm) = project.litellm_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url)); env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
if let Some(ref key) = litellm.api_key { if let Some(ref key) = litellm.api_key {
@@ -624,7 +624,7 @@ pub async fn create_container(
// AWS config mount (read-only) // AWS config mount (read-only)
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set // Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
let should_mount_aws = if project.auth_mode == AuthMode::Bedrock { let should_mount_aws = if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config { if let Some(ref bedrock) = project.bedrock_config {
bedrock.auth_method == BedrockAuthMethod::Profile bedrock.auth_method == BedrockAuthMethod::Profile
} else { } else {
@@ -694,7 +694,7 @@ pub async fn create_container(
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.backend".to_string(), format!("{:?}", project.backend));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); 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.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project)); labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
@@ -897,11 +897,13 @@ pub async fn container_needs_recreation(
// Code settings stored in the named volume). The change takes effect // Code settings stored in the named volume). The change takes effect
// on the next explicit rebuild instead. // on the next explicit rebuild instead.
// ── Auth mode ──────────────────────────────────────────────────────── // ── Backend ──────────────────────────────────────────────────────────
let current_auth_mode = format!("{:?}", project.auth_mode); let current_backend = format!("{:?}", project.backend);
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") { // Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
if container_auth_mode != current_auth_mode { let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode); if let Some(container_backend) = container_backend {
if container_backend != current_backend {
log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend);
return Ok(true); return Ok(true);
} }
} }
@@ -1143,11 +1145,6 @@ pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool {
servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio) 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. /// Find an existing MCP container by its expected name.
pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> { pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> {
let docker = get_docker()?; let docker = get_docker()?;

View File

@@ -22,6 +22,7 @@ impl ExecSession {
.map_err(|e| format!("Failed to send input: {}", e)) .map_err(|e| format!("Failed to send input: {}", e))
} }
#[allow(dead_code)]
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> { pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?; let docker = get_docker()?;
docker docker

View File

@@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
Ok(!images.is_empty()) Ok(!images.is_empty())
} }
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
/// or None if the image doesn't exist locally or has no repo digests.
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec![image_name.to_string()],
)]);
let images: Vec<ImageSummary> = docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list images: {}", e))?;
if let Some(img) = images.first() {
// RepoDigests contains entries like "registry/repo@sha256:abc..."
if let Some(digest_str) = img.repo_digests.first() {
// Extract the sha256:... part after '@'
if let Some(pos) = digest_str.find('@') {
return Ok(Some(digest_str[pos + 1..].to_string()));
}
return Ok(Some(digest_str.clone()));
}
}
Ok(None)
}
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String> pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
where where
F: Fn(String) + Send + 'static, F: Fn(String) + Send + 'static,

View File

@@ -4,8 +4,13 @@ pub mod image;
pub mod exec; pub mod exec;
pub mod network; pub mod network;
#[allow(unused_imports)]
pub use client::*; pub use client::*;
#[allow(unused_imports)]
pub use container::*; pub use container::*;
#[allow(unused_imports)]
pub use image::*; pub use image::*;
#[allow(unused_imports)]
pub use exec::*; pub use exec::*;
#[allow(unused_imports)]
pub use network::*; pub use network::*;

View File

@@ -48,6 +48,7 @@ pub async fn ensure_project_network(project_id: &str) -> Result<String, String>
} }
/// Connect a container to the project network. /// Connect a container to the project network.
#[allow(dead_code)]
pub async fn connect_container_to_network( pub async fn connect_container_to_network(
container_id: &str, container_id: &str,
network_name: &str, network_name: &str,

View File

@@ -119,6 +119,7 @@ pub fn run() {
// Updates // Updates
commands::update_commands::get_app_version, commands::update_commands::get_app_version,
commands::update_commands::check_for_updates, commands::update_commands::check_for_updates,
commands::update_commands::check_image_update,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -72,6 +72,8 @@ pub struct AppSettings {
pub timezone: Option<String>, pub timezone: Option<String>,
#[serde(default)] #[serde(default)]
pub default_microphone: Option<String>, pub default_microphone: Option<String>,
#[serde(default)]
pub dismissed_image_digest: Option<String>,
} }
impl Default for AppSettings { impl Default for AppSettings {
@@ -90,6 +92,7 @@ impl Default for AppSettings {
dismissed_update_version: None, dismissed_update_version: None,
timezone: None, timezone: None,
default_microphone: None, default_microphone: None,
dismissed_image_digest: None,
} }
} }
} }

View File

@@ -31,7 +31,8 @@ pub struct Project {
pub paths: Vec<ProjectPath>, pub paths: Vec<ProjectPath>,
pub container_id: Option<String>, pub container_id: Option<String>,
pub status: ProjectStatus, pub status: ProjectStatus,
pub auth_mode: AuthMode, #[serde(alias = "auth_mode")]
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>, pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>, pub ollama_config: Option<OllamaConfig>,
pub litellm_config: Option<LiteLlmConfig>, pub litellm_config: Option<LiteLlmConfig>,
@@ -65,13 +66,14 @@ pub enum ProjectStatus {
Error, Error,
} }
/// How the project authenticates with Claude. /// Which AI model backend/provider the project uses.
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console, /// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// persisted in the config volume) /// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials /// - `Ollama`: Local or remote Ollama server
/// - `LiteLlm`: LiteLLM proxy gateway for 100+ model providers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AuthMode { pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic. /// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")] #[serde(alias = "login", alias = "api_key")]
Anthropic, Anthropic,
@@ -81,7 +83,7 @@ pub enum AuthMode {
LiteLlm, LiteLlm,
} }
impl Default for AuthMode { impl Default for Backend {
fn default() -> Self { fn default() -> Self {
Self::Anthropic Self::Anthropic
} }
@@ -152,7 +154,7 @@ impl Project {
paths, paths,
container_id: None, container_id: None,
status: ProjectStatus::Stopped, status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(), backend: Backend::default(),
bedrock_config: None, bedrock_config: None,
ollama_config: None, ollama_config: None,
litellm_config: None, litellm_config: None,

View File

@@ -35,3 +35,14 @@ pub struct GiteaAsset {
pub browser_download_url: String, pub browser_download_url: String,
pub size: u64, pub size: u64,
} }
/// Info returned to the frontend about an available container image update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUpdateInfo {
/// The remote digest (e.g. sha256:abc...)
pub remote_digest: String,
/// The local digest, if available
pub local_digest: Option<String>,
/// When the remote image was last updated (if known)
pub remote_updated_at: Option<String>,
}

View File

@@ -3,7 +3,11 @@ pub mod secure;
pub mod settings_store; pub mod settings_store;
pub mod mcp_store; pub mod mcp_store;
#[allow(unused_imports)]
pub use projects_store::*; pub use projects_store::*;
#[allow(unused_imports)]
pub use secure::*; pub use secure::*;
#[allow(unused_imports)]
pub use settings_store::*; pub use settings_store::*;
#[allow(unused_imports)]
pub use mcp_store::*; pub use mcp_store::*;

View File

@@ -17,7 +17,7 @@ export default function App() {
const { loadSettings } = useSettings(); const { loadSettings } = useSettings();
const { refresh } = useProjects(); const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers(); const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
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 }))
); );
@@ -46,7 +46,10 @@ export default function App() {
// Update detection // Update detection
loadVersion(); loadVersion();
const updateTimer = setTimeout(() => checkForUpdates(), 3000); const updateTimer = setTimeout(() => {
checkForUpdates();
checkImageUpdate();
}, 3000);
const cleanup = startPeriodicCheck(); const cleanup = startPeriodicCheck();
return () => { return () => {
clearTimeout(updateTimer); clearTimeout(updateTimer);

View File

@@ -4,19 +4,23 @@ import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings"; import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog"; import UpdateDialog from "../settings/UpdateDialog";
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
export default function TopBar() { export default function TopBar() {
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState( const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
useShallow(s => ({ useShallow(s => ({
dockerAvailable: s.dockerAvailable, dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists, imageExists: s.imageExists,
updateInfo: s.updateInfo, updateInfo: s.updateInfo,
imageUpdateInfo: s.imageUpdateInfo,
appVersion: s.appVersion, appVersion: s.appVersion,
setUpdateInfo: s.setUpdateInfo, setUpdateInfo: s.setUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
})) }))
); );
const { appSettings, saveSettings } = useSettings(); const { appSettings, saveSettings } = useSettings();
const [showUpdateDialog, setShowUpdateDialog] = useState(false); const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
const handleDismiss = async () => { const handleDismiss = async () => {
if (appSettings && updateInfo) { if (appSettings && updateInfo) {
@@ -29,6 +33,17 @@ export default function TopBar() {
setShowUpdateDialog(false); setShowUpdateDialog(false);
}; };
const handleImageUpdateDismiss = async () => {
if (appSettings && imageUpdateInfo) {
await saveSettings({
...appSettings,
dismissed_image_digest: imageUpdateInfo.remote_digest,
});
}
setImageUpdateInfo(null);
setShowImageUpdateDialog(false);
};
return ( return (
<> <>
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden"> <div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
@@ -44,6 +59,15 @@ export default function TopBar() {
Update Update
</button> </button>
)} )}
{imageUpdateInfo && (
<button
onClick={() => setShowImageUpdateDialog(true)}
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
title="A newer container image is available"
>
Image Update
</button>
)}
<StatusDot ok={dockerAvailable === true} label="Docker" /> <StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" /> <StatusDot ok={imageExists === true} label="Image" />
</div> </div>
@@ -56,6 +80,13 @@ export default function TopBar() {
onClose={() => setShowUpdateDialog(false)} onClose={() => setShowUpdateDialog(false)}
/> />
)} )}
{showImageUpdateDialog && imageUpdateInfo && (
<ImageUpdateDialog
imageUpdateInfo={imageUpdateInfo}
onDismiss={handleImageUpdateDismiss}
onClose={() => setShowImageUpdateDialog(false)}
/>
)}
</> </>
); );
} }

View File

@@ -57,7 +57,7 @@ const mockProject: Project = {
paths: [{ host_path: "/home/user/project", mount_name: "project" }], paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null, container_id: null,
status: "stopped", status: "stopped",
auth_mode: "anthropic", backend: "anthropic",
bedrock_config: null, bedrock_config: null,
allow_docker_access: false, allow_docker_access: false,
ssh_key_path: null, ssh_key_path: null,

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useMcpServers } from "../../hooks/useMcpServers"; import { useMcpServers } from "../../hooks/useMcpServers";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
@@ -202,16 +202,16 @@ export default function ProjectCard({ project }: Props) {
model_id: null, model_id: null,
}; };
const handleAuthModeChange = async (mode: AuthMode) => { const handleBackendChange = async (mode: Backend) => {
try { try {
const updates: Partial<Project> = { auth_mode: mode }; const updates: Partial<Project> = { backend: mode };
if (mode === "bedrock" && !project.bedrock_config) { if (mode === "bedrock" && !project.bedrock_config) {
updates.bedrock_config = defaultBedrockConfig; updates.bedrock_config = defaultBedrockConfig;
} }
if (mode === "ollama" && !project.ollama_config) { if (mode === "ollama" && !project.ollama_config) {
updates.ollama_config = defaultOllamaConfig; updates.ollama_config = defaultOllamaConfig;
} }
if (mode === "lit_llm" && !project.litellm_config) { if (mode === "lite_llm" && !project.litellm_config) {
updates.litellm_config = defaultLiteLlmConfig; updates.litellm_config = defaultLiteLlmConfig;
} }
await update({ ...project, ...updates }); await update({ ...project, ...updates });
@@ -446,53 +446,21 @@ export default function ProjectCard({ project }: Props) {
{isSelected && ( {isSelected && (
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden"> <div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Auth mode selector */} {/* Backend 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">Backend:</span>
<button <select
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }} value={project.backend}
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
onClick={(e) => e.stopPropagation()}
disabled={!isStopped} disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${ className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
project.auth_mode === "anthropic"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
> >
Anthropic <option value="anthropic">Anthropic</option>
</button> <option value="bedrock">Bedrock</option>
<button <option value="ollama">Ollama</option>
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }} <option value="lite_llm">LiteLLM</option>
disabled={!isStopped} </select>
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "bedrock"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
Bedrock
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("ollama"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "ollama"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
Ollama
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("lit_llm"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "lit_llm"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
LiteLLM
</button>
</div> </div>
{/* Action buttons */} {/* Action buttons */}
@@ -826,7 +794,7 @@ export default function ProjectCard({ project }: Props) {
)} )}
{/* Bedrock config */} {/* Bedrock config */}
{project.auth_mode === "bedrock" && (() => { {project.backend === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig; const bc = project.bedrock_config ?? defaultBedrockConfig;
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)] disabled:opacity-50"; 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)] disabled:opacity-50";
return ( return (
@@ -836,20 +804,17 @@ export default function ProjectCard({ project }: Props) {
{/* Sub-method selector */} {/* Sub-method 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">Method:</span> <span className="text-[var(--text-secondary)] mr-1">Method:</span>
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => ( <select
<button value={bc.auth_method}
key={m} onChange={(e) => updateBedrockConfig({ auth_method: e.target.value as BedrockAuthMethod })}
onClick={() => updateBedrockConfig({ auth_method: m })} onClick={(e) => e.stopPropagation()}
disabled={!isStopped} disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${ className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
bc.auth_method === m
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
> >
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"} <option value="static_credentials">Keys</option>
</button> <option value="profile">Profile</option>
))} <option value="bearer_token">Token</option>
</select>
</div> </div>
{/* AWS Region (always shown) */} {/* AWS Region (always shown) */}
@@ -951,7 +916,7 @@ export default function ProjectCard({ project }: Props) {
})()} })()}
{/* Ollama config */} {/* Ollama config */}
{project.auth_mode === "ollama" && (() => { {project.backend === "ollama" && (() => {
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)] disabled:opacity-50"; 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)] disabled:opacity-50";
return ( return (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]"> <div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
@@ -991,7 +956,7 @@ export default function ProjectCard({ project }: Props) {
})()} })()}
{/* LiteLLM config */} {/* LiteLLM config */}
{project.auth_mode === "lit_llm" && (() => { {project.backend === "lite_llm" && (() => {
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)] disabled:opacity-50"; 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)] disabled:opacity-50";
return ( return (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]"> <div className="space-y-2 pt-1 border-t border-[var(--border-color)]">

View File

@@ -1,9 +1,9 @@
export default function ApiKeyInput() { export default function ApiKeyInput() {
return ( return (
<div> <div>
<label className="block text-sm font-medium mb-1">Authentication</label> <label className="block text-sm font-medium mb-1">Backend</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) 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 backend per-project.
</p> </p>
</div> </div>
); );

View File

@@ -121,9 +121,9 @@ export default function DockerSettings() {
)} )}
{/* Resolved image display */} {/* Resolved image display */}
<div className="flex items-center justify-between"> <div>
<span className="text-[var(--text-secondary)]">Image</span> <span className="text-[var(--text-secondary)]">Image</span>
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}> <span className="block text-xs text-[var(--text-secondary)] font-mono mt-0.5 truncate" title={resolvedImageName}>
{resolvedImageName} {resolvedImageName}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useCallback } from "react";
import type { ImageUpdateInfo } from "../../lib/types";
interface Props {
imageUpdateInfo: ImageUpdateInfo;
onDismiss: () => void;
onClose: () => void;
}
export default function ImageUpdateDialog({
imageUpdateInfo,
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 shortDigest = (digest: string) => {
// Show first 16 chars of the hash part (after "sha256:")
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
return hash.slice(0, 16);
};
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">Container Image Update</h2>
<p className="text-sm text-[var(--text-secondary)] mb-4">
A newer version of the container image is available in the registry.
Re-pull the image in Docker settings to get the latest tools and fixes.
</p>
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
{imageUpdateInfo.local_digest && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Local digest</span>
<span className="font-mono text-[var(--text-primary)]">
{shortDigest(imageUpdateInfo.local_digest)}...
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Remote digest</span>
<span className="font-mono text-[var(--accent)]">
{shortDigest(imageUpdateInfo.remote_digest)}...
</span>
</div>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Go to Settings &gt; Docker and click &quot;Re-pull Image&quot; to update.
Running containers will not be affected until restarted.
</p>
<div className="flex items-center justify-end 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>
);
}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
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 { useSettings } from "../../hooks/useSettings";
@@ -11,7 +10,7 @@ import type { EnvVar } from "../../lib/types";
export default function SettingsPanel() { export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings(); const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates(); const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []); const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false); const [checkingUpdates, setCheckingUpdates] = useState(false);
@@ -39,7 +38,7 @@ export default function SettingsPanel() {
const handleCheckNow = async () => { const handleCheckNow = async () => {
setCheckingUpdates(true); setCheckingUpdates(true);
try { try {
await checkForUpdates(); await Promise.all([checkForUpdates(), checkImageUpdate()]);
} finally { } finally {
setCheckingUpdates(false); setCheckingUpdates(false);
} }
@@ -55,7 +54,6 @@ export default function SettingsPanel() {
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]"> <h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Settings Settings
</h2> </h2>
<ApiKeyInput />
<DockerSettings /> <DockerSettings />
<AwsSettings /> <AwsSettings />
@@ -146,6 +144,12 @@ export default function SettingsPanel() {
> >
{checkingUpdates ? "Checking..." : "Check now"} {checkingUpdates ? "Checking..." : "Check now"}
</button> </button>
{imageUpdateInfo && (
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
<span>A newer container image is available. Re-pull the image in Docker settings above to update.</span>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -6,11 +6,20 @@ import * as commands from "../lib/tauri-commands";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export function useUpdates() { export function useUpdates() {
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } = const {
useAppState( updateInfo,
setUpdateInfo,
imageUpdateInfo,
setImageUpdateInfo,
appVersion,
setAppVersion,
appSettings,
} = useAppState(
useShallow((s) => ({ useShallow((s) => ({
updateInfo: s.updateInfo, updateInfo: s.updateInfo,
setUpdateInfo: s.setUpdateInfo, setUpdateInfo: s.setUpdateInfo,
imageUpdateInfo: s.imageUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
appVersion: s.appVersion, appVersion: s.appVersion,
setAppVersion: s.setAppVersion, setAppVersion: s.setAppVersion,
appSettings: s.appSettings, appSettings: s.appSettings,
@@ -47,11 +56,31 @@ export function useUpdates() {
} }
}, [setUpdateInfo, appSettings?.dismissed_update_version]); }, [setUpdateInfo, appSettings?.dismissed_update_version]);
const checkImageUpdate = useCallback(async () => {
try {
const info = await commands.checkImageUpdate();
if (info) {
// Respect dismissed image digest
const dismissed = appSettings?.dismissed_image_digest;
if (dismissed && dismissed === info.remote_digest) {
setImageUpdateInfo(null);
return null;
}
}
setImageUpdateInfo(info);
return info;
} catch (e) {
console.error("Failed to check for image updates:", e);
return null;
}
}, [setImageUpdateInfo, appSettings?.dismissed_image_digest]);
const startPeriodicCheck = useCallback(() => { const startPeriodicCheck = useCallback(() => {
if (intervalRef.current) return; if (intervalRef.current) return;
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
if (appSettings?.auto_check_updates !== false) { if (appSettings?.auto_check_updates !== false) {
checkForUpdates(); checkForUpdates();
checkImageUpdate();
} }
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
return () => { return () => {
@@ -60,13 +89,15 @@ export function useUpdates() {
intervalRef.current = null; intervalRef.current = null;
} }
}; };
}, [checkForUpdates, appSettings?.auto_check_updates]); }, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]);
return { return {
updateInfo, updateInfo,
imageUpdateInfo,
appVersion, appVersion,
loadVersion, loadVersion,
checkForUpdates, checkForUpdates,
checkImageUpdate,
startPeriodicCheck, startPeriodicCheck,
}; };
} }

View File

@@ -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, McpServer, FileEntry } from "./types"; import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -83,3 +83,5 @@ export const uploadFileToContainer = (projectId: string, hostPath: string, conta
export const getAppVersion = () => invoke<string>("get_app_version"); export const getAppVersion = () => invoke<string>("get_app_version");
export const checkForUpdates = () => export const checkForUpdates = () =>
invoke<UpdateInfo | null>("check_for_updates"); invoke<UpdateInfo | null>("check_for_updates");
export const checkImageUpdate = () =>
invoke<ImageUpdateInfo | null>("check_image_update");

View File

@@ -20,7 +20,7 @@ export interface Project {
paths: ProjectPath[]; paths: ProjectPath[];
container_id: string | null; container_id: string | null;
status: ProjectStatus; status: ProjectStatus;
auth_mode: AuthMode; backend: Backend;
bedrock_config: BedrockConfig | null; bedrock_config: BedrockConfig | null;
ollama_config: OllamaConfig | null; ollama_config: OllamaConfig | null;
litellm_config: LiteLlmConfig | null; litellm_config: LiteLlmConfig | null;
@@ -45,7 +45,7 @@ export type ProjectStatus =
| "stopping" | "stopping"
| "error"; | "error";
export type AuthMode = "anthropic" | "bedrock" | "ollama" | "lit_llm"; export type Backend = "anthropic" | "bedrock" | "ollama" | "lite_llm";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -116,6 +116,7 @@ export interface AppSettings {
dismissed_update_version: string | null; dismissed_update_version: string | null;
timezone: string | null; timezone: string | null;
default_microphone: string | null; default_microphone: string | null;
dismissed_image_digest: string | null;
} }
export interface UpdateInfo { export interface UpdateInfo {
@@ -133,6 +134,12 @@ export interface ReleaseAsset {
size: number; size: number;
} }
export interface ImageUpdateInfo {
remote_digest: string;
local_digest: string | null;
remote_updated_at: string | null;
}
export type McpTransportType = "stdio" | "http"; export type McpTransportType = "stdio" | "http";
export interface McpServer { export interface McpServer {

View File

@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types"; import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
interface AppState { interface AppState {
// Projects // Projects
@@ -39,6 +39,10 @@ interface AppState {
setUpdateInfo: (info: UpdateInfo | null) => void; setUpdateInfo: (info: UpdateInfo | null) => void;
appVersion: string; appVersion: string;
setAppVersion: (version: string) => void; setAppVersion: (version: string) => void;
// Image update info
imageUpdateInfo: ImageUpdateInfo | null;
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
} }
export const useAppState = create<AppState>((set) => ({ export const useAppState = create<AppState>((set) => ({
@@ -111,4 +115,8 @@ export const useAppState = create<AppState>((set) => ({
setUpdateInfo: (info) => set({ updateInfo: info }), setUpdateInfo: (info) => set({ updateInfo: info }),
appVersion: "", appVersion: "",
setAppVersion: (version) => set({ appVersion: version }), setAppVersion: (version) => set({ appVersion: version }),
// Image update info
imageUpdateInfo: null,
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
})); }));