Compare commits

..

7 Commits

Author SHA1 Message Date
06be613e36 Add port mappings feature, update app icon, and enhance default instructions
All checks were successful
Build App / build-linux (push) Successful in 2m49s
Build App / build-windows (push) Successful in 4m57s
- Add per-project port mapping configuration (host:container port pairs with
  TCP/UDP protocol) stored in project config and applied as Docker port
  bindings at container creation. Port changes trigger automatic container
  recreation via fingerprint detection.
- Create PortMappingsModal UI component following the same pattern as
  EnvVarsModal, integrated into ProjectCard config panel.
- Inject port mapping details into CLAUDE_INSTRUCTIONS so Claude inside the
  container knows which ports are available for testing services.
- Update default global instructions for new installs to encourage use of
  subagents for long-running and parallel tasks.
- Replace app icons with new v2 sun logo design for better visibility at
  small sizes (taskbar/dock).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:36:51 +00:00
da078af73f Remove Anthropic API key authentication support
All checks were successful
Build App / build-windows (push) Successful in 2m28s
Build App / build-linux (push) Successful in 3m13s
API key auth only provides short-lived session tokens (8hrs or until
session restart) with no refresh mechanism, unlike OAuth which persists
via .credentials.json. Remove the non-functional API key settings UI
and all supporting code (frontend state, Tauri commands, keyring
storage, container env var injection, and fingerprint-based recreation
checks) to avoid user confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:59:58 +00:00
01ea581f8a Fix type inference error for api_key after removing ApiKey auth mode
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 2m44s
Both match arms now return None, so Rust needs an explicit type
annotation for the Option<String>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:15:50 +00:00
552aaebf16 Simplify auth modes to Anthropic and Bedrock, fix Windows taskbar icon
Some checks failed
Build App / build-linux (push) Failing after 1m40s
Build App / build-windows (push) Failing after 1m43s
Replace the three auth modes (Login, API Key, Bedrock) with two
(Anthropic, Bedrock). The Anthropic mode uses OAuth via `claude login`
inside the terminal, which generates and stores its own API key in the
persistent config volume. The separate API Key mode is removed because
Claude Code now requires interactive approval of externally-provided
keys, making the injected ANTHROPIC_API_KEY approach unreliable.

Old projects stored as "login" or "api_key" are automatically migrated
to "anthropic" via serde aliases.

Also fix the Windows taskbar icon showing as a black square by loading
icon.png instead of icon.ico for the runtime window icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:10:57 +00:00
c2736ace90 Fix API key changes not triggering container recreation
All checks were successful
Build App / build-linux (push) Successful in 2m45s
Build App / build-windows (push) Successful in 4m15s
The container was only recreated when the auth mode changed, not when
the API key value itself changed. This meant saving a new key required
a manual container rebuild. Now we store a hash of the API key as a
Docker label and compare it on start, so a key change automatically
recreates the container (preserving the claude config volume).

