diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14188f4..90cc93f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@humanspeak/svelte-json-view-lite': specifier: ^0.1.2 version: 0.1.2(svelte@5.55.9) + '@tanstack/svelte-virtual': + specifier: ^3.13.35 + version: 3.13.35(svelte@5.55.9) '@tauri-apps/api': specifier: ^2.11.0 version: 2.11.0 @@ -537,6 +540,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/svelte-virtual@3.13.35': + resolution: {integrity: sha512-4ur3bPzSXnZZtQWwns/ziCR9pJ/4x/AlBIeDZu96MKrRMbZBL1TQkGiO+ZaX5zCgdpXUZwJnehKzBp32KOumCQ==} + peerDependencies: + svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + '@tauri-apps/api@2.11.0': resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} @@ -1426,6 +1437,13 @@ snapshots: tailwindcss: 4.3.0 vite: 6.4.2(jiti@2.7.0)(lightningcss@1.32.0) + '@tanstack/svelte-virtual@3.13.35(svelte@5.55.9)': + dependencies: + '@tanstack/virtual-core': 3.17.7 + svelte: 5.55.9 + + '@tanstack/virtual-core@3.17.7': {} + '@tauri-apps/api@2.11.0': {} '@tauri-apps/cli-darwin-arm64@2.11.2': diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e144f08..e5be5f6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -459,6 +459,31 @@ fn read_toon_output() -> bool { .unwrap_or(true) } +/// Read `relay.write_dirs` from `~/.endara/config.toml`. +/// Returns an empty list on any error, missing section, or missing key — +/// matches the relay's own default (no writable directories). +fn read_write_dirs() -> Vec { + let Ok(parsed) = read_config() else { + return Vec::new(); + }; + // De-duplicate while preserving first-seen order: a hand-edited config + // with duplicate entries would otherwise produce duplicate keys in the + // UI's keyed `{#each}` list. + let mut seen = std::collections::HashSet::new(); + parsed + .get("relay") + .and_then(|v| v.as_table()) + .and_then(|t| t.get("write_dirs")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| seen.insert(s.clone())) + .collect() + }) + .unwrap_or_default() +} + /// Holds the relay sidecar child process handle. pub struct RelayState { child: Arc>>, @@ -1799,6 +1824,65 @@ async fn set_toon_output(enabled: bool) -> Result<(), String> { write_config(&table) } +/// Get the list of directories sandbox scripts may write into. +/// Returns an empty list (the relay's own default) if the config is missing, +/// malformed, has no `[relay]` section, or has no `write_dirs` field. +#[tauri::command] +async fn get_write_dirs() -> Result, String> { + Ok(read_write_dirs()) +} + +#[tauri::command] +async fn set_write_dirs(dirs: Vec) -> Result<(), String> { + // Fail fast at the Tauri boundary: the native picker only produces + // absolute paths, and the relay rejects relative entries on reload — + // but never persist an entry the relay would later flag. + if let Some(bad) = dirs + .iter() + .find(|d| d.is_empty() || !std::path::Path::new(d).is_absolute()) + { + return Err(format!( + "write_dirs entries must be absolute paths, got: {bad:?}" + )); + } + + let mut table = read_config().unwrap_or_else(|_| toml::Table::new()); + + // Ensure [relay] section exists. The relay's `RelayConfig` requires + // `machine_name`, so populate it from the system hostname when creating + // the section from scratch — otherwise the relay's next config reload + // would fail to deserialize. + let relay = table + .entry("relay") + .or_insert_with(|| { + let mut t = toml::Table::new(); + let machine_name = hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown".to_string()); + t.insert( + "machine_name".to_string(), + toml::Value::String(machine_name), + ); + toml::Value::Table(t) + }) + .as_table_mut() + .ok_or("Invalid [relay] section in config")?; + + // De-duplicate while preserving first-seen order so repeated picker + // selections never produce duplicate entries on disk. + let mut seen = std::collections::HashSet::new(); + let deduped: Vec = dirs + .into_iter() + .filter(|d| seen.insert(d.clone())) + .map(toml::Value::String) + .collect(); + + relay.insert("write_dirs".to_string(), toml::Value::Array(deduped)); + + write_config(&table) +} + /// Get the current update channel ("stable" or "beta"). #[tauri::command] async fn get_update_channel() -> Result { @@ -2515,6 +2599,8 @@ pub fn run() { set_js_execution_mode, get_toon_output, set_toon_output, + get_write_dirs, + set_write_dirs, get_config_path_display, get_buffered_relay_logs, get_update_channel, @@ -3810,6 +3896,289 @@ mod toon_output_tests { } } +#[cfg(test)] +mod write_dirs_tests { + //! Round-trip coverage for `read_write_dirs` and `set_write_dirs`. + //! Mirrors `toon_output_tests` but pins the default to an empty list — + //! a missing field, missing section, missing file, or malformed file all + //! resolve to no writable directories. + use super::*; + use serial_test::serial; + use tempfile::TempDir; + + struct HomeGuard { + prior: Option, + _tmp: TempDir, + } + + impl HomeGuard { + fn new() -> Self { + let prior = std::env::var("HOME").ok(); + let tmp = tempfile::tempdir().expect("create tempdir"); + std::env::set_var("HOME", tmp.path()); + Self { prior, _tmp: tmp } + } + } + + impl Drop for HomeGuard { + fn drop(&mut self) { + match &self.prior { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime") + } + + fn write_config_str(contents: &str) { + let path = config_path().expect("config_path"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent dir"); + } + std::fs::write(&path, contents).expect("write config.toml"); + } + + fn read_config_str() -> String { + let path = config_path().expect("config_path"); + std::fs::read_to_string(&path).expect("read config.toml") + } + + #[test] + #[serial] + fn get_write_dirs_returns_entries_when_set() { + let _home = HomeGuard::new(); + write_config_str("[relay]\nmachine_name = \"x\"\nwrite_dirs = [\"/tmp/a\", \"/tmp/b\"]\n"); + assert_eq!(read_write_dirs(), vec!["/tmp/a", "/tmp/b"]); + } + + #[test] + #[serial] + fn get_write_dirs_returns_empty_when_field_missing() { + let _home = HomeGuard::new(); + write_config_str("[relay]\nmachine_name = \"x\"\n"); + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn get_write_dirs_returns_empty_when_no_relay_section() { + let _home = HomeGuard::new(); + write_config_str("[desktop]\nupdate_channel = \"stable\"\n"); + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn get_write_dirs_returns_empty_when_file_missing() { + let _home = HomeGuard::new(); + // No config.toml written. + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn get_write_dirs_returns_empty_when_file_malformed() { + let _home = HomeGuard::new(); + write_config_str("not valid toml ====\n"); + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn set_write_dirs_roundtrips() { + let _home = HomeGuard::new(); + let rt = rt(); + write_config_str("[relay]\nmachine_name = \"x\"\n"); + + rt.block_on(set_write_dirs(vec![ + "/tmp/a".to_string(), + "/tmp/b".to_string(), + ])) + .expect("set write_dirs"); + assert_eq!(read_write_dirs(), vec!["/tmp/a", "/tmp/b"]); + + rt.block_on(set_write_dirs(vec![])) + .expect("clear write_dirs"); + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn get_write_dirs_deduplicates_preserving_order() { + let _home = HomeGuard::new(); + write_config_str( + "[relay]\nmachine_name = \"x\"\nwrite_dirs = [\"/tmp/a\", \"/tmp/b\", \"/tmp/a\"]\n", + ); + assert_eq!(read_write_dirs(), vec!["/tmp/a", "/tmp/b"]); + } + + #[test] + #[serial] + fn set_write_dirs_rejects_relative_and_empty_paths() { + let _home = HomeGuard::new(); + let rt = rt(); + write_config_str("[relay]\nmachine_name = \"x\"\n"); + + for bad in ["relative/path", "", "./dot"] { + let err = rt + .block_on(set_write_dirs(vec!["/tmp/a".to_string(), bad.to_string()])) + .expect_err("non-absolute entry should be rejected"); + assert!(err.contains("absolute"), "unexpected error: {err}"); + } + // Nothing was persisted by the rejected calls. + assert!(read_write_dirs().is_empty()); + } + + #[test] + #[serial] + fn set_write_dirs_deduplicates_preserving_order() { + let _home = HomeGuard::new(); + let rt = rt(); + write_config_str("[relay]\nmachine_name = \"x\"\n"); + + rt.block_on(set_write_dirs(vec![ + "/tmp/a".to_string(), + "/tmp/b".to_string(), + "/tmp/a".to_string(), + ])) + .expect("set write_dirs"); + + assert_eq!(read_write_dirs(), vec!["/tmp/a", "/tmp/b"]); + } + + #[test] + #[serial] + fn set_write_dirs_creates_missing_relay_section() { + let _home = HomeGuard::new(); + let rt = rt(); + write_config_str("[desktop]\nupdate_channel = \"stable\"\n"); + + rt.block_on(set_write_dirs(vec!["/tmp/a".to_string()])) + .expect("set_write_dirs should succeed"); + + let toml_str = read_config_str(); + let parsed: toml::Table = + toml::from_str(&toml_str).expect("re-parse config.toml as toml::Table"); + + let relay = parsed + .get("relay") + .and_then(|v| v.as_table()) + .expect("[relay] section should exist"); + let dirs = relay + .get("write_dirs") + .and_then(|v| v.as_array()) + .expect("write_dirs should be set"); + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0].as_str(), Some("/tmp/a")); + let machine_name = relay + .get("machine_name") + .and_then(|v| v.as_str()) + .expect("machine_name should be set"); + assert!( + !machine_name.is_empty(), + "machine_name should be non-empty, got {machine_name:?}" + ); + } + + #[test] + #[serial] + fn set_write_dirs_preserves_other_fields() { + let _home = HomeGuard::new(); + let rt = rt(); + write_config_str( + "[desktop]\n\ + update_channel = \"beta\"\n\ + \n\ + [relay]\n\ + machine_name = \"host\"\n\ + token_dir = \"/tmp/x\"\n\ + local_js_execution = true\n\ + toon_output = false\n\ + \n\ + [[endpoints]]\n\ + name = \"gmail-acct\"\n\ + transport = \"stdio\"\n\ + tool_prefix = \"gmail\"\n\ + command = \"echo\"\n\ + args = [\"hi\"]\n", + ); + + rt.block_on(set_write_dirs(vec!["/tmp/allowed".to_string()])) + .expect("set_write_dirs should succeed"); + + let toml_str = read_config_str(); + let parsed: toml::Table = + toml::from_str(&toml_str).expect("re-parse config.toml as toml::Table"); + + let desktop = parsed + .get("desktop") + .and_then(|v| v.as_table()) + .expect("[desktop] preserved"); + assert_eq!( + desktop.get("update_channel").and_then(|v| v.as_str()), + Some("beta"), + "update_channel should be preserved" + ); + + let relay = parsed + .get("relay") + .and_then(|v| v.as_table()) + .expect("[relay] preserved"); + assert_eq!( + relay.get("machine_name").and_then(|v| v.as_str()), + Some("host"), + "machine_name should be preserved" + ); + assert_eq!( + relay.get("token_dir").and_then(|v| v.as_str()), + Some("/tmp/x"), + "token_dir should be preserved" + ); + assert_eq!( + relay.get("local_js_execution").and_then(|v| v.as_bool()), + Some(true), + "local_js_execution should be preserved" + ); + assert_eq!( + relay.get("toon_output").and_then(|v| v.as_bool()), + Some(false), + "toon_output should be preserved" + ); + let dirs = relay + .get("write_dirs") + .and_then(|v| v.as_array()) + .expect("write_dirs should be set"); + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0].as_str(), Some("/tmp/allowed")); + + let endpoints = parsed + .get("endpoints") + .and_then(|v| v.as_array()) + .expect("[[endpoints]] preserved"); + assert_eq!(endpoints.len(), 1, "endpoint count should be unchanged"); + let ep = endpoints[0].as_table().expect("endpoint is a table"); + assert_eq!(ep.get("name").and_then(|v| v.as_str()), Some("gmail-acct")); + assert_eq!(ep.get("transport").and_then(|v| v.as_str()), Some("stdio")); + assert_eq!( + ep.get("tool_prefix").and_then(|v| v.as_str()), + Some("gmail") + ); + assert_eq!(ep.get("command").and_then(|v| v.as_str()), Some("echo")); + let args = ep + .get("args") + .and_then(|v| v.as_array()) + .expect("args preserved"); + assert_eq!(args.len(), 1); + assert_eq!(args[0].as_str(), Some("hi")); + } +} + #[cfg(test)] mod parse_endpoint_from_span_tests { //! Coverage for [`parse_endpoint_from_span`] — the helper that lifts the diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index c2d4b2c..726ac99 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -1,5 +1,5 @@