Initial commit: Triple-C app, container, and CI

Tauri v2 desktop app (React/TypeScript + Rust) for managing
containerized Claude Code environments. Includes Gitea Actions
workflow for building and pushing the sandbox container image,
and a BUILDING.md guide for manual app builds on Linux and Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 04:29:51 +00:00
commit 97a0745ead
65 changed files with 17202 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
use tauri::State;
use crate::docker;
use crate::models::ContainerInfo;
use crate::AppState;
#[tauri::command]
pub async fn check_docker() -> Result<bool, String> {
docker::check_docker_available().await
}
#[tauri::command]
pub async fn check_image_exists() -> Result<bool, String> {
docker::image_exists().await
}
#[tauri::command]
pub async fn build_image(app_handle: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
docker::build_image(move |msg| {
let _ = app_handle.emit("image-build-progress", msg);
})
.await
}
#[tauri::command]
pub async fn get_container_info(
project_id: String,
state: State<'_, AppState>,
) -> Result<Option<ContainerInfo>, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
docker::get_container_info(&project).await
}
#[tauri::command]
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
let containers = docker::list_sibling_containers().await?;
let result: Vec<serde_json::Value> = containers
.into_iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"names": c.names,
"image": c.image,
"state": c.state,
"status": c.status,
})
})
.collect();
Ok(result)
}

View File

@@ -0,0 +1,4 @@
pub mod docker_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod terminal_commands;

View File

@@ -0,0 +1,157 @@
use tauri::State;
use crate::docker;
use crate::models::{AuthMode, Project, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list())
}
#[tauri::command]
pub async fn add_project(
name: String,
path: String,
state: State<'_, AppState>,
) -> Result<Project, String> {
let project = Project::new(name, path);
state.projects_store.add(project)
}
#[tauri::command]
pub async fn remove_project(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id {
let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await;
}
}
// Close any exec sessions
state.exec_manager.close_all_sessions().await;
state.projects_store.remove(&project_id)
}
#[tauri::command]
pub async fn update_project(
project: Project,
state: State<'_, AppState>,
) -> Result<Project, String> {
state.projects_store.update(project)
}
#[tauri::command]
pub async fn start_project_container(
project_id: String,
state: State<'_, AppState>,
) -> Result<Project, String> {
let mut project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// 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)
}
AuthMode::Login => {
// Login mode: no API key needed, user runs `claude login` in the container.
// Auth state persists in the .claude config volume.
None
}
};
// Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
// Ensure image exists
if !docker::image_exists().await? {
return Err("Docker image not built. Please build the image first.".to_string());
}
// Determine docker socket path
let docker_socket = default_docker_socket();
// Check for existing container
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Start existing container
docker::start_container(&existing_id).await?;
existing_id
} else {
// Create new container
let new_id = docker::create_container(&project, api_key.as_deref(), &docker_socket).await?;
docker::start_container(&new_id).await?;
new_id
};
// Update project with container info
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
project.container_id = Some(container_id);
project.status = ProjectStatus::Running;
Ok(project)
}
#[tauri::command]
pub async fn stop_project_container(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(ref container_id) = project.container_id {
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// Close exec sessions for this project
state.exec_manager.close_all_sessions().await;
docker::stop_container(container_id).await?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
}
Ok(())
}
#[tauri::command]
pub async fn rebuild_project_container(
project_id: String,
state: State<'_, AppState>,
) -> Result<Project, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// Remove existing container
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_all_sessions().await;
let _ = docker::stop_container(container_id).await;
docker::remove_container(container_id).await?;
state.projects_store.set_container_id(&project_id, None)?;
}
// Start fresh
start_project_container(project_id, state).await
}
fn default_docker_socket() -> String {
if cfg!(target_os = "windows") {
"//./pipe/docker_engine".to_string()
} else {
"/var/run/docker.sock".to_string()
}
}

View File

@@ -0,0 +1,16 @@
use crate::storage::secure;
#[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()
}

View File

@@ -0,0 +1,74 @@
use tauri::{AppHandle, Emitter, State};
use crate::AppState;
#[tauri::command]
pub async fn open_terminal_session(
project_id: String,
session_id: String,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
let output_event = format!("terminal-output-{}", session_id);
let exit_event = format!("terminal-exit-{}", session_id);
let app_handle_output = app_handle.clone();
let app_handle_exit = app_handle.clone();
state
.exec_manager
.create_session(
container_id,
&session_id,
cmd,
move |data| {
let _ = app_handle_output.emit(&output_event, data);
},
Box::new(move || {
let _ = app_handle_exit.emit(&exit_event, ());
}),
)
.await
}
#[tauri::command]
pub async fn terminal_input(
session_id: String,
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<(), String> {
state.exec_manager.send_input(&session_id, data).await
}
#[tauri::command]
pub async fn terminal_resize(
session_id: String,
cols: u16,
rows: u16,
state: State<'_, AppState>,
) -> Result<(), String> {
state.exec_manager.resize(&session_id, cols, rows).await
}
#[tauri::command]
pub async fn close_terminal_session(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.exec_manager.close_session(&session_id).await;
Ok(())
}