Also adds a note to the global AWS settings UI that changes require a
container rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:22:34 +00:00
2ff270ebfe Fix Windows crash from missing ICO decoder and add file logging
All checks were successful
Build App / build-linux (push) Successful in 3m7s
Build App / build-windows (push) Successful in 4m19s
The app crashed on startup because the image-ico Tauri feature was
missing, causing Image::from_bytes to panic when decoding icon.ico.
Added the feature flag and replaced env_logger with fern to log to both
stderr and <data_dir>/triple-c/logs/triple-c.log. A panic hook captures
crash details with backtraces. Store init and icon loading errors are now
logged before failing so future issues are diagnosable from the log file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:45:59 +00:00
5a59fdb64b Add UX enhancements: modals for env vars and instructions, global env vars, taskbar icon fix
All checks were successful
Build App / build-linux (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m5s
- Fix Windows taskbar icon by loading icon.ico instead of icon.png (ICO contains
  multiple sizes native to Windows taskbar/title bar/alt-tab)
- Add "Container must be stopped to change settings" warning banner in config panel
- Move per-project Environment Variables and Claude Instructions into modal dialogs
  for more editing space, with buttons in the config panel to open them
- Move global Claude Instructions into a modal in Settings panel
- Add default global Claude instruction recommending git initialization
- Add global environment variables support (full stack: Rust model, TS types,
  container creation with merge logic where project overrides global for same key,
  fingerprinting for recreation checks, and Settings UI with modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:21:33 +00:00
30 changed files with 827 additions and 464 deletions

View File

@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
Each project can independently use one of:
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
### Container Spawning (Sibling Containers)

208
app/src-tauri/Cargo.lock generated
View File

@@ -41,56 +41,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -404,6 +354,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -543,12 +499,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -938,29 +888,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1024,6 +951,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"chrono",
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1745,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -1862,6 +1799,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1929,12 +1879,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1964,30 +1908,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2265,6 +2185,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2280,7 +2210,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -2583,12 +2513,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -2872,6 +2796,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2886,21 +2823,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3009,6 +2931,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4076,6 +4007,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -4143,7 +4075,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4728,7 +4660,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -4741,7 +4673,7 @@ dependencies = [
"bollard",
"chrono",
"dirs",
"env_logger",
"fern",
"futures-util",
"keyring",
"log",
@@ -4889,12 +4821,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"

View File

@@ -12,7 +12,7 @@ name = "triple-c"
path = "src/main.rs"
[dependencies]
tauri = { version = "2", features = ["image-png"] }
tauri = { version = "2", features = ["image-png", "image-ico"] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
@@ -26,7 +26,7 @@ uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
log = "0.4"
env_logger = "0.11"
fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -124,26 +124,15 @@ pub async fn start_project_container(
let settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// Get API key only if auth mode requires it
let api_key = match project.auth_mode {
AuthMode::ApiKey => {
let key = secure::get_api_key()?
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
Some(key)
// Validate auth mode requirements
if project.auth_mode == AuthMode::Bedrock {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
// Region can come from per-project or global
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
}
AuthMode::Login => {
None
}
AuthMode::Bedrock => {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
// Region can come from per-project or global
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
}
None
}
};
}
// Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
@@ -170,6 +159,7 @@ pub async fn start_project_container(
&existing_id,
&project,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
)
.await
.unwrap_or(false);
@@ -179,12 +169,12 @@ pub async fn start_project_container(
docker::remove_container(&existing_id).await?;
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
).await?;
docker::start_container(&new_id).await?;
new_id
@@ -195,12 +185,12 @@ pub async fn start_project_container(
} else {
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
).await?;
docker::start_container(&new_id).await?;
new_id

View File

@@ -2,24 +2,8 @@ use tauri::State;
use crate::docker;
use crate::models::AppSettings;
use crate::storage::secure;
use crate::AppState;
#[tauri::command]
pub async fn set_api_key(key: String) -> Result<(), String> {
secure::store_api_key(&key)
}
#[tauri::command]
pub async fn has_api_key() -> Result<bool, String> {
secure::has_api_key()
}
#[tauri::command]
pub async fn delete_api_key() -> Result<(), String> {
secure::delete_api_key()
}
#[tauri::command]
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
Ok(state.settings_store.get())

View File

@@ -2,13 +2,13 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
/// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation.
@@ -30,6 +30,25 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
parts.join(",")
}
/// Merge global and per-project custom environment variables.
/// Per-project variables override global variables with the same key.
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
for ev in global {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
for ev in project {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
merged.into_values().collect()
}
/// Merge global and per-project Claude instructions into a single string.
fn merge_claude_instructions(
global_instructions: Option<&str>,
@@ -76,6 +95,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
format!("{:x}", hasher.finish())
}
/// Compute a fingerprint for port mappings so we can detect changes.
/// Sorted so order changes don't cause spurious recreation.
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
let mut parts: Vec<String> = port_mappings
.iter()
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?;
let container_name = project.container_name();
@@ -108,12 +141,12 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
pub async fn create_container(
project: &Project,
api_key: Option<&str>,
docker_socket_path: &str,
image_name: &str,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
@@ -156,10 +189,6 @@ pub async fn create_container(
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
}
if let Some(key) = api_key {
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
}
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
@@ -222,9 +251,10 @@ pub async fn create_container(
}
}
// Custom environment variables
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
for env_var in &project.custom_env_vars {
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
@@ -236,14 +266,30 @@ pub async fn create_container(
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Claude instructions (global + per-project)
let combined_instructions = merge_claude_instructions(
// Claude instructions (global + per-project, plus port mapping info)
let mut combined_instructions = merge_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
);
if !project.port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in &project.port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined_instructions = Some(match combined_instructions {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
@@ -330,6 +376,21 @@ pub async fn create_container(
});
}
// Port mappings
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
for pm in &project.port_mappings {
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
exposed_ports.insert(container_key.clone(), HashMap::new());
port_bindings.insert(
container_key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(pm.host_port.to_string()),
}]),
);
}
let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
@@ -337,10 +398,12 @@ pub async fn create_container(
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
let host_config = HostConfig {
mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
..Default::default()
};
@@ -357,6 +420,7 @@ pub async fn create_container(
labels: Some(labels),
working_dir: Some(working_dir),
host_config: Some(host_config),
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
tty: Some(true),
..Default::default()
};
@@ -419,6 +483,7 @@ pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
@@ -471,6 +536,14 @@ pub async fn container_needs_recreation(
}
}
// ── Port mappings fingerprint ──────────────────────────────────────────
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
if container_ports_fp != expected_ports_fp {
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
return Ok(true);
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
@@ -547,7 +620,8 @@ pub async fn container_needs_recreation(
}
// ── Custom environment variables ──────────────────────────────────────
let expected_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env);
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);

View File

@@ -1,5 +1,6 @@
mod commands;
mod docker;
mod logging;
mod models;
mod storage;
@@ -15,22 +16,42 @@ pub struct AppState {
}
pub fn run() {
env_logger::init();
logging::init();
let projects_store = match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
};
let settings_store = match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
};
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(AppState {
projects_store: ProjectsStore::new().expect("Failed to initialize projects store"),
settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
projects_store,
settings_store,
exec_manager: ExecSessionManager::new(),
})
.setup(|app| {
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png"))
.expect("Failed to load window icon");
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_icon(icon);
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_icon(icon);
}
}
Err(e) => {
log::error!("Failed to load window icon: {}", e);
}
}
Ok(())
})
@@ -58,9 +79,6 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
// Settings
commands::settings_commands::set_api_key,
commands::settings_commands::has_api_key,
commands::settings_commands::delete_api_key,
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
commands::settings_commands::pull_image,

