From e25ddc4f8a37924b0cd3b027a2db716e792cdfe6 Mon Sep 17 00:00:00 2001 From: Guilherme Drumond Date: Thu, 13 Aug 2026 13:17:59 -0300 Subject: [PATCH 1/2] feat(updater): add manual tray update check --- src-tauri/src/tray.rs | 16 +++++++++++ src/components/UpdateChecker.tsx | 46 +++++++++++++++++++++++++++++++- src/i18n/en.ts | 3 +++ src/i18n/es.ts | 3 +++ src/i18n/pt.ts | 3 +++ src/i18n/zh.ts | 3 +++ 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 016264c..511ac17 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -15,6 +15,8 @@ use tauri::{ const EVENT_STATE_CHANGED: &str = "loomrouter://state-changed"; /// Event carrying a route the UI should navigate to, e.g. "/providers". const EVENT_NAVIGATE: &str = "loomrouter://navigate"; +/// Event requesting an explicit updater check in the main window. +const EVENT_CHECK_UPDATES: &str = "loomrouter://check-updates"; /// The pages the tray can jump to, as (menu id suffix, route, label). const PAGES: &[(&str, &str, &str)] = &[ @@ -168,6 +170,13 @@ fn build_menu( )?; let config_folder = MenuItem::with_id(app, "open-config", "Open Config Folder", true, None::<&str>)?; + let check_updates = MenuItem::with_id( + app, + "check-updates", + "Check for Updates", + true, + None::<&str>, + )?; let quit = MenuItem::with_id(app, "quit", "Quit LoomRouter", true, None::<&str>)?; // Only built after something failed, so it costs nothing in the normal @@ -205,6 +214,7 @@ fn build_menu( &open, &go_to, &config_folder, + &check_updates, &separators[3], &quit, ]); @@ -469,6 +479,12 @@ pub(crate) fn notify_state_changed(app: &tauri::AppHandle) { fn on_tray_menu_event(app: &tauri::AppHandle, id: &str) { match id { "show" => show_main_window(app), + "check-updates" => { + show_main_window(app); + if let Err(e) = app.emit(EVENT_CHECK_UPDATES, ()) { + tracing::warn!("check-updates event failed: {e}"); + } + } // AppHandle::exit() does not fire CloseRequested, so this is a // real quit (the window is not just hidden again). "quit" => app.exit(0), diff --git a/src/components/UpdateChecker.tsx b/src/components/UpdateChecker.tsx index b9c0641..60321e7 100644 --- a/src/components/UpdateChecker.tsx +++ b/src/components/UpdateChecker.tsx @@ -8,6 +8,9 @@ import type { Update } from '@tauri-apps/plugin-updater' type Phase = | { kind: 'idle' } + | { kind: 'checking' } + | { kind: 'current' } + | { kind: 'error' } | { kind: 'available'; version: string } | { kind: 'downloading'; percent: number } | { kind: 'ready' } @@ -21,6 +24,26 @@ export default function UpdateChecker() { const [update, setUpdate] = useState(null) const [dismissed, setDismissed] = useState(false) + const checkForUpdates = async (manual = false) => { + if (!isTauri) return + if (manual) { + setDismissed(false) + setPhase({ kind: 'checking' }) + } + try { + const { check } = await import('@tauri-apps/plugin-updater') + const found = await check() + if (found) { + setUpdate(found) + setPhase({ kind: 'available', version: found.version }) + } else if (manual) { + setPhase({ kind: 'current' }) + } + } catch { + if (manual) setPhase({ kind: 'error' }) + } + } + useEffect(() => { if (!isTauri) return let cancelled = false @@ -33,10 +56,20 @@ export default function UpdateChecker() { } }) .catch(() => { - // Offline or no release published yet: stay silent. + // Automatic checks stay silent when offline or already current. }) + let unlisten: (() => void) | undefined + import('@tauri-apps/api/event').then(({ listen }) => + listen('loomrouter://check-updates', () => { + if (!cancelled) void checkForUpdates(true) + }).then((dispose) => { + if (cancelled) dispose() + else unlisten = dispose + }), + ) return () => { cancelled = true + unlisten?.() } }, []) @@ -77,6 +110,9 @@ export default function UpdateChecker() { return (
+ {phase.kind === 'checking' && {s.updater.checking}} + {phase.kind === 'current' && {s.updater.current}} + {phase.kind === 'error' && {s.updater.error}} {phase.kind === 'available' && ( <> @@ -108,6 +144,14 @@ export default function UpdateChecker() { )} + {(phase.kind === 'current' || phase.kind === 'error') && ( + + )}
) } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 09cb0a4..d9f2ce5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -297,6 +297,9 @@ const en = { zoomReset: 'Reset zoom to 100%', }, updater: { + checking: 'Checking for updates...', + current: 'LoomRouter is up to date', + error: 'Could not check for updates', available: 'Version {{version}} is available', install: 'Download & install', downloading: 'Downloading update…', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 54c7388..7a71919 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -295,6 +295,9 @@ const es: DeepPartial = { zoomReset: 'Restablecer zoom al 100%', }, updater: { + checking: 'Buscando actualizaciones...', + current: 'LoomRouter está actualizado', + error: 'No se pudieron buscar actualizaciones', available: 'Versión {{version}} disponible', install: 'Descargar e instalar', downloading: 'Descargando actualización…', diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 30bfeba..f378845 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -294,6 +294,9 @@ const pt: DeepPartial = { zoomReset: 'Redefinir zoom para 100%', }, updater: { + checking: 'Verificando atualizações...', + current: 'O LoomRouter está atualizado', + error: 'Não foi possível verificar atualizações', available: 'Versão {{version}} disponível', install: 'Baixar e instalar', downloading: 'Baixando atualização…', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e7359d6..e17205b 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -293,6 +293,9 @@ const zh: DeepPartial = { zoomReset: '重置缩放为 100%', }, updater: { + checking: '正在检查更新...', + current: 'LoomRouter 已是最新版本', + error: '无法检查更新', available: '新版本 {{version}} 可用', install: '下载并安装', downloading: '正在下载更新…', From 57e436510b5f5f7a9d4933eecb46463e9e1ed77a Mon Sep 17 00:00:00 2001 From: Guilherme Drumond Date: Fri, 14 Aug 2026 19:49:15 -0300 Subject: [PATCH 2/2] fix(claude): restore print-mode tool access --- src-tauri/src/claude_cli.rs | 166 ++++++++++++++++++++++++++++++++--- src-tauri/src/cli_locator.rs | 84 +++++++++++++++++- 2 files changed, 239 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 4cf6e26..cae8366 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -225,6 +225,9 @@ pub struct ClaudePrintResult { /// and passes it through `--settings`. This is intentionally narrow: it /// covers the repo's quality gate, not arbitrary commands. const INJECTED_CLAUDE_ALLOW: &[&str] = &[ + "WebSearch", + "WebFetch", + "Bash(curl:*)", "Bash(bun run lint)", "Bash(bun run test)", "Bash(bun run test:*)", @@ -272,6 +275,68 @@ fn claude_project_dir() -> Option { .then_some(cwd) } +/// Mark a LoomRouter-selected workspace as trusted before a non-interactive +/// Claude turn starts. Print mode cannot answer Claude Code's trust dialog, +/// so leaving this false makes the CLI silently ignore project permissions. +fn trust_claude_project(project_dir: &std::path::Path) -> anyhow::Result<()> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?; + trust_claude_project_in(&home.join(".claude.json"), project_dir) +} + +fn trust_claude_project_in( + config_path: &std::path::Path, + project_dir: &std::path::Path, +) -> anyhow::Result<()> { + static CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut config = if config_path.is_file() { + serde_json::from_slice::(&std::fs::read(config_path)?).map_err(|e| { + anyhow::anyhow!( + "could not parse Claude config {}: {e}", + config_path.display() + ) + })? + } else { + json!({}) + }; + let root = config + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude config root must be a JSON object"))?; + let projects = root + .entry("projects") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude config projects must be a JSON object"))?; + let project_key = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()) + .to_string_lossy() + .into_owned(); + let project = projects.entry(project_key).or_insert_with(|| json!({})); + let project = project + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude project config must be a JSON object"))?; + if project + .get("hasTrustDialogAccepted") + .and_then(Value::as_bool) + == Some(true) + { + return Ok(()); + } + project.insert("hasTrustDialogAccepted".to_string(), Value::Bool(true)); + let encoded = serde_json::to_vec_pretty(&config)?; + crate::secure_fs::write_private_with_backup(config_path, &encoded)?; + Ok(()) +} + +fn configure_claude_project(cmd: &mut std::process::Command) -> anyhow::Result<()> { + if let Some(dir) = claude_project_dir() { + trust_claude_project(&dir)?; + cmd.current_dir(dir); + } + Ok(()) +} + fn configure_print_command(cmd: &mut std::process::Command, model: &str) { // Proxy turns are stateless, so persisting them only pollutes Claude Code's // session history with entries grouped under the app process cwd. Safe mode @@ -279,6 +344,10 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { // memory that do not belong in a routed model call. cmd.arg("-p") .arg("--safe-mode") + // Print mode has no interactive permission prompt. Accept workspace + // edits, while commands and network access remain explicitly allowlisted. + .arg("--permission-mode") + .arg("acceptEdits") .arg("--no-session-persistence") .arg("--prompt-suggestions") .arg("false") @@ -287,6 +356,12 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); } +fn configure_child_environment(cmd: &mut std::process::Command, bin: &std::path::Path) { + if let Some(path) = crate::cli_locator::child_path(bin) { + cmd.env("PATH", path); + } +} + /// One turn's input to the CLI: a flat prompt, or Claude Code's stream-json /// message protocol when the turn carries images. pub enum ClaudeTurnInput { @@ -342,9 +417,8 @@ pub fn stream_print_turn( let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings").arg(&injected); if matches!(input, ClaudeTurnInput::StreamJson(_)) { @@ -659,9 +733,8 @@ pub async fn run_print_turn( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings") .arg(&injected) @@ -717,9 +790,8 @@ pub async fn run_print_turn_stream_json( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings") .arg(&injected) @@ -1339,11 +1411,83 @@ mod tests { let settings: serde_json::Value = serde_json::from_str(&raw).unwrap(); let allow = settings["permissions"]["allow"].as_array().unwrap(); assert_eq!(allow.len(), INJECTED_CLAUDE_ALLOW.len()); - assert_eq!(allow[0], "Bash(bun run lint)"); + assert_eq!(allow[0], "WebSearch"); + assert!(allow.contains(&json!("WebFetch"))); + assert!(allow.contains(&json!("Bash(curl:*)"))); assert!(!raw.contains("Bash(*)")); std::fs::remove_file(path).unwrap(); } + #[test] + fn trusting_project_preserves_existing_claude_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + let project_dir = dir.path().join("workspace"); + std::fs::create_dir(&project_dir).unwrap(); + let project_key = project_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + std::fs::write( + &config_path, + serde_json::to_vec(&json!({ + "theme": "dark", + "projects": { + (project_key.clone()): { + "allowedTools": ["Read"], + "hasTrustDialogAccepted": false + } + } + })) + .unwrap(), + ) + .unwrap(); + + trust_claude_project_in(&config_path, &project_dir).unwrap(); + + let config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + assert_eq!(config["theme"], "dark"); + assert_eq!(config["projects"][&project_key]["allowedTools"][0], "Read"); + assert_eq!( + config["projects"][&project_key]["hasTrustDialogAccepted"], + true + ); + assert!(dir.path().join(".claude.json.bak").is_file()); + } + + #[test] + fn trusting_new_project_creates_projects_map() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + let project_dir = dir.path().join("workspace"); + std::fs::create_dir(&project_dir).unwrap(); + + trust_claude_project_in(&config_path, &project_dir).unwrap(); + + let config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let project_key = project_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!( + config["projects"][&project_key]["hasTrustDialogAccepted"], + true + ); + } + + #[test] + fn invalid_claude_config_is_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + std::fs::write(&config_path, b"not-json").unwrap(); + + assert!(trust_claude_project_in(&config_path, dir.path()).is_err()); + assert_eq!(std::fs::read(&config_path).unwrap(), b"not-json"); + assert!(!dir.path().join(".claude.json.bak").exists()); + } + #[test] fn proxy_print_turns_do_not_persist_claude_sessions() { let mut command = std::process::Command::new("claude"); @@ -1355,6 +1499,8 @@ mod tests { [ "-p", "--safe-mode", + "--permission-mode", + "acceptEdits", "--no-session-persistence", "--prompt-suggestions", "false", diff --git a/src-tauri/src/cli_locator.rs b/src-tauri/src/cli_locator.rs index 2c6b925..3308c2c 100644 --- a/src-tauri/src/cli_locator.rs +++ b/src-tauri/src/cli_locator.rs @@ -1,4 +1,4 @@ -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; /// Walk `PATH` for a binary name, returning the first matching file. @@ -87,6 +87,75 @@ where validate(&path).then_some(path) } +/// Return the PATH configured by the user's Unix login shell. +/// +/// Finder and other GUI launchers inherit launchd's minimal PATH. The CLI may +/// still be found through a login-shell lookup, but its child tools need that +/// same PATH to find package-manager binaries such as bun. +#[cfg(unix)] +pub(crate) fn login_shell_path() -> Option { + use std::io::Read; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut child = std::process::Command::new(&shell) + .args(["-lic", "printf '%s\\n' \"$PATH\""]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + tracing::warn!("the login shell did not answer in time; skipping its PATH"); + return None; + } + Err(_) => return None, + } + } + + let mut output = String::new(); + child.stdout.take()?.read_to_string(&mut output).ok()?; + Some(OsString::from(last_non_empty_line(&output)?)) +} + +/// Build a subprocess PATH that includes the selected CLI's directory, the +/// user's Unix shell PATH, and the launcher PATH, without duplicate entries. +pub(crate) fn child_path(cli_bin: &Path) -> Option { + let mut entries = Vec::new(); + if let Some(parent) = cli_bin.parent() { + entries.push(parent.to_path_buf()); + } + #[cfg(unix)] + if let Some(login_path) = login_shell_path() { + entries.extend(std::env::split_paths(&login_path)); + } + if let Some(inherited) = std::env::var_os("PATH") { + entries.extend(std::env::split_paths(&inherited)); + } + join_unique_paths(entries) +} + +fn join_unique_paths(paths: impl IntoIterator) -> Option { + let mut unique = Vec::new(); + for path in paths { + if !path.as_os_str().is_empty() && !unique.contains(&path) { + unique.push(path); + } + } + (!unique.is_empty()) + .then(|| std::env::join_paths(unique).ok()) + .flatten() +} + // Windows subprocesses need CREATE_NO_WINDOW to avoid flashing a console. #[cfg(windows)] pub(crate) fn hide_console_window(command: &mut std::process::Command) { @@ -156,4 +225,17 @@ mod tests { ); assert_eq!(last_non_empty_line("\n \n"), None); } + + #[test] + fn child_path_keeps_the_cli_directory_and_deduplicates() { + let cli_dir = PathBuf::from("/opt/loom/bin"); + let joined = + join_unique_paths([cli_dir.clone(), PathBuf::from("/usr/bin"), cli_dir.clone()]) + .unwrap(); + + assert_eq!( + std::env::split_paths(&joined).collect::>(), + vec![cli_dir, PathBuf::from("/usr/bin")] + ); + } }