Add app update detection and multi-folder project support
All checks were successful
Build App / build-linux (push) Successful in 2m54s
Build App / build-windows (push) Successful in 4m18s
Build Container / build-container (push) Successful in 1m30s

Feature 1 - Update Detection: Query Gitea releases API on startup (3s
delay) and every 24h, compare patch versions by platform, show pulsing
"Update" button in TopBar with dialog for release notes/downloads.
Settings: auto-check toggle, manual check, dismiss per-version.

Feature 2 - Multi-Folder Projects: Replace single `path` with
`paths: Vec<ProjectPath>` (host_path + mount_name). Each folder mounts
to `/workspace/{mount_name}`. Auto-migrate old single-path JSON on load.
Container recreation via paths-fingerprint label. AddProjectDialog and
ProjectCard support add/remove/edit of multiple folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 21:18:33 +00:00
parent 854f59a95a
commit 7e1cc92aa4
23 changed files with 1163 additions and 98 deletions

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

@@ -523,6 +523,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -1333,8 +1339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1344,9 +1352,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1649,6 +1659,23 @@ dependencies = [
"winapi",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2153,6 +2180,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2985,6 +3018,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
@@ -3025,6 +3113,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3045,6 +3143,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3063,6 +3171,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3165,6 +3282,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3223,6 +3378,26 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3245,6 +3420,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3709,6 +3919,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3873,7 +4089,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -4258,6 +4474,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
@@ -4286,6 +4517,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4504,6 +4745,7 @@ dependencies = [
"futures-util",
"keyring",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
"tar",
@@ -4604,6 +4846,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4856,6 +5104,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@@ -4900,6 +5158,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5130,6 +5397,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View File

@@ -28,6 +28,7 @@ dirs = "6"
log = "0.4"
env_logger = "0.11"
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -2,3 +2,4 @@ pub mod docker_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod terminal_commands;
pub mod update_commands;

View File

@@ -1,7 +1,7 @@
use tauri::State;
use crate::docker;
use crate::models::{container_config, AuthMode, Project, ProjectStatus};
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
@@ -51,10 +51,26 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
#[tauri::command]
pub async fn add_project(
name: String,
path: String,
paths: Vec<ProjectPath>,
state: State<'_, AppState>,
) -> Result<Project, String> {
let project = Project::new(name, path);
// Validate paths
if paths.is_empty() {
return Err("At least one folder path is required.".to_string());
}
let mut seen_names = std::collections::HashSet::new();
for p in &paths {
if p.mount_name.is_empty() {
return Err("Mount name cannot be empty.".to_string());
}
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
}
if !seen_names.insert(p.mount_name.clone()) {
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
}
}
let project = Project::new(name, paths);
store_secrets_for_project(&project)?;
state.projects_store.add(project)
}

View File

@@ -0,0 +1,117 @@
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[tauri::command]
pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client
.get(RELEASES_URL)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let is_windows = cfg!(target_os = "windows");
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
.iter()
.filter(|r| {
if is_windows {
r.tag_name.ends_with("-win")
} else {
!r.tag_name.ends_with("-win")
}
})
.collect();
// Find the latest release with a higher patch version
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
let current_patch = parse_patch_version(current_version).unwrap_or(0);
let mut best: Option<(&GiteaRelease, u32)> = None;
for release in &platform_releases {
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
if patch > current_patch {
if best.is_none() || patch > best.unwrap().1 {
best = Some((release, patch));
}
}
}
}
match best {
Some((release, _)) => {
let assets = release
.assets
.iter()
.map(|a| ReleaseAsset {
name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(),
size: a.size,
})
.collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone());
Ok(Some(UpdateInfo {
version,
tag_name: release.tag_name.clone(),
release_url: release.html_url.clone(),
body: release.body.clone(),
assets,
published_at: release.published_at.clone(),
}))
}
None => Ok(None),
}
}
/// Parse patch version from a semver string like "0.1.5" -> 5
fn parse_patch_version(version: &str) -> Option<u32> {
let clean = version.trim_start_matches('v');
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 {
parts[2].parse().ok()
} else {
None
}
}
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win").unwrap_or(clean);
parse_patch_version(clean)
}
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let clean = tag.trim_start_matches('v');
let clean = clean.strip_suffix("-win").unwrap_or(clean);
// Validate it looks like a version
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
Some(clean.to_string())
} else {
None
}
}

View File