View File

@@ -0,0 +1,73 @@
use std::fs;
use std::path::PathBuf;
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
fn log_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
}
/// Initialise logging to both stderr and a log file in the app data directory.
///
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
/// A panic hook is also installed so that unexpected crashes are captured in the
/// same log file before the process exits.
pub fn init() {
let log_file_path = log_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
let path = dir.join("triple-c.log");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()
.map(|file| (path, file))
});
let mut dispatch = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{} {} {}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.target(),
message
))
})
.level(log::LevelFilter::Info)
.chain(std::io::stderr());
if let Some((_path, file)) = &log_file_path {
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
}
if let Err(e) = dispatch.apply() {
eprintln!("Failed to initialise logger: {}", e);
}
// Install a panic hook that writes to the log file so crashes are captured.
let crash_log_dir = log_dir();
std::panic::set_hook(Box::new(move |info| {
let msg = format!(
"[{} PANIC] {}\nBacktrace:\n{:?}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
info,
std::backtrace::Backtrace::force_capture(),
);
eprintln!("{}", msg);
if let Some(ref dir) = crash_log_dir {
let crash_path = dir.join("triple-c.log");
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&crash_path)
.and_then(|mut f| {
use std::io::Write;
writeln!(f, "{}", msg)
});
}
}));
if let Some((ref path, _)) = log_file_path {
log::info!("Logging to {}", path.display());
}
}

View File

@@ -1,9 +1,15 @@
use serde::{Deserialize, Serialize};
use super::project::EnvVar;
fn default_true() -> bool {
true
}
fn default_global_instructions() -> Option<String> {
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ImageSource {
@@ -54,8 +60,10 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
pub global_custom_env_vars: Vec<EnvVar>,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
@@ -72,7 +80,8 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_claude_instructions: None,
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
dismissed_update_version: None,
}

View File

@@ -12,6 +12,18 @@ pub struct ProjectPath {
pub mount_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PortMapping {
pub host_port: u16,
pub container_port: u16,
#[serde(default = "default_protocol")]
pub protocol: String,
}
fn default_protocol() -> String {
"tcp".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -30,6 +42,8 @@ pub struct Project {
#[serde(default)]
pub custom_env_vars: Vec<EnvVar>,
#[serde(default)]
pub port_mappings: Vec<PortMapping>,
#[serde(default)]
pub claude_instructions: Option<String>,
pub created_at: String,
pub updated_at: String,
@@ -46,20 +60,21 @@ pub enum ProjectStatus {
}
/// How the project authenticates with Claude.
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
/// - `ApiKey`: Uses the API key stored in the OS keychain
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
/// persisted in the config volume)
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Login,
ApiKey,
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")]
Anthropic,
Bedrock,
}
impl Default for AuthMode {
fn default() -> Self {
Self::Login
Self::Anthropic
}
}
@@ -113,6 +128,7 @@ impl Project {
git_user_name: None,
git_user_email: None,
custom_env_vars: Vec::new(),
port_mappings: Vec::new(),
claude_instructions: None,
created_at: now.clone(),
updated_at: now,

View File

@@ -1,42 +1,3 @@
const SERVICE_NAME: &str = "triple-c";
const API_KEY_USER: &str = "anthropic-api-key";
pub fn store_api_key(key: &str) -> Result<(), String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(key)
.map_err(|e| format!("Failed to store API key: {}", e))
}
pub fn get_api_key() -> Result<Option<String>, String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.get_password() {
Ok(key) => Ok(Some(key)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
}
}
pub fn delete_api_key() -> Result<(), String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete API key: {}", e)),
}
}
pub fn has_api_key() -> Result<bool, String> {
match get_api_key() {
Ok(Some(_)) => Ok(true),
Ok(None) => Ok(false),
Err(e) => Err(e),
}
}
/// Store a per-project secret in the OS keychain.
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
let service = format!("triple-c-project-{}-{}", project_id, key_name);

View File

@@ -12,7 +12,7 @@ import { useAppState } from "./store/appState";
export default function App() {
const { checkDocker, checkImage } = useDocker();
const { checkApiKey, loadSettings } = useSettings();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId } = useAppState(
@@ -25,7 +25,6 @@ export default function App() {
checkDocker().then((available) => {
if (available) checkImage();
});
checkApiKey();
refresh();
// Update detection

View File

@@ -0,0 +1,80 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface Props {
instructions: string;
disabled: boolean;
onSave: (instructions: string) => Promise<void>;
onClose: () => void;
}
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
const [instructions, setInstructions] = useState(initial);
const overlayRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const handleBlur = async () => {
try { await onSave(instructions); } catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change Claude instructions.
</div>
)}
<textarea
ref={textareaRef}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={handleBlur}
placeholder="Enter instructions for Claude Code in this project's container..."
disabled={disabled}
rows={14}
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
/>
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { EnvVar } from "../../lib/types";
interface Props {
envVars: EnvVar[];
disabled: boolean;
onSave: (vars: EnvVar[]) => Promise<void>;
onClose: () => void;
}
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
const [vars, setVars] = useState<EnvVar[]>(initial);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
const updated = [...vars];
updated[index] = { ...updated[index], [field]: value };
setVars(updated);
};
const removeVar = async (index: number) => {
const updated = vars.filter((_, i) => i !== index);
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
};
const addVar = async () => {
const updated = [...vars, { key: "", value: "" }];
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add environment variable:", err);
}
};
const handleBlur = async () => {
try { await onSave(vars); } catch (err) {
console.error("Failed to update environment variables:", err);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change environment variables.
</div>
)}
<div className="space-y-2 mb-4">
{vars.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
)}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={handleBlur}
placeholder="KEY"
disabled={disabled}
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={handleBlur}
placeholder="value"
disabled={disabled}
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<button
onClick={() => removeVar(i)}
disabled={disabled}
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addVar}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add variable
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { PortMapping } from "../../lib/types";
interface Props {
portMappings: PortMapping[];
disabled: boolean;
onSave: (mappings: PortMapping[]) => Promise<void>;
onClose: () => void;
}
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
const [mappings, setMappings] = useState<PortMapping[]>(initial);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
const updated = [...mappings];
const num = parseInt(value, 10);
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
setMappings(updated);
};
const updateProtocol = (index: number, value: string) => {
const updated = [...mappings];
updated[index] = { ...updated[index], protocol: value };
setMappings(updated);
};
const removeMapping = async (index: number) => {
const updated = mappings.filter((_, i) => i !== index);
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove port mapping:", err);
}
};
const addMapping = async () => {
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add port mapping:", err);
}
};
const handleBlur = async () => {
try { await onSave(mappings); } catch (err) {
console.error("Failed to update port mappings:", err);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Map host ports to container ports. Services can be started after the container is running.
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change port mappings.
</div>
)}
<div className="space-y-2 mb-4">
{mappings.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
)}
{mappings.length > 0 && (
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
<span className="w-[30%]">Host Port</span>
<span className="w-[30%]">Container Port</span>
<span className="w-[25%]">Protocol</span>
<span className="w-[15%]" />
</div>
)}
{mappings.map((pm, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="number"
min="1"
max="65535"
value={pm.host_port || ""}
onChange={(e) => updatePort(i, "host_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
type="number"
min="1"
max="65535"
value={pm.container_port || ""}
onChange={(e) => updatePort(i, "container_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<select
value={pm.protocol}
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
disabled={disabled}
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button
onClick={() => removeMapping(i)}
disabled={disabled}
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addMapping}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add port mapping
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -4,6 +4,9 @@ import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod }
import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
interface Props {
project: Project;
@@ -17,6 +20,9 @@ export default function ProjectCard({ project }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error";
@@ -28,6 +34,7 @@ export default function ProjectCard({ project }: Props) {
const [gitToken, setGitToken] = useState(project.git_token ?? "");
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
// Bedrock local state for text fields
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
@@ -47,6 +54,7 @@ export default function ProjectCard({ project }: Props) {
setGitToken(project.git_token ?? "");
setClaudeInstructions(project.claude_instructions ?? "");
setEnvVars(project.custom_env_vars ?? []);
setPortMappings(project.port_mappings ?? []);
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
@@ -165,22 +173,6 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleClaudeInstructionsBlur = async () => {
try {
await update({ ...project, claude_instructions: claudeInstructions || null });
} catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
const handleEnvVarBlur = async () => {
try {
await update({ ...project, custom_env_vars: envVars });
} catch (err) {
console.error("Failed to update environment variables:", err);
}
};
const handleBedrockRegionBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
@@ -279,26 +271,15 @@ export default function ProjectCard({ project }: Props) {
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "login"
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`}
>
/login
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "api_key"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
API key
Anthropic
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
@@ -358,6 +339,11 @@ export default function ProjectCard({ project }: Props) {
{/* Config panel */}
{showConfig && (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{!isStopped && (
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change settings.
</div>
)}
{/* Folder paths */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
@@ -530,76 +516,42 @@ export default function ProjectCard({ project }: Props) {
</div>
{/* Environment Variables */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
{envVars.map((ev, i) => (
<div key={i} className="flex gap-1 mb-1">
<input
value={ev.key}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], key: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="KEY"
disabled={!isStopped}
className="w-1/3 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
value={ev.value}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], value: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="value"
disabled={!isStopped}
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<button
onClick={async () => {
const vars = envVars.filter((_, j) => j !== i);
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
</label>
<button
onClick={async () => {
const vars = [...envVars, { key: "", value: "" }];
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to add environment variable:", err);
}
}}
disabled={!isStopped}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
+ Add variable
Edit
</button>
</div>
{/* Port Mappings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
</label>
<button
onClick={() => setShowPortMappingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Claude Instructions */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
<textarea
value={claudeInstructions}
onChange={(e) => setClaudeInstructions(e.target.value)}
onBlur={handleClaudeInstructionsBlur}
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
disabled={!isStopped}
rows={3}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
/>
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Instructions{claudeInstructions ? " (set)" : ""}
</label>
<button
onClick={() => setShowClaudeInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Bedrock config */}
@@ -734,6 +686,42 @@ export default function ProjectCard({ project }: Props) {
{error && (
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={envVars}
disabled={!isStopped}
onSave={async (vars) => {
setEnvVars(vars);
await update({ ...project, custom_env_vars: vars });
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
{showPortMappingsModal && (
<PortMappingsModal
portMappings={portMappings}
disabled={!isStopped}
onSave={async (mappings) => {
setPortMappings(mappings);
await update({ ...project, port_mappings: mappings });
}}
onClose={() => setShowPortMappingsModal(false)}
/>
)}
{showClaudeInstructionsModal && (
<ClaudeInstructionsModal
instructions={claudeInstructions}
disabled={!isStopped}
onSave={async (instructions) => {
setClaudeInstructions(instructions);
await update({ ...project, claude_instructions: instructions || null });
}}
onClose={() => setShowClaudeInstructionsModal(false)}
/>
)}
</div>
);
}

View File

@@ -1,68 +1,10 @@
import { useState } from "react";
import { useSettings } from "../../hooks/useSettings";
export default function ApiKeyInput() {
const { hasKey, saveApiKey, removeApiKey } = useSettings();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!key.trim()) return;
setSaving(true);
setError(null);
try {
await saveApiKey(key.trim());
setKey("");
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">Authentication</label>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
</p>
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
API Key (for projects using API key mode)
</label>
{hasKey ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--success)]">Key configured</span>
<button
onClick={async () => {
try { await removeApiKey(); } catch (e) { setError(String(e)); }
}}
className="text-xs text-[var(--error)] hover:underline"
>
Remove
</button>
</div>
) : (
<div className="space-y-2">
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<button
onClick={handleSave}
disabled={saving || !key.trim()}
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{saving ? "Saving..." : "Save Key"}
</button>
</div>
)}
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
</div>
);
}

View File

@@ -51,6 +51,7 @@ export default function AwsSettings() {
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global AWS defaults for Bedrock projects. Per-project settings override these.
Changes here require a container rebuild to take effect.
</p>
{/* AWS Config Path */}

View File

@@ -4,22 +4,24 @@ import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import EnvVarsModal from "../projects/EnvVarsModal";
import type { EnvVar } from "../../lib/types";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
}, [appSettings?.global_claude_instructions]);
const handleInstructionsBlur = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
};
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
const handleCheckNow = async () => {
setCheckingUpdates(true);
@@ -43,19 +45,43 @@ export default function SettingsPanel() {
<ApiKeyInput />
<DockerSettings />
<AwsSettings />
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p>
<textarea
value={globalInstructions}
onChange={(e) => setGlobalInstructions(e.target.value)}
onBlur={handleInstructionsBlur}
placeholder="Instructions for Claude Code in all project containers..."
rows={4}
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalInstructions ? "Configured" : "Not set"}
</span>
<button
onClick={() => setShowInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Global Environment Variables */}
<div>
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Applied to all project containers. Per-project variables override global ones with the same key.
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
</span>
<button
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Updates section */}
@@ -89,6 +115,34 @@ export default function SettingsPanel() {
</button>
</div>
</div>
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
disabled={false}
onSave={async (instructions) => {
setGlobalInstructions(instructions);
if (appSettings) {
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
}
}}
onClose={() => setShowInstructionsModal(false)}
/>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={globalEnvVars}
disabled={false}
onSave={async (vars) => {
setGlobalEnvVars(vars);
if (appSettings) {
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
}
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
</div>
);
}

View File

@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
import type { AppSettings } from "../lib/types";
export function useSettings() {
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
const { appSettings, setAppSettings } = useAppState(
useShallow(s => ({
hasKey: s.hasKey,
setHasKey: s.setHasKey,
appSettings: s.appSettings,
setAppSettings: s.setAppSettings,
}))
);
const checkApiKey = useCallback(async () => {
try {
const has = await commands.hasApiKey();
setHasKey(has);
return has;
} catch {
setHasKey(false);
return false;
}
}, [setHasKey]);
const saveApiKey = useCallback(
async (key: string) => {
await commands.setApiKey(key);
setHasKey(true);
},
[setHasKey],
);
const removeApiKey = useCallback(async () => {
await commands.deleteApiKey();
setHasKey(false);
}, [setHasKey]);
const loadSettings = useCallback(async () => {
try {
const settings = await commands.getSettings();
@@ -59,10 +33,6 @@ export function useSettings() {
);
return {
hasKey,
checkApiKey,
saveApiKey,
removeApiKey,
appSettings,
loadSettings,
saveSettings,

View File

@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
invoke<Project>("rebuild_project_container", { projectId });
// Settings
export const setApiKey = (key: string) =>
invoke<void>("set_api_key", { key });
export const hasApiKey = () => invoke<boolean>("has_api_key");
export const deleteApiKey = () => invoke<void>("delete_api_key");
export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) =>
invoke<AppSettings>("update_settings", { settings });

View File

@@ -8,6 +8,12 @@ export interface ProjectPath {
mount_name: string;
}
export interface PortMapping {
host_port: number;
container_port: number;
protocol: string;
}
export interface Project {
id: string;
name: string;
@@ -22,6 +28,7 @@ export interface Project {
git_user_name: string | null;
git_user_email: string | null;
custom_env_vars: EnvVar[];
port_mappings: PortMapping[];
claude_instructions: string | null;
created_at: string;
updated_at: string;
@@ -34,7 +41,7 @@ export type ProjectStatus =
| "stopping"
| "error";
export type AuthMode = "login" | "api_key" | "bedrock";
export type AuthMode = "anthropic" | "bedrock";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -88,6 +95,7 @@ export interface AppSettings {
custom_image_name: string | null;
global_aws: GlobalAwsSettings;
global_claude_instructions: string | null;
global_custom_env_vars: EnvVar[];
auto_check_updates: boolean;
dismissed_update_version: string | null;
}

View File

@@ -24,9 +24,6 @@ interface AppState {
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
setImageExists: (exists: boolean | null) => void;
hasKey: boolean | null;
setHasKey: (has: boolean | null) => void;
// App settings
appSettings: AppSettings | null;
setAppSettings: (settings: AppSettings) => void;
@@ -85,9 +82,6 @@ export const useAppState = create<AppState>((set) => ({
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
setImageExists: (exists) => set({ imageExists: exists }),
hasKey: null,
setHasKey: (has) => set({ hasKey: has }),
// App settings
appSettings: null,
setAppSettings: (settings) => set({ appSettings: settings }),

BIN
triple-c-app-logov2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB