diff --git a/Cargo.lock b/Cargo.lock index 252402bb..8ee994ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1640,6 +1640,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fslock" version = "0.2.1" @@ -2574,6 +2583,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2804,6 +2833,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.2" @@ -3115,6 +3164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3291,6 +3341,7 @@ dependencies = [ "reqwest-middleware", "runtimed", "runtimelib", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml", @@ -3309,6 +3360,45 @@ dependencies = [ "uuid", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -5257,7 +5347,7 @@ dependencies = [ [[package]] name = "runtimed" -version = "0.1.0" +version = "0.1.0-dev.1" dependencies = [ "anyhow", "automerge", @@ -5268,6 +5358,8 @@ dependencies = [ "futures", "libc", "log", + "notify", + "notify-debouncer-mini", "rattler", "rattler_cache", "rattler_conda_types", @@ -5276,6 +5368,7 @@ dependencies = [ "rattler_virtual_packages", "reqwest", "reqwest-middleware", + "schemars 1.2.1", "serde", "serde_json", "tempfile", @@ -5507,7 +5600,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -5534,6 +5627,7 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -5550,6 +5644,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -8375,6 +8481,10 @@ dependencies = [ [[package]] name = "xtask" version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "serde_json", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index bd94ca47..6ae0c801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ tokio = { version = "1.36.0", features = ["full"] } runtimelib = { version = "1.3.0", default-features = false } jupyter-protocol = "1.2.1" thiserror = "1" +schemars = "1" +notify = "8" +notify-debouncer-mini = "0.7" [workspace.lints.rust] dead_code = "warn" diff --git a/contributing/runtimed.md b/contributing/runtimed.md index 407e2132..4689d67f 100644 --- a/contributing/runtimed.md +++ b/contributing/runtimed.md @@ -6,6 +6,7 @@ The runtime daemon manages prewarmed Python environments shared across notebook | Task | Command | |------|---------| +| Install daemon from source | `cargo xtask install-daemon` | | Run daemon | `cargo run -p runtimed` | | Run with debug logs | `RUST_LOG=debug cargo run -p runtimed` | | Check status | `cargo run -p runt-cli -- pool status` | @@ -65,15 +66,32 @@ The notebook app automatically tries to connect to or start the daemon on launch runtimed::client::ensure_daemon_running(None).await ``` +### Install daemon from source + +When you change daemon code and want the installed service to pick it up: + +```bash +cargo xtask install-daemon +``` + +This builds runtimed in release mode, stops the running service, replaces the binary, and restarts it. You can verify the version with: + +```bash +cat ~/.cache/runt/daemon.json # check "version" field +``` + ### Manual: Run daemon separately -For debugging daemon-specific code, run it in a separate terminal: +For debugging daemon-specific code, stop the installed service and run from source: ```bash -# Terminal 1: Run daemon +# Stop the installed service first +launchctl bootout gui/$(id -u)/io.runtimed + +# Run daemon with debug logs RUST_LOG=debug cargo run -p runtimed -# Terminal 2: Test with runt CLI +# In another terminal, test with runt CLI cargo run -p runt-cli -- pool ping cargo run -p runt-cli -- pool status cargo run -p runt-cli -- pool take uv diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 3898c56a..c2479219 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -48,6 +48,7 @@ toml = "0.8" serde_yaml = "0.9" pathdiff = "0.2" pyproject-toml = "0.13" +schemars = { workspace = true } # Conda environment support via rattler rattler = "0.39" diff --git a/crates/notebook/src/conda_env.rs b/crates/notebook/src/conda_env.rs index ced7fc5d..1525968f 100644 --- a/crates/notebook/src/conda_env.rs +++ b/crates/notebook/src/conda_env.rs @@ -992,7 +992,7 @@ pub async fn create_prewarmed_conda_environment( // Build dependency list: ipykernel + ipywidgets + user-configured defaults let mut deps_list = vec!["ipykernel".to_string(), "ipywidgets".to_string()]; - let extra: Vec = crate::settings::load_settings().default_conda_packages; + let extra: Vec = crate::settings::load_settings().conda.default_packages; if !extra.is_empty() { info!("[prewarm] Including default packages: {:?}", extra); deps_list.extend(extra); diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 1437a3a5..1d9613d3 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -1849,7 +1849,7 @@ async fn start_default_python_kernel_impl( // Include default uv packages from settings let default_deps: Vec = { let s = settings::load_settings(); - s.default_uv_packages + s.uv.default_packages }; if !default_deps.is_empty() { info!( @@ -2050,7 +2050,7 @@ async fn start_default_python_kernel_impl( let mut conda_deps_list = vec!["ipykernel".to_string()]; { let s = settings::load_settings(); - let extra = s.default_conda_packages; + let extra = s.conda.default_packages; if !extra.is_empty() { info!("[prewarm:conda] Including default packages: {:?}", extra); conda_deps_list.extend(extra); @@ -2914,18 +2914,38 @@ async fn set_default_python_env(env_type: String) -> Result<(), String> { } /// Get synced settings from the Automerge settings document via runtimed. -/// Returns an error if the daemon is unavailable — the frontend should -/// keep its local state (localStorage) rather than overwriting with defaults. +/// Falls back to reading settings.json when the daemon is unavailable, +/// so the frontend always gets real settings instead of hardcoded defaults. #[tauri::command] async fn get_synced_settings() -> Result { - runtimed::sync_client::try_get_synced_settings() - .await - .map_err(|e| e.to_string()) + match runtimed::sync_client::try_get_synced_settings().await { + Ok(settings) => Ok(settings), + Err(e) => { + log::warn!( + "[settings] Daemon unavailable ({}), falling back to settings.json", + e + ); + let local = settings::load_settings(); + Ok(runtimed::settings_doc::SyncedSettings { + theme: local.theme, + default_runtime: local.default_runtime.to_string(), + default_python_env: local.default_python_env.to_string(), + uv: local.uv, + conda: local.conda, + }) + } + } } /// Persist a setting to local settings.json (for keys that have local representation). fn save_setting_locally(key: &str, value: &serde_json::Value) -> Result<(), String> { match key { + "theme" => { + let value_str = value.as_str().ok_or("expected string")?; + let mut s = settings::load_settings(); + s.theme = value_str.to_string(); + settings::save_settings(&s).map_err(|e| e.to_string()) + } "default_runtime" => { let value_str = value.as_str().ok_or("expected string")?; let runtime: Runtime = @@ -2948,16 +2968,15 @@ fn save_setting_locally(key: &str, value: &serde_json::Value) -> Result<(), Stri "uv.default_packages" => { let packages = json_value_to_string_vec(value); let mut s = settings::load_settings(); - s.default_uv_packages = packages; + s.uv.default_packages = packages; settings::save_settings(&s).map_err(|e| e.to_string()) } "conda.default_packages" => { let packages = json_value_to_string_vec(value); let mut s = settings::load_settings(); - s.default_conda_packages = packages; + s.conda.default_packages = packages; settings::save_settings(&s).map_err(|e| e.to_string()) } - // Theme has no local fallback in settings.json (handled by localStorage) _ => Ok(()), } } diff --git a/crates/notebook/src/runtime.rs b/crates/notebook/src/runtime.rs index 446d40bb..fb9b95f1 100644 --- a/crates/notebook/src/runtime.rs +++ b/crates/notebook/src/runtime.rs @@ -1,9 +1,10 @@ //! Runtime type for notebooks - Python or Deno +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Supported notebook runtime environments -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum Runtime { #[default] diff --git a/crates/notebook/src/settings.rs b/crates/notebook/src/settings.rs index 4c749560..5b56edc0 100644 --- a/crates/notebook/src/settings.rs +++ b/crates/notebook/src/settings.rs @@ -4,14 +4,19 @@ //! - macOS: ~/Library/Application Support/runt-notebook/settings.json //! - Linux: ~/.config/runt-notebook/settings.json //! - Windows: C:\Users\\AppData\Roaming\runt-notebook\settings.json +//! +//! The JSON schema matches `runtimed::settings_doc::SyncedSettings` so both +//! the daemon and the notebook write the same format to settings.json. use crate::runtime::Runtime; use anyhow::Result; -use serde::{Deserialize, Deserializer, Serialize}; +use runtimed::settings_doc::{CondaDefaults, UvDefaults}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Python environment type for dependency management -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum PythonEnvType { /// Use uv for Python package management (fast, pip-compatible) @@ -30,9 +35,24 @@ impl std::fmt::Display for PythonEnvType { } } -/// Application settings for notebook preferences -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Application settings for notebook preferences. +/// +/// Serializes to the same nested JSON schema as `SyncedSettings`: +/// ```json +/// { +/// "theme": "system", +/// "default_runtime": "python", +/// "default_python_env": "uv", +/// "uv": { "default_packages": ["numpy"] }, +/// "conda": { "default_packages": [] } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AppSettings { + /// UI theme: "system", "light", or "dark" + #[serde(default = "default_theme")] + pub theme: String, + /// Default runtime for new notebooks (used by Cmd+N) #[serde(default)] pub default_runtime: Runtime, @@ -41,65 +61,27 @@ pub struct AppSettings { #[serde(default)] pub default_python_env: PythonEnvType, - /// Default packages for prewarmed uv environments - #[serde(default, deserialize_with = "deserialize_package_list")] - pub default_uv_packages: Vec, + /// UV environment defaults + #[serde(default)] + pub uv: UvDefaults, - /// Default packages for prewarmed conda environments - #[serde(default, deserialize_with = "deserialize_package_list")] - pub default_conda_packages: Vec, + /// Conda environment defaults + #[serde(default)] + pub conda: CondaDefaults, } -/// Deserialize a package list that accepts both: -/// - Old format: `"numpy, pandas, matplotlib"` (comma-separated string) -/// - New format: `["numpy", "pandas", "matplotlib"]` (JSON array) -fn deserialize_package_list<'de, D>(deserializer: D) -> std::result::Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de; - - struct PackageListVisitor; - - impl<'de> de::Visitor<'de> for PackageListVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string or array of strings") - } - - fn visit_str(self, v: &str) -> std::result::Result, E> { - Ok(v.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect()) - } - - fn visit_seq>( - self, - mut seq: A, - ) -> std::result::Result, A::Error> { - let mut items = Vec::new(); - while let Some(item) = seq.next_element::()? { - let trimmed = item.trim().to_string(); - if !trimmed.is_empty() { - items.push(trimmed); - } - } - Ok(items) - } - } - - deserializer.deserialize_any(PackageListVisitor) +fn default_theme() -> String { + "system".to_string() } impl Default for AppSettings { fn default() -> Self { Self { + theme: default_theme(), default_runtime: Runtime::Python, default_python_env: PythonEnvType::Uv, - default_uv_packages: vec![], - default_conda_packages: vec![], + uv: UvDefaults::default(), + conda: CondaDefaults::default(), } } } @@ -112,7 +94,7 @@ fn settings_path() -> PathBuf { .join("settings.json") } -/// Load settings from disk, returning defaults if file doesn't exist +/// Load settings from disk, returning defaults if file doesn't exist. pub fn load_settings() -> AppSettings { let path = settings_path(); if path.exists() { @@ -131,7 +113,8 @@ pub fn save_settings(settings: &AppSettings) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, serde_json::to_string_pretty(settings)?)?; + let json = serde_json::to_string_pretty(settings)?; + std::fs::write(&path, format!("{json}\n"))?; Ok(()) } @@ -142,64 +125,56 @@ mod tests { #[test] fn test_default_settings() { let settings = AppSettings::default(); + assert_eq!(settings.theme, "system"); assert_eq!(settings.default_runtime, Runtime::Python); assert_eq!(settings.default_python_env, PythonEnvType::Uv); - assert!(settings.default_uv_packages.is_empty()); - assert!(settings.default_conda_packages.is_empty()); + assert!(settings.uv.default_packages.is_empty()); + assert!(settings.conda.default_packages.is_empty()); } #[test] - fn test_settings_serde() { + fn test_settings_serde_nested_format() { let settings = AppSettings { + theme: "dark".to_string(), default_runtime: Runtime::Deno, default_python_env: PythonEnvType::Uv, - default_uv_packages: vec!["numpy".into(), "pandas".into()], - default_conda_packages: vec![], + uv: UvDefaults { + default_packages: vec!["numpy".into(), "pandas".into()], + }, + conda: CondaDefaults::default(), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: AppSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.theme, "dark"); assert_eq!(parsed.default_runtime, Runtime::Deno); assert_eq!(parsed.default_python_env, PythonEnvType::Uv); - assert_eq!(parsed.default_uv_packages, vec!["numpy", "pandas"]); - } - - #[test] - fn test_deserialize_old_comma_format() { - let json = r#"{ - "default_runtime": "python", - "default_python_env": "uv", - "default_uv_packages": "numpy, pandas, matplotlib", - "default_conda_packages": "scipy" - }"#; - let parsed: AppSettings = serde_json::from_str(json).unwrap(); - assert_eq!( - parsed.default_uv_packages, - vec!["numpy", "pandas", "matplotlib"] - ); - assert_eq!(parsed.default_conda_packages, vec!["scipy"]); + assert_eq!(parsed.uv.default_packages, vec!["numpy", "pandas"]); } #[test] - fn test_deserialize_new_array_format() { + fn test_deserialize_nested_format() { let json = r#"{ + "theme": "dark", "default_runtime": "python", "default_python_env": "uv", - "default_uv_packages": ["numpy", "pandas"], - "default_conda_packages": ["scipy", "scikit-learn"] + "uv": { "default_packages": ["numpy", "pandas"] }, + "conda": { "default_packages": ["scipy"] } }"#; let parsed: AppSettings = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.default_uv_packages, vec!["numpy", "pandas"]); - assert_eq!(parsed.default_conda_packages, vec!["scipy", "scikit-learn"]); + assert_eq!(parsed.theme, "dark"); + assert_eq!(parsed.uv.default_packages, vec!["numpy", "pandas"]); + assert_eq!(parsed.conda.default_packages, vec!["scipy"]); } #[test] - fn test_deserialize_missing_packages() { + fn test_deserialize_missing_fields_defaults() { let json = r#"{"default_runtime": "python"}"#; let parsed: AppSettings = serde_json::from_str(json).unwrap(); - assert!(parsed.default_uv_packages.is_empty()); - assert!(parsed.default_conda_packages.is_empty()); + assert_eq!(parsed.theme, "system"); + assert!(parsed.uv.default_packages.is_empty()); + assert!(parsed.conda.default_packages.is_empty()); } #[test] @@ -222,4 +197,33 @@ mod tests { let path = settings_path(); assert!(path.ends_with("runt-notebook/settings.json")); } + + #[test] + fn test_serialized_format_matches_synced_settings() { + let settings = AppSettings { + theme: "dark".to_string(), + default_runtime: Runtime::Python, + default_python_env: PythonEnvType::Uv, + uv: UvDefaults { + default_packages: vec!["numpy".into()], + }, + conda: CondaDefaults { + default_packages: vec!["scipy".into()], + }, + }; + + let json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); + + // Verify nested structure matches SyncedSettings + assert_eq!(json["theme"], "dark"); + assert_eq!(json["default_runtime"], "python"); + assert_eq!(json["default_python_env"], "uv"); + assert_eq!(json["uv"]["default_packages"][0], "numpy"); + assert_eq!(json["conda"]["default_packages"][0], "scipy"); + + // Verify no old flat keys + assert!(json.get("default_uv_packages").is_none()); + assert!(json.get("default_conda_packages").is_none()); + } } diff --git a/crates/notebook/src/uv_env.rs b/crates/notebook/src/uv_env.rs index 2bf3f775..26bf9922 100644 --- a/crates/notebook/src/uv_env.rs +++ b/crates/notebook/src/uv_env.rs @@ -390,7 +390,7 @@ pub async fn find_existing_prewarmed_environments() -> Vec { /// Read the `default_uv_packages` setting. fn parse_extra_packages() -> Vec { - crate::settings::load_settings().default_uv_packages + crate::settings::load_settings().uv.default_packages } /// Create a prewarmed environment with ipykernel, ipywidgets, and any diff --git a/crates/runtimed/Cargo.toml b/crates/runtimed/Cargo.toml index aa8ac651..8602ad93 100644 --- a/crates/runtimed/Cargo.toml +++ b/crates/runtimed/Cargo.toml @@ -44,6 +44,13 @@ reqwest-middleware = "0.4" # Automerge CRDT for settings sync automerge = "0.7" +# JSON Schema generation +schemars = { workspace = true } + +# File watching for external settings.json changes +notify = { workspace = true } +notify-debouncer-mini = { workspace = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/runtimed/src/daemon.rs b/crates/runtimed/src/daemon.rs index 5187cdb7..2204baaa 100644 --- a/crates/runtimed/src/daemon.rs +++ b/crates/runtimed/src/daemon.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::time::Instant; use log::{error, info, warn}; +use notify_debouncer_mini::DebounceEventResult; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::sync::{Mutex, Notify}; @@ -236,6 +237,12 @@ impl Daemon { conda_daemon.conda_warming_loop().await; }); + // Spawn the settings.json file watcher + let watcher_daemon = self.clone(); + tokio::spawn(async move { + watcher_daemon.watch_settings_json().await; + }); + // Spawn the settings sync server let sync_socket_path = self.config.sync_socket_path.clone(); let sync_settings = self.settings.clone(); @@ -365,6 +372,135 @@ impl Daemon { Ok(()) } + /// Watch `settings.json` for external changes and apply them to the Automerge doc. + /// + /// Uses the `notify` crate with a 500ms debouncer. When changes are detected, + /// reads the file, parses it, and selectively applies any differences to the + /// Automerge settings document. Self-writes (from `persist_settings`) are + /// automatically skipped because the file contents match the doc state. + async fn watch_settings_json(self: Arc) { + let json_path = crate::settings_json_path(); + + // Determine which path to watch: the file itself if it exists, + // or the parent directory if it doesn't exist yet. + let watch_path = if json_path.exists() { + json_path.clone() + } else if let Some(parent) = json_path.parent() { + // Watch parent directory; we'll filter for our file in the handler + if !parent.exists() { + if let Err(e) = std::fs::create_dir_all(parent) { + error!("[settings-watch] Failed to create config dir: {}", e); + return; + } + } + parent.to_path_buf() + } else { + error!( + "[settings-watch] Cannot determine watch path for {:?}", + json_path + ); + return; + }; + + // Create a tokio mpsc channel to bridge from the notify callback thread + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + + // Create debouncer with 500ms window + let debouncer_result = notify_debouncer_mini::new_debouncer( + std::time::Duration::from_millis(500), + move |res: DebounceEventResult| { + let _ = tx.blocking_send(res); + }, + ); + + let mut debouncer = match debouncer_result { + Ok(d) => d, + Err(e) => { + error!("[settings-watch] Failed to create file watcher: {}", e); + return; + } + }; + + if let Err(e) = debouncer + .watcher() + .watch(&watch_path, notify::RecursiveMode::NonRecursive) + { + error!("[settings-watch] Failed to watch {:?}: {}", watch_path, e); + return; + } + + info!( + "[settings-watch] Watching {:?} for external changes", + watch_path + ); + + loop { + tokio::select! { + Some(result) = rx.recv() => { + match result { + Ok(events) => { + // Check if any event is for our settings file + let relevant = events.iter().any(|e| e.path == json_path); + if !relevant { + continue; + } + + // Read and parse the file + let contents = match tokio::fs::read_to_string(&json_path).await { + Ok(c) => c, + Err(e) => { + // File may have been deleted or is being written + warn!("[settings-watch] Cannot read settings.json: {}", e); + continue; + } + }; + + let json: serde_json::Value = match serde_json::from_str(&contents) { + Ok(j) => j, + Err(e) => { + // Partial write or invalid JSON — try again next event + warn!("[settings-watch] Cannot parse settings.json: {}", e); + continue; + } + }; + + // Apply changes to the Automerge doc + let changed = { + let mut doc = self.settings.write().await; + let changed = doc.apply_json_changes(&json); + if changed { + // Only persist the Automerge binary — do NOT write + // the JSON mirror back, as serde_json formatting + // differs from editors (e.g. arrays expand to one + // element per line) which causes unwanted churn. + let automerge_path = crate::default_settings_doc_path(); + if let Err(e) = doc.save_to_file(&automerge_path) { + warn!("[settings-watch] Failed to save Automerge doc: {}", e); + } + } + changed + }; + + if changed { + info!("[settings-watch] Applied external settings.json changes"); + let _ = self.settings_changed.send(()); + } + } + Err(errs) => { + warn!("[settings-watch] Watch error: {:?}", errs); + } + } + } + _ = self.shutdown_notify.notified() => { + if *self.shutdown.lock().await { + info!("[settings-watch] Shutting down"); + break; + } + } + } + } + } + /// Find and reuse existing runtimed environments from previous runs. async fn find_existing_environments(&self) { let cache_dir = &self.config.cache_dir; diff --git a/crates/runtimed/src/settings_doc.rs b/crates/runtimed/src/settings_doc.rs index febccdab..56bb01c5 100644 --- a/crates/runtimed/src/settings_doc.rs +++ b/crates/runtimed/src/settings_doc.rs @@ -25,22 +25,23 @@ use automerge::sync::SyncDoc; use automerge::transaction::Transactable; use automerge::{AutoCommit, AutomergeError, ObjId, ObjType, ReadDoc}; use log::info; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Default packages for uv environments. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct UvDefaults { pub default_packages: Vec, } /// Default packages for conda environments. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct CondaDefaults { pub default_packages: Vec, } /// Snapshot of all synced settings. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct SyncedSettings { pub theme: String, pub default_runtime: String, @@ -136,12 +137,12 @@ impl SettingsDoc { } /// Create a settings document from parsed JSON (for migration from settings.json). - /// - /// Handles both old flat format (`default_uv_packages: "numpy, pandas"`) - /// and new nested format (`uv: { default_packages: ["numpy", "pandas"] }`). fn from_json(json: &serde_json::Value) -> Self { let mut settings = Self::new(); + if let Some(theme) = json.get("theme").and_then(|v| v.as_str()) { + settings.put("theme", theme); + } if let Some(runtime) = json.get("default_runtime").and_then(|v| v.as_str()) { settings.put("default_runtime", runtime); } @@ -149,46 +150,29 @@ impl SettingsDoc { settings.put("default_python_env", env); } - // Try new nested format first, then fall back to old flat format - let uv_packages = Self::extract_packages_from_json(json, "uv", "default_uv_packages"); + let uv_packages = Self::extract_packages_from_json(json, "uv"); if !uv_packages.is_empty() { settings.put_list("uv.default_packages", &uv_packages); } - let conda_packages = - Self::extract_packages_from_json(json, "conda", "default_conda_packages"); + let conda_packages = Self::extract_packages_from_json(json, "conda"); if !conda_packages.is_empty() { settings.put_list("conda.default_packages", &conda_packages); } - // Theme was never in settings.json, so it stays at the default ("system"). settings } - /// Extract packages from JSON, trying nested format then flat comma-separated. - fn extract_packages_from_json( - json: &serde_json::Value, - nested_key: &str, - flat_key: &str, - ) -> Vec { - // Try nested: { "uv": { "default_packages": ["numpy", "pandas"] } } + /// Extract packages from a nested JSON key (e.g. `uv.default_packages`). + fn extract_packages_from_json(json: &serde_json::Value, nested_key: &str) -> Vec { if let Some(nested) = json.get(nested_key).and_then(|v| v.as_object()) { if let Some(arr) = nested.get("default_packages").and_then(|v| v.as_array()) { - let pkgs: Vec = arr + return arr .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); - if !pkgs.is_empty() { - return pkgs; - } } } - - // Fall back to flat: { "default_uv_packages": "numpy, pandas" } - if let Some(comma_str) = json.get(flat_key).and_then(|v| v.as_str()) { - return split_comma_list(comma_str); - } - vec![] } @@ -245,7 +229,7 @@ impl SettingsDoc { } let settings = self.get_all(); let json = serde_json::to_string_pretty(&settings).map_err(std::io::Error::other)?; - std::fs::write(path, json) + std::fs::write(path, format!("{json}\n")) } // ── Scalar accessors ───────────────────────────────────────────── @@ -435,6 +419,44 @@ impl SettingsDoc { ) -> Result<(), AutomergeError> { self.doc.sync().receive_sync_message(peer_state, message) } + + /// Selectively apply external JSON changes to the Automerge doc. + /// + /// Only updates fields that are **present** in the JSON and **differ** from + /// the current document state. Returns `true` if any field was modified. + pub(crate) fn apply_json_changes(&mut self, json: &serde_json::Value) -> bool { + let mut changed = false; + + // Scalar fields — only update if present in JSON and different + for key in &["theme", "default_runtime", "default_python_env"] { + if let Some(value) = json.get(key).and_then(|v| v.as_str()) { + if self.get(key).as_deref() != Some(value) { + self.put(key, value); + changed = true; + } + } + } + + // UV packages + if json.get("uv").is_some() { + let uv_packages = Self::extract_packages_from_json(json, "uv"); + if self.get_list("uv.default_packages") != uv_packages { + self.put_list("uv.default_packages", &uv_packages); + changed = true; + } + } + + // Conda packages + if json.get("conda").is_some() { + let conda_packages = Self::extract_packages_from_json(json, "conda"); + if self.get_list("conda.default_packages") != conda_packages { + self.put_list("conda.default_packages", &conda_packages); + changed = true; + } + } + + changed + } } impl Default for SettingsDoc { @@ -659,32 +681,6 @@ mod tests { assert_eq!(loaded.get_flat("default_conda_packages"), None); } - #[test] - fn test_migrate_from_json_flat_format() { - let tmp = TempDir::new().unwrap(); - let automerge_path = tmp.path().join("settings.automerge"); - let json_path = tmp.path().join("settings.json"); - - // Write old-format settings.json - std::fs::write( - &json_path, - r#"{"default_runtime":"deno","default_python_env":"conda","default_uv_packages":"numpy, pandas","default_conda_packages":"scipy, scikit-learn"}"#, - ) - .unwrap(); - - let doc = SettingsDoc::load_or_create(&automerge_path, Some(&json_path)); - let settings = doc.get_all(); - - assert_eq!(settings.default_runtime, "deno"); - assert_eq!(settings.default_python_env, "conda"); - assert_eq!(settings.uv.default_packages, vec!["numpy", "pandas"]); - assert_eq!( - settings.conda.default_packages, - vec!["scipy", "scikit-learn"] - ); - assert_eq!(settings.theme, "system"); // Theme was never in settings.json - } - #[test] fn test_migrate_from_json_nested_format() { let tmp = TempDir::new().unwrap(); @@ -826,4 +822,77 @@ mod tests { doc.put("new_section.key", "value"); assert_eq!(doc.get("new_section.key"), Some("value".to_string())); } + + #[test] + fn test_apply_json_changes_detects_difference() { + let mut doc = SettingsDoc::new(); + assert_eq!(doc.get("theme"), Some("system".to_string())); + + let json = serde_json::json!({ + "theme": "dark", + "default_runtime": "deno", + }); + let changed = doc.apply_json_changes(&json); + assert!(changed); + assert_eq!(doc.get("theme"), Some("dark".to_string())); + assert_eq!(doc.get("default_runtime"), Some("deno".to_string())); + // Unchanged fields stay the same + assert_eq!(doc.get("default_python_env"), Some("uv".to_string())); + } + + #[test] + fn test_apply_json_changes_no_change_when_matching() { + let doc = SettingsDoc::new(); + let settings = doc.get_all(); + + // Write current values back — should detect no change + let json = serde_json::to_value(&settings).unwrap(); + let mut doc = SettingsDoc::new(); + let changed = doc.apply_json_changes(&json); + assert!(!changed); + } + + #[test] + fn test_apply_json_changes_skips_absent_fields() { + let mut doc = SettingsDoc::new(); + doc.put("theme", "dark"); + + // JSON without theme key — should NOT reset theme + let json = serde_json::json!({ + "default_runtime": "python", + }); + let changed = doc.apply_json_changes(&json); + assert!(!changed); // runtime already "python" + assert_eq!(doc.get("theme"), Some("dark".to_string())); // preserved + } + + #[test] + fn test_apply_json_changes_nested_packages() { + let mut doc = SettingsDoc::new(); + + let json = serde_json::json!({ + "uv": { "default_packages": ["numpy", "pandas"] }, + "conda": { "default_packages": ["scipy"] }, + }); + let changed = doc.apply_json_changes(&json); + assert!(changed); + assert_eq!(doc.get_list("uv.default_packages"), vec!["numpy", "pandas"]); + assert_eq!(doc.get_list("conda.default_packages"), vec!["scipy"]); + } + + #[test] + fn test_apply_json_changes_packages_no_change() { + let mut doc = SettingsDoc::new(); + doc.put_list( + "uv.default_packages", + &["numpy".to_string(), "pandas".to_string()], + ); + + // Same packages — should detect no change + let json = serde_json::json!({ + "uv": { "default_packages": ["numpy", "pandas"] }, + }); + let changed = doc.apply_json_changes(&json); + assert!(!changed); + } } diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index e00f16b3..3ad185b6 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -8,3 +8,5 @@ publish = false workspace = true [dependencies] +dirs = "5" +serde_json.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index a23aef9b..34fbcd63 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -26,6 +26,7 @@ fn main() { "build-e2e" => cmd_build_e2e(), "build-dmg" => cmd_build_dmg(), "build-app" => cmd_build_app(), + "install-daemon" => cmd_install_daemon(), "--help" | "-h" | "help" => print_help(), cmd => { eprintln!("Unknown command: {cmd}"); @@ -51,6 +52,9 @@ Release: build-app Build .app bundle with icons build-dmg Build DMG with icons (for CI) +Daemon: + install-daemon Build and install runtimed into the running service + Other: icons [source.png] Generate icon variants help Show this help @@ -204,6 +208,153 @@ fn build_with_bundle(bundle: &str) { println!("Build complete!"); } +/// Build runtimed and install it into the running launchd/systemd service. +/// +/// This is the dev workflow for testing daemon changes: +/// 1. Build runtimed in release mode +/// 2. Stop the running service +/// 3. Copy the new binary over the installed one +/// 4. Restart the service +fn cmd_install_daemon() { + println!("Building runtimed (release)..."); + run_cmd("cargo", &["build", "--release", "-p", "runtimed"]); + + let source = if cfg!(windows) { + "target/release/runtimed.exe" + } else { + "target/release/runtimed" + }; + + if !Path::new(source).exists() { + eprintln!("Build succeeded but binary not found at {source}"); + exit(1); + } + + // Use runtimed's own service manager to perform the upgrade. + // The `runtimed install` CLI already handles stop → copy → chmod → start. + // We call `runtimed upgrade --from ` if available, otherwise + // fall back to the manual stop/copy/start dance. + println!("Installing daemon..."); + + // Stop the running daemon gracefully + #[cfg(target_os = "macos")] + { + let uid = Command::new("id") + .args(["-u"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "501".to_string()); + let domain = format!("gui/{uid}/io.runtimed"); + + // Stop (ignore errors — may not be running) + let _ = Command::new("launchctl") + .args(["bootout", &domain]) + .status(); + + // Brief pause for process cleanup + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + #[cfg(target_os = "linux")] + { + let _ = Command::new("systemctl") + .args(["--user", "stop", "runtimed.service"]) + .status(); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + // Determine install path (matches runtimed::service::default_binary_path) + let install_dir = dirs::data_local_dir() + .expect("Could not determine data directory") + .join("runt") + .join("bin"); + + let install_path = if cfg!(windows) { + install_dir.join("runtimed.exe") + } else { + install_dir.join("runtimed") + }; + + if !install_path.exists() { + eprintln!( + "No existing daemon installation found at {}", + install_path.display() + ); + eprintln!("Run the app once first to install the daemon service."); + exit(1); + } + + // Copy new binary + fs::copy(source, &install_path).unwrap_or_else(|e| { + eprintln!("Failed to copy binary: {e}"); + exit(1); + }); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&install_path, fs::Permissions::from_mode(0o755)).unwrap_or_else(|e| { + eprintln!("Failed to set permissions: {e}"); + exit(1); + }); + } + + println!("Installed to {}", install_path.display()); + + // Restart the service + #[cfg(target_os = "macos")] + { + let plist = dirs::home_dir() + .expect("No home dir") + .join("Library/LaunchAgents/io.runtimed.plist"); + if plist.exists() { + let uid = Command::new("id") + .args(["-u"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "501".to_string()); + let domain = format!("gui/{uid}"); + run_cmd( + "launchctl", + &["bootstrap", &domain, &plist.to_string_lossy()], + ); + } else { + eprintln!("Warning: launchd plist not found at {}", plist.display()); + eprintln!("Start manually with: {}", install_path.display()); + } + } + + #[cfg(target_os = "linux")] + { + run_cmd("systemctl", &["--user", "start", "runtimed.service"]); + } + + // Wait briefly and verify + std::thread::sleep(std::time::Duration::from_secs(2)); + let daemon_json = dirs::cache_dir() + .unwrap_or_else(|| Path::new("/tmp").to_path_buf()) + .join("runt") + .join("daemon.json"); + + if daemon_json.exists() { + if let Ok(contents) = fs::read_to_string(&daemon_json) { + if let Ok(info) = serde_json::from_str::(&contents) { + if let Some(version) = info.get("version").and_then(|v| v.as_str()) { + println!("Daemon running: version {version}"); + return; + } + } + } + } + + println!("Daemon restarted (could not verify version from daemon.json)"); +} + /// Build external binaries (runtimed daemon and runt CLI) for Tauri bundling. fn build_runtimed_daemon() { build_external_binary("runtimed", "runtimed"); diff --git a/docs/settings.md b/docs/settings.md index 8dc2ec23..3a3f0b78 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -6,7 +6,7 @@ Runt notebook settings control default behavior for new notebooks, appearance, a | Setting | Options | Default | Stored In | |---------|---------|---------|-----------| -| Theme | light, dark, system | system | Synced (Automerge) + localStorage for FOUC | +| Theme | light, dark, system | system | Synced (Automerge) + settings file | | Default runtime | python, deno | python | Synced (Automerge) + settings file | | Default Python env | uv, conda | uv | Synced (Automerge) + settings file | | Default uv packages | list of strings | (empty) | Synced (Automerge) + settings file | @@ -17,7 +17,9 @@ Runt notebook settings control default behavior for new notebooks, appearance, a Settings are synced across all notebook windows via the runtimed daemon using Automerge. The daemon holds the canonical document; each notebook window maintains a local replica. - **Source of truth:** The Automerge document in the daemon -- **Persistence:** Settings are also written to `settings.json` as a local fallback (for Cmd+N menu behavior and daemon-unavailable scenarios) +- **Persistence:** Settings are also written to `settings.json` in the same nested format +- **External edits:** The daemon watches `settings.json` for external changes (manual edits, CLI tools) and propagates them to all connected windows automatically +- **Fallback:** When the daemon is unavailable, settings are read directly from `settings.json` - **Theme special case:** Theme also uses browser localStorage to prevent a flash of unstyled content on startup When you change a setting in any window, it propagates to all other open windows in real time. @@ -41,7 +43,7 @@ Environment-specific settings (packages, future: channels) live under `uv/` and ## Settings File -Settings are persisted to a JSON file shared across all notebook windows. +Settings are persisted to a JSON file shared across all notebook windows. Both the daemon and the notebook app write the same nested JSON format. | Platform | Path | |----------|------| @@ -49,26 +51,27 @@ Settings are persisted to a JSON file shared across all notebook windows. | Linux | `~/.config/runt-notebook/settings.json` | | Windows | `C:\Users\\AppData\Roaming\runt-notebook\settings.json` | -The file is created automatically when you first change a setting. You can also create or edit it by hand. +The file is created automatically when you first change a setting. You can also edit it by hand — changes are detected and applied automatically when the daemon is running. Example: ```json { + "theme": "system", "default_runtime": "python", "default_python_env": "uv", - "default_uv_packages": ["numpy", "pandas", "matplotlib"], - "default_conda_packages": ["numpy", "pandas", "scikit-learn"] + "uv": { + "default_packages": ["numpy", "pandas", "matplotlib"] + }, + "conda": { + "default_packages": ["numpy", "pandas", "scikit-learn"] + } } ``` -For backward compatibility, the old comma-separated string format is also accepted when reading: +### JSON Schema -```json -{ - "default_uv_packages": "numpy, pandas, matplotlib" -} -``` +The settings structs derive `schemars::JsonSchema`. Both `SyncedSettings` (in runtimed) and `AppSettings` (in the notebook crate) serialize to the same JSON schema. ## Theme @@ -119,8 +122,12 @@ Since uv and conda have different package ecosystems, packages are configured se ```json { - "default_uv_packages": ["numpy", "pandas", "matplotlib"], - "default_conda_packages": ["numpy", "pandas", "scikit-learn"] + "uv": { + "default_packages": ["numpy", "pandas", "matplotlib"] + }, + "conda": { + "default_packages": ["numpy", "pandas", "scikit-learn"] + } } ```