@@ -8,7 +8,7 @@ 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};
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
/// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation.
@@ -62,6 +62,20 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
}
}
/// Compute a fingerprint for the project paths so we can detect changes.
/// Sorted by mount_name so order changes don't cause spurious recreation.
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
let mut parts: Vec<String> = paths
.iter()
.map(|p| format!("{}:{}", p.mount_name, p.host_path))
.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();
@@ -231,24 +245,27 @@ pub async fn create_container(
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
let mut mounts = vec![
// Project directory -> /workspace
Mount {
target: Some("/workspace".to_string()),
source: Some(project.path.clone()),
let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name}
for pp in &project.paths {
mounts.push(Mount {
target: Some(format!("/workspace/{}", pp.mount_name)),
source: Some(pp.host_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
// Named volume for claude config persistence
Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
},
];
});
}
// Named volume for claude config persistence
mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
if let Some(ref ssh_path) = project.ssh_key_path {
@@ -315,7 +332,7 @@ pub async fn create_container(
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.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.project-path".to_string(), project.path.clone());
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.image".to_string(), image_name.to_string());
@@ -324,12 +341,18 @@ pub async fn create_container(
..Default::default()
};
let working_dir = if project.paths.len() == 1 {
format!("/workspace/{}", project.paths[0].mount_name)
} else {
"/workspace".to_string()
};
let config = Config {
image: Some(image_name.to_string()),
hostname: Some("triple-c".to_string()),
env: Some(env_vars),
labels: Some(labels),
working_dir: Some("/workspace".to_string()),
working_dir: Some(working_dir),
host_config: Some(host_config),
tty: Some(true),
..Default::default()
@@ -425,10 +448,18 @@ pub async fn container_needs_recreation(
}
}
// ── Project path ─────────────────────────────────────────────────────
if let Some(container_path) = get_label("triple-c.project-path") {
if container_path != project.path {
log::info!("Project path mismatch (container={:?}, project={:?})", container_path, project.path);
// ── Project paths fingerprint ──────────────────────────────────────────
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
match get_label("triple-c.paths-fingerprint") {
Some(container_fp) => {
if container_fp != expected_paths_fp {
log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp);
return Ok(true);
}
}
None => {
// Old container without paths-fingerprint label -> force recreation for migration
log::info!("Container missing paths-fingerprint label, triggering recreation for migration");
return Ok(true);
}
}

View File

@@ -63,6 +63,9 @@ pub fn run() {
commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,5 +1,9 @@
use serde::{Deserialize, Serialize};
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ImageSource {
@@ -52,6 +56,10 @@ pub struct AppSettings {
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_claude_instructions: Option<String>,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub dismissed_update_version: Option<String>,
}
impl Default for AppSettings {
@@ -65,6 +73,8 @@ impl Default for AppSettings {
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_claude_instructions: None,
auto_check_updates: true,
dismissed_update_version: None,
}
}
}

View File

@@ -1,7 +1,9 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use update_info::*;

View File

@@ -6,11 +6,17 @@ pub struct EnvVar {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectPath {
pub host_path: String,
pub mount_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub path: String,
pub paths: Vec<ProjectPath>,
pub container_id: Option<String>,
pub status: ProjectStatus,
pub auth_mode: AuthMode,
@@ -91,12 +97,12 @@ pub struct BedrockConfig {
}
impl Project {
pub fn new(name: String, path: String) -> Self {
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
path,
paths,
container_id: None,
status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(),
@@ -116,4 +122,29 @@ impl Project {
pub fn container_name(&self) -> String {
format!("triple-c-{}", self.id)
}
/// Migrate a project JSON value from old single-`path` format to new `paths` format.
/// If the value already has `paths`, it is returned unchanged.
pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value {
if let Some(obj) = val.as_object_mut() {
if obj.contains_key("paths") {
return val;
}
if let Some(path_val) = obj.remove("path") {
let path_str = path_val.as_str().unwrap_or("").to_string();
let mount_name = path_str
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.next()
.unwrap_or("workspace")
.to_string();
let project_path = serde_json::json!([{
"host_path": path_str,
"mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name },
}]);
obj.insert("paths".to_string(), project_path);
}
}
val
}
}

View File

@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
/// Info returned to the frontend about an available update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub version: String,
pub tag_name: String,
pub release_url: String,
pub body: String,
pub assets: Vec<ReleaseAsset>,
pub published_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}
/// Gitea API release response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease {
pub tag_name: String,
pub html_url: String,
pub body: String,
pub assets: Vec<GiteaAsset>,
pub published_at: String,
}
/// Gitea API asset response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}

View File

@@ -19,33 +19,72 @@ impl ProjectsStore {
let file_path = data_dir.join("projects.json");
let projects = if file_path.exists() {
let (projects, needs_save) = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(data) => match serde_json::from_str(&data) {
Ok(parsed) => parsed,
Err(e) => {
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
// Back up the corrupted file
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted projects.json: {}", be);
Ok(data) => {
// First try to parse as Vec<Value> to run migration
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
Ok(raw_values) => {
let mut migrated = false;
let migrated_values: Vec<serde_json::Value> = raw_values
.into_iter()
.map(|v| {
let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths"));
if has_path {
migrated = true;
}
crate::models::Project::migrate_from_value(v)
})
.collect();
// Now deserialize the migrated values
let json_str = serde_json::to_string(&migrated_values).unwrap_or_default();
match serde_json::from_str::<Vec<crate::models::Project>>(&json_str) {
Ok(parsed) => (parsed, migrated),
Err(e) => {
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted projects.json: {}", be);
}
(Vec::new(), false)
}
}
}
Err(e) => {
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted projects.json: {}", be);
}
(Vec::new(), false)
}
Vec::new()
}
},
}
Err(e) => {
log::error!("Failed to read projects.json: {}", e);
Vec::new()
(Vec::new(), false)
}
}
} else {
Vec::new()
(Vec::new(), false)
};
Ok(Self {
let store = Self {
projects: Mutex::new(projects),
file_path,
})
};
// Persist migrated format back to disk
if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format");
let projects = store.lock();
if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e);
}
}
Ok(store)
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {