Compare commits

..

1 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
25 changed files with 409 additions and 80 deletions

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

@@ -86,21 +86,21 @@ 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:** **Ollama:**
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 **Ollama**. 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. 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. 4. Start the container again.
**LiteLLM:** **LiteLLM:**
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 **LiteLLM**. 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. 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. 4. Start the container again.
@@ -361,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
@@ -390,7 +390,7 @@ Per-project settings always override these global defaults.
## Ollama Configuration ## Ollama Configuration
To use Claude Code with a local or remote Ollama server, switch the auth mode to **Ollama** on the project card. To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
### Settings ### Settings
@@ -407,7 +407,7 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
## LiteLLM Configuration ## LiteLLM Configuration
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the auth mode to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy. 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 ### Settings

View File

@@ -102,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 |
@@ -122,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

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);
} }
} }

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

@@ -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

@@ -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,12 +446,12 @@ 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>
<select <select
value={project.auth_mode} value={project.backend}
onChange={(e) => { e.stopPropagation(); handleAuthModeChange(e.target.value as AuthMode); }} onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={!isStopped} disabled={!isStopped}
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" 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"
@@ -459,7 +459,7 @@ export default function ProjectCard({ project }: Props) {
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="bedrock">Bedrock</option> <option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option> <option value="ollama">Ollama</option>
<option value="lit_llm">LiteLLM</option> <option value="lite_llm">LiteLLM</option>
</select> </select>
</div> </div>
@@ -794,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 (
@@ -916,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)]">
@@ -956,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 }),
})); }));