diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index fcc8c0bc..1c36c0bb 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -988,6 +988,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futf" version = "0.1.5" @@ -2383,6 +2393,7 @@ dependencies = [ name = "openwork" version = "0.4.0" dependencies = [ + "fs2", "json5", "serde", "serde_json", @@ -4637,9 +4648,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -5628,9 +5639,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" [[package]] name = "zvariant" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 694fa01c..40848da9 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] +fs2 = "0.4" json5 = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs index f6a8b9be..5b0e171f 100644 --- a/packages/desktop/src-tauri/build.rs +++ b/packages/desktop/src-tauri/build.rs @@ -30,6 +30,16 @@ fn ensure_opencode_sidecar() { } let dest_path = sidecar_dir.join(file_name); + if let Ok(metadata) = fs::symlink_metadata(&dest_path) { + if metadata.file_type().is_symlink() { + println!( + "cargo:warning=OpenCode sidecar path is a symlink, refusing to overwrite: {}", + dest_path.display() + ); + return; + } + } + if dest_path.exists() { return; } @@ -37,8 +47,11 @@ fn ensure_opencode_sidecar() { let source_path = env::var("OPENCODE_BIN_PATH") .ok() .map(PathBuf::from) - .filter(|path| path.is_file()) - .or_else(|| find_in_path(if target.contains("windows") { "opencode.exe" } else { "opencode" })); + .and_then(resolve_source_path) + .or_else(|| { + find_in_path(if target.contains("windows") { "opencode.exe" } else { "opencode" }) + .and_then(resolve_source_path) + }); let profile = env::var("PROFILE").unwrap_or_default(); @@ -99,6 +112,15 @@ fn find_in_path(binary: &str) -> Option { }) } +fn resolve_source_path(path: PathBuf) -> Option { + let resolved = path.canonicalize().ok()?; + if resolved.is_file() { + Some(resolved) + } else { + None + } +} + fn create_debug_stub(dest_path: &PathBuf, sidecar_dir: &PathBuf, profile: &str, target: &str) { if profile != "debug" || target.contains("windows") { return; diff --git a/packages/desktop/src-tauri/src/commands/engine.rs b/packages/desktop/src-tauri/src/commands/engine.rs index f0e31046..eed9c543 100644 --- a/packages/desktop/src-tauri/src/commands/engine.rs +++ b/packages/desktop/src-tauri/src/commands/engine.rs @@ -8,233 +8,286 @@ use crate::utils::truncate_output; #[tauri::command] pub fn engine_info(manager: State) -> EngineInfo { - let mut state = manager.inner.lock().expect("engine mutex poisoned"); - EngineManager::snapshot_locked(&mut state) + let mut state = manager.inner.lock().expect("engine mutex poisoned"); + EngineManager::snapshot_locked(&mut state) } #[tauri::command] pub fn engine_stop(manager: State) -> EngineInfo { - let mut state = manager.inner.lock().expect("engine mutex poisoned"); - EngineManager::stop_locked(&mut state); - EngineManager::snapshot_locked(&mut state) + let mut state = manager.inner.lock().expect("engine mutex poisoned"); + EngineManager::stop_locked(&mut state); + EngineManager::snapshot_locked(&mut state) } #[tauri::command] pub fn engine_doctor(app: AppHandle, prefer_sidecar: Option) -> EngineDoctorResult { - let prefer_sidecar = prefer_sidecar.unwrap_or(false); - let resource_dir = app.path().resource_dir().ok(); - - let current_bin_dir = tauri::process::current_binary(&app.env()) - .ok() - .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); - - let (resolved, in_path, notes) = - resolve_engine_path(prefer_sidecar, resource_dir.as_deref(), current_bin_dir.as_deref()); - - let (version, supports_serve, serve_help_status, serve_help_stdout, serve_help_stderr) = - match resolved.as_ref() { - Some(path) => { - let (ok, status, stdout, stderr) = opencode_serve_help(path.as_os_str()); - ( - opencode_version(path.as_os_str()), - ok, - status, - stdout, - stderr, - ) - } - None => (None, false, None, None, None), - }; + let prefer_sidecar = prefer_sidecar.unwrap_or(false); + let resource_dir = app.path().resource_dir().ok(); + + let current_bin_dir = tauri::process::current_binary(&app.env()) + .ok() + .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); + + let (resolved, in_path, notes) = resolve_engine_path( + prefer_sidecar, + resource_dir.as_deref(), + current_bin_dir.as_deref(), + ); - EngineDoctorResult { - found: resolved.is_some(), - in_path, - resolved_path: resolved.map(|path| path.to_string_lossy().to_string()), - version, - supports_serve, - notes, - serve_help_status, - serve_help_stdout, - serve_help_stderr, - } + let (version, supports_serve, serve_help_status, serve_help_stdout, serve_help_stderr) = + match resolved.as_ref() { + Some(path) => { + let (ok, status, stdout, stderr) = opencode_serve_help(path.as_os_str()); + ( + opencode_version(path.as_os_str()), + ok, + status, + stdout, + stderr, + ) + } + None => (None, false, None, None, None), + }; + + EngineDoctorResult { + found: resolved.is_some(), + in_path, + resolved_path: resolved.map(|path| path.to_string_lossy().to_string()), + version, + supports_serve, + notes, + serve_help_status, + serve_help_stdout, + serve_help_stderr, + } } #[tauri::command] pub fn engine_install() -> Result { - #[cfg(windows)] - { - return Ok(ExecResult { + #[cfg(windows)] + { + return Ok(ExecResult { ok: false, status: -1, stdout: String::new(), stderr: "Guided install is not supported on Windows yet. Install OpenCode via Scoop/Chocolatey or https://opencode.ai/install, then restart OpenWork.".to_string(), }); - } - - #[cfg(not(windows))] - { - let install_dir = crate::paths::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".opencode") - .join("bin"); - - let output = std::process::Command::new("bash") - .arg("-lc") - .arg("curl -fsSL https://opencode.ai/install | bash") - .env("OPENCODE_INSTALL_DIR", install_dir) - .output() - .map_err(|e| format!("Failed to run installer: {e}"))?; - - let status = output.status.code().unwrap_or(-1); - Ok(ExecResult { - ok: output.status.success(), - status, - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) - } + } + + #[cfg(not(windows))] + { + let install_dir = crate::paths::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".opencode") + .join("bin"); + + let curl_output = std::process::Command::new("curl") + .arg("-fsSL") + .arg("https://opencode.ai/install") + .output() + .map_err(|e| format!("Failed to download installer: {e}"))?; + + if !curl_output.status.success() { + let status = curl_output.status.code().unwrap_or(-1); + return Ok(ExecResult { + ok: false, + status, + stdout: String::from_utf8_lossy(&curl_output.stdout).to_string(), + stderr: String::from_utf8_lossy(&curl_output.stderr).to_string(), + }); + } + + let mut bash = std::process::Command::new("bash") + .arg("-s") + .env("OPENCODE_INSTALL_DIR", install_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to run installer: {e}"))?; + + if let Some(mut stdin) = bash.stdin.take() { + use std::io::Write; + stdin + .write_all(&curl_output.stdout) + .map_err(|e| format!("Failed to pipe installer: {e}"))?; + } + + let output = bash + .wait_with_output() + .map_err(|e| format!("Failed to run installer: {e}"))?; + + let status = output.status.code().unwrap_or(-1); + let mut stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + let prefix = format!("Installer failed (bash exit {status})."); + if stderr.trim().is_empty() { + stderr = prefix; + } else { + stderr = format!("{prefix}\n\n{}", stderr.trim()); + } + } + let curl_stderr = String::from_utf8_lossy(&curl_output.stderr).to_string(); + if !curl_stderr.trim().is_empty() { + if !stderr.trim().is_empty() { + stderr.push_str("\n\n"); + } + stderr.push_str("curl stderr:\n"); + stderr.push_str(curl_stderr.trim()); + } + + Ok(ExecResult { + ok: output.status.success(), + status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr, + }) + } } #[tauri::command] pub fn engine_start( - app: AppHandle, - manager: State, - project_dir: String, - prefer_sidecar: Option, + app: AppHandle, + manager: State, + project_dir: String, + prefer_sidecar: Option, ) -> Result { - let project_dir = project_dir.trim().to_string(); - if project_dir.is_empty() { - return Err("projectDir is required".to_string()); - } - - // OpenCode is spawned with `current_dir(project_dir)`. If the user selected a - // workspace path that doesn't exist yet (common during onboarding), spawning - // fails with `os error 2`. - std::fs::create_dir_all(&project_dir) - .map_err(|e| format!("Failed to create projectDir directory: {e}"))?; - - let hostname = "127.0.0.1".to_string(); - let port = find_free_port()?; - - let mut state = manager.inner.lock().expect("engine mutex poisoned"); - EngineManager::stop_locked(&mut state); - - let resource_dir = app.path().resource_dir().ok(); - let current_bin_dir = tauri::process::current_binary(&app.env()) - .ok() - .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); - let (program, _in_path, notes) = - resolve_engine_path(prefer_sidecar.unwrap_or(false), resource_dir.as_deref(), current_bin_dir.as_deref()); - let Some(program) = program else { - let notes_text = notes.join("\n"); - return Err(format!( + let project_dir = project_dir.trim().to_string(); + if project_dir.is_empty() { + return Err("projectDir is required".to_string()); + } + + // OpenCode is spawned with `current_dir(project_dir)`. If the user selected a + // workspace path that doesn't exist yet (common during onboarding), spawning + // fails with `os error 2`. + std::fs::create_dir_all(&project_dir) + .map_err(|e| format!("Failed to create projectDir directory: {e}"))?; + + let hostname = "127.0.0.1".to_string(); + let port = find_free_port()?; + + let mut state = manager.inner.lock().expect("engine mutex poisoned"); + EngineManager::stop_locked(&mut state); + + let resource_dir = app.path().resource_dir().ok(); + let current_bin_dir = tauri::process::current_binary(&app.env()) + .ok() + .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); + let (program, _in_path, notes) = resolve_engine_path( + prefer_sidecar.unwrap_or(false), + resource_dir.as_deref(), + current_bin_dir.as_deref(), + ); + let Some(program) = program else { + let notes_text = notes.join("\n"); + return Err(format!( "OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}" )); - }; - - let mut command = build_engine_command(&program, &hostname, port, &project_dir); - let mut child = spawn_engine(&mut command)?; - - state.last_stdout = None; - state.last_stderr = None; - - let warmup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - if let Ok(Some(status)) = child.try_wait() { - let mut stdout = String::new(); - if let Some(mut stream) = child.stdout.take() { - use std::io::Read; - let mut buffer = Vec::new(); - let _ = stream.read_to_end(&mut buffer); - stdout = String::from_utf8_lossy(&buffer).trim().to_string(); - } - - let mut stderr = String::new(); - if let Some(mut stream) = child.stderr.take() { - use std::io::Read; - let mut buffer = Vec::new(); - let _ = stream.read_to_end(&mut buffer); - stderr = String::from_utf8_lossy(&buffer).trim().to_string(); - } - - let stdout = if stdout.is_empty() { - None - } else { - Some(truncate_output(&stdout, 8000)) - }; - let stderr = if stderr.is_empty() { - None - } else { - Some(truncate_output(&stderr, 8000)) - }; - - let mut parts = Vec::new(); - if let Some(stdout) = stdout { - parts.push(format!("stdout:\n{stdout}")); - } - if let Some(stderr) = stderr { - parts.push(format!("stderr:\n{stderr}")); - } - - let suffix = if parts.is_empty() { - String::new() - } else { - format!("\n\n{}", parts.join("\n\n")) - }; - - return Err(format!( - "OpenCode exited immediately with status {}.{}", - status.code().unwrap_or(-1), - suffix - )); + }; + + let mut command = build_engine_command(&program, &hostname, port, &project_dir); + let mut child = spawn_engine(&mut command)?; + + state.last_stdout = None; + state.last_stderr = None; + + let warmup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Ok(Some(status)) = child.try_wait() { + let mut stdout = String::new(); + if let Some(mut stream) = child.stdout.take() { + use std::io::Read; + let mut buffer = Vec::new(); + let _ = stream.read_to_end(&mut buffer); + stdout = String::from_utf8_lossy(&buffer).trim().to_string(); + } + + let mut stderr = String::new(); + if let Some(mut stream) = child.stderr.take() { + use std::io::Read; + let mut buffer = Vec::new(); + let _ = stream.read_to_end(&mut buffer); + stderr = String::from_utf8_lossy(&buffer).trim().to_string(); + } + + let stdout = if stdout.is_empty() { + None + } else { + Some(truncate_output(&stdout, 8000)) + }; + let stderr = if stderr.is_empty() { + None + } else { + Some(truncate_output(&stderr, 8000)) + }; + + let mut parts = Vec::new(); + if let Some(stdout) = stdout { + parts.push(format!("stdout:\n{stdout}")); + } + if let Some(stderr) = stderr { + parts.push(format!("stderr:\n{stderr}")); + } + + let suffix = if parts.is_empty() { + String::new() + } else { + format!("\n\n{}", parts.join("\n\n")) + }; + + return Err(format!( + "OpenCode exited immediately with status {}.{}", + status.code().unwrap_or(-1), + suffix + )); + } + + if std::time::Instant::now() >= warmup_deadline { + break; + } + + std::thread::sleep(std::time::Duration::from_millis(150)); } - if std::time::Instant::now() >= warmup_deadline { - break; + if let Some(stream) = child.stderr.take() { + let stderr_state = manager.inner.clone(); + std::thread::spawn(move || { + use std::io::Read; + let mut buffer = Vec::new(); + let mut reader = stream; + let _ = reader.read_to_end(&mut buffer); + let output = String::from_utf8_lossy(&buffer).trim().to_string(); + if output.is_empty() { + return; + } + if let Ok(mut state) = stderr_state.lock() { + state.last_stderr = Some(crate::utils::truncate_output(&output, 8000)); + } + }); } - std::thread::sleep(std::time::Duration::from_millis(150)); - } - - if let Some(stream) = child.stderr.take() { - let stderr_state = manager.inner.clone(); - std::thread::spawn(move || { - use std::io::Read; - let mut buffer = Vec::new(); - let mut reader = stream; - let _ = reader.read_to_end(&mut buffer); - let output = String::from_utf8_lossy(&buffer).trim().to_string(); - if output.is_empty() { - return; - } - if let Ok(mut state) = stderr_state.lock() { - state.last_stderr = Some(crate::utils::truncate_output(&output, 8000)); - } - }); - } - - if let Some(stream) = child.stdout.take() { - let stdout_state = manager.inner.clone(); - std::thread::spawn(move || { - use std::io::Read; - let mut buffer = Vec::new(); - let mut reader = stream; - let _ = reader.read_to_end(&mut buffer); - let output = String::from_utf8_lossy(&buffer).trim().to_string(); - if output.is_empty() { - return; - } - if let Ok(mut state) = stdout_state.lock() { - state.last_stdout = Some(crate::utils::truncate_output(&output, 8000)); - } - }); - } + if let Some(stream) = child.stdout.take() { + let stdout_state = manager.inner.clone(); + std::thread::spawn(move || { + use std::io::Read; + let mut buffer = Vec::new(); + let mut reader = stream; + let _ = reader.read_to_end(&mut buffer); + let output = String::from_utf8_lossy(&buffer).trim().to_string(); + if output.is_empty() { + return; + } + if let Ok(mut state) = stdout_state.lock() { + state.last_stdout = Some(crate::utils::truncate_output(&output, 8000)); + } + }); + } - state.child = Some(child); - state.project_dir = Some(project_dir); - state.hostname = Some(hostname.clone()); - state.port = Some(port); - state.base_url = Some(format!("http://{hostname}:{port}")); + state.child = Some(child); + state.project_dir = Some(project_dir); + state.hostname = Some(hostname.clone()); + state.port = Some(port); + state.base_url = Some(format!("http://{hostname}:{port}")); - Ok(EngineManager::snapshot_locked(&mut state)) + Ok(EngineManager::snapshot_locked(&mut state)) } diff --git a/packages/desktop/src-tauri/src/commands/skills.rs b/packages/desktop/src-tauri/src/commands/skills.rs index 41f540bf..46beb0e9 100644 --- a/packages/desktop/src-tauri/src/commands/skills.rs +++ b/packages/desktop/src-tauri/src/commands/skills.rs @@ -104,6 +104,10 @@ fn validate_skill_name(name: &str) -> Result { return Err("skill name is required".to_string()); } + if trimmed.len() > 100 { + return Err("skill name must be 1-100 characters".to_string()); + } + if !trimmed .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') diff --git a/packages/desktop/src-tauri/src/config.rs b/packages/desktop/src-tauri/src/config.rs index c7cd238d..ccf4bd44 100644 --- a/packages/desktop/src-tauri/src/config.rs +++ b/packages/desktop/src-tauri/src/config.rs @@ -4,77 +4,102 @@ use std::path::PathBuf; use crate::types::{ExecResult, OpencodeConfigFile}; -fn opencode_config_candidates(scope: &str, project_dir: &str) -> Result<(PathBuf, PathBuf), String> { - match scope { - "project" => { - if project_dir.trim().is_empty() { - return Err("projectDir is required".to_string()); - } - let root = PathBuf::from(project_dir); - Ok((root.join("opencode.jsonc"), root.join("opencode.json"))) +fn env_path(var: &str) -> Option { + let value = env::var_os(var)?; + let trimmed = value.to_string_lossy(); + let trimmed = trimmed.trim(); + if trimmed.is_empty() { + return None; } - "global" => { - let base = if let Ok(dir) = env::var("XDG_CONFIG_HOME") { - PathBuf::from(dir) - } else if let Ok(home) = env::var("HOME") { - PathBuf::from(home).join(".config") - } else { - return Err("Unable to resolve config directory".to_string()); - }; - - let root = base.join("opencode"); - Ok((root.join("opencode.jsonc"), root.join("opencode.json"))) + + let path = PathBuf::from(trimmed); + if path.is_absolute() { + Some(path) + } else { + None + } +} + +fn opencode_config_candidates( + scope: &str, + project_dir: &str, +) -> Result<(PathBuf, PathBuf), String> { + match scope { + "project" => { + if project_dir.trim().is_empty() { + return Err("projectDir is required".to_string()); + } + let root = PathBuf::from(project_dir); + Ok((root.join("opencode.jsonc"), root.join("opencode.json"))) + } + "global" => { + let base = if let Some(dir) = env_path("XDG_CONFIG_HOME") { + dir + } else if let Some(home) = env_path("HOME") { + home.join(".config") + } else { + return Err("Unable to resolve config directory".to_string()); + }; + + let root = base.join("opencode"); + Ok((root.join("opencode.jsonc"), root.join("opencode.json"))) + } + _ => Err("scope must be 'project' or 'global'".to_string()), } - _ => Err("scope must be 'project' or 'global'".to_string()), - } } pub fn resolve_opencode_config_path(scope: &str, project_dir: &str) -> Result { - let (jsonc_path, json_path) = opencode_config_candidates(scope, project_dir)?; + let (jsonc_path, json_path) = opencode_config_candidates(scope, project_dir)?; - if jsonc_path.exists() { - return Ok(jsonc_path); - } + if jsonc_path.exists() { + return Ok(jsonc_path); + } - if json_path.exists() { - return Ok(json_path); - } + if json_path.exists() { + return Ok(json_path); + } - Ok(jsonc_path) + Ok(jsonc_path) } pub fn read_opencode_config(scope: &str, project_dir: &str) -> Result { - let path = resolve_opencode_config_path(scope.trim(), project_dir)?; - let exists = path.exists(); - - let content = if exists { - Some(fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?) - } else { - None - }; - - Ok(OpencodeConfigFile { - path: path.to_string_lossy().to_string(), - exists, - content, - }) + let path = resolve_opencode_config_path(scope.trim(), project_dir)?; + let exists = path.exists(); + + let content = if exists { + Some( + fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?, + ) + } else { + None + }; + + Ok(OpencodeConfigFile { + path: path.to_string_lossy().to_string(), + exists, + content, + }) } -pub fn write_opencode_config(scope: &str, project_dir: &str, content: &str) -> Result { - let path = resolve_opencode_config_path(scope.trim(), project_dir)?; +pub fn write_opencode_config( + scope: &str, + project_dir: &str, + content: &str, +) -> Result { + let path = resolve_opencode_config_path(scope.trim(), project_dir)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create config dir {}: {e}", parent.display()))?; - } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config dir {}: {e}", parent.display()))?; + } - fs::write(&path, content) - .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + fs::write(&path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; - Ok(ExecResult { - ok: true, - status: 0, - stdout: format!("Wrote {}", path.display()), - stderr: String::new(), - }) + Ok(ExecResult { + ok: true, + status: 0, + stdout: format!("Wrote {}", path.display()), + stderr: String::new(), + }) } diff --git a/packages/desktop/src-tauri/src/engine/paths.rs b/packages/desktop/src-tauri/src/engine/paths.rs index 81a82feb..1decead2 100644 --- a/packages/desktop/src-tauri/src/engine/paths.rs +++ b/packages/desktop/src-tauri/src/engine/paths.rs @@ -59,6 +59,15 @@ pub fn candidate_opencode_paths() -> Vec { candidates } +fn resolve_executable_candidate(candidate: &PathBuf) -> Option { + let resolved = candidate.canonicalize().ok()?; + if resolved.is_file() { + Some(resolved) + } else { + None + } +} + pub fn resolve_opencode_executable() -> (Option, bool, Vec) { let mut notes = Vec::new(); @@ -66,33 +75,39 @@ pub fn resolve_opencode_executable() -> (Option, bool, Vec) { let custom = custom.trim(); if !custom.is_empty() { let candidate = PathBuf::from(custom); - if candidate.is_file() { - notes.push(format!("Using OPENCODE_BIN_PATH: {}", candidate.display())); - return (Some(candidate), false, notes); + if let Some(resolved) = resolve_executable_candidate(&candidate) { + notes.push(format!("Using OPENCODE_BIN_PATH: {}", resolved.display())); + return (Some(resolved), false, notes); } notes.push(format!("OPENCODE_BIN_PATH set but missing: {}", candidate.display())); } } if let Some(path) = resolve_in_path(OPENCODE_EXECUTABLE) { - notes.push(format!("Found in PATH: {}", path.display())); - return (Some(path), true, notes); + if let Some(resolved) = resolve_executable_candidate(&path) { + notes.push(format!("Found in PATH: {}", resolved.display())); + return (Some(resolved), true, notes); + } + notes.push(format!("Found in PATH but missing: {}", path.display())); } #[cfg(windows)] { if let Some(path) = resolve_in_path(OPENCODE_CMD) { - notes.push(format!("Found in PATH: {}", path.display())); - return (Some(path), true, notes); + if let Some(resolved) = resolve_executable_candidate(&path) { + notes.push(format!("Found in PATH: {}", resolved.display())); + return (Some(resolved), true, notes); + } + notes.push(format!("Found in PATH but missing: {}", path.display())); } } notes.push("Not found on PATH".to_string()); for candidate in candidate_opencode_paths() { - if candidate.is_file() { - notes.push(format!("Found at {}", candidate.display())); - return (Some(candidate), false, notes); + if let Some(resolved) = resolve_executable_candidate(&candidate) { + notes.push(format!("Found at {}", resolved.display())); + return (Some(resolved), false, notes); } notes.push(format!("Missing: {}", candidate.display())); diff --git a/packages/desktop/src-tauri/src/paths.rs b/packages/desktop/src-tauri/src/paths.rs index d41ebae5..61889a53 100644 --- a/packages/desktop/src-tauri/src/paths.rs +++ b/packages/desktop/src-tauri/src/paths.rs @@ -6,14 +6,22 @@ const MACOS_APP_SUPPORT_DIR: &str = "Library/Application Support"; pub fn home_dir() -> Option { if let Ok(home) = env::var("HOME") { - if !home.trim().is_empty() { - return Some(PathBuf::from(home)); + let trimmed = home.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + return Some(path); + } } } if let Ok(profile) = env::var("USERPROFILE") { - if !profile.trim().is_empty() { - return Some(PathBuf::from(profile)); + let trimmed = profile.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + return Some(path); + } } } @@ -58,8 +66,10 @@ pub fn maybe_infer_xdg_home( candidates: Vec, relative_marker: &Path, ) -> Option { - if env::var_os(var_name).is_some() { - return None; + if let Some(value) = env::var_os(var_name) { + if !value.to_string_lossy().trim().is_empty() { + return None; + } } for base in candidates { diff --git a/packages/desktop/src-tauri/src/workspace/state.rs b/packages/desktop/src-tauri/src/workspace/state.rs index f9de4bd9..deacfeb2 100644 --- a/packages/desktop/src-tauri/src/workspace/state.rs +++ b/packages/desktop/src-tauri/src/workspace/state.rs @@ -1,5 +1,6 @@ use std::fs; use std::hash::{Hash, Hasher}; +use std::io::{Read, Write}; use std::path::PathBuf; use tauri::Manager; @@ -23,12 +24,21 @@ pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf) pub fn load_workspace_state(app: &tauri::AppHandle) -> Result { let (_, path) = openwork_state_paths(app)?; - if !path.exists() { - return Ok(WorkspaceState::default()); - } + let mut file = match fs::OpenOptions::new().read(true).open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorkspaceState::default()); + } + Err(e) => return Err(format!("Failed to read {}: {e}", path.display())), + }; + + fs2::FileExt::lock_shared(&file) + .map_err(|e| format!("Failed to lock {}: {e}", path.display()))?; + + let mut raw = String::new(); + file.read_to_string(&mut raw) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - let raw = - fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; let mut state: WorkspaceState = serde_json::from_str(&raw) .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; @@ -42,14 +52,24 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result Result<(), String> { let (dir, path) = openwork_state_paths(app)?; fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; - fs::write( - &path, - serde_json::to_string_pretty(state).map_err(|e| e.to_string())?, - ) - .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .open(&path) + .map_err(|e| format!("Failed to open {}: {e}", path.display()))?; + + fs2::FileExt::lock_exclusive(&file) + .map_err(|e| format!("Failed to lock {}: {e}", path.display()))?; + file.set_len(0) + .map_err(|e| format!("Failed to truncate {}: {e}", path.display()))?; + + let payload = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + file.write_all(payload.as_bytes()) + .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + file.sync_all() + .map_err(|e| format!("Failed to sync {}: {e}", path.display()))?; Ok(()) } - pub fn ensure_starter_workspace(app: &tauri::AppHandle) -> Result { let data_dir = app .path()