From a302aadb342424e5d17a37aae5611bb3232f761c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 24 Mar 2026 16:28:48 -0700 Subject: [PATCH] feat(desktop): migrate legacy data dir and support dev/prod side-by-side (#168) --- desktop/src-tauri/src/commands/agents.rs | 9 +- desktop/src-tauri/src/lib.rs | 6 + .../src-tauri/src/managed_agents/backend.rs | 6 +- desktop/src-tauri/src/managed_agents/types.rs | 9 +- desktop/src-tauri/src/migration.rs | 286 ++++++++++++++++++ desktop/src-tauri/src/relay.rs | 2 +- desktop/src-tauri/src/util.rs | 50 +-- desktop/src-tauri/tauri.dev.conf.json | 4 + justfile | 8 +- 9 files changed, 334 insertions(+), 46 deletions(-) create mode 100644 desktop/src-tauri/src/migration.rs create mode 100644 desktop/src-tauri/tauri.dev.conf.json diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 4150fcfd2f..9128497d5f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -431,7 +431,7 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| managed_agent_avatar_url(agent.agent_command.as_str())); - let profile_sync_error = match sync_managed_agent_profile( + let profile_sync_error = (sync_managed_agent_profile( &state, &resolved_relay_url, &agent_keys, @@ -440,11 +440,8 @@ pub async fn create_managed_agent( &name, avatar_url.as_deref(), ) - .await - { - Ok(()) => None, - Err(error) => Some(error), - }; + .await) + .err(); // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 31829cafea..cb5b390f4c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod app_state; mod commands; mod events; mod managed_agents; +mod migration; mod models; mod relay; mod util; @@ -189,6 +190,11 @@ pub fn run() { .setup(|app| { let app_handle = app.handle().clone(); + // Migrate data from the legacy `com.wesb.sprout` directory before + // resolving identity, so the persisted key is available at the new + // path on first launch after the identifier change. + migration::migrate_legacy_data_dir(&app_handle); + // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity // that will be lost on restart, as that silently breaks channel diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 88d2bbcf79..2b60104569 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -244,10 +244,10 @@ fn split_config_key(key: &str) -> Vec { // Start a new word on: (a) transition from lowercase to uppercase, or // (b) uppercase followed by lowercase (end of acronym run, e.g. "APIKey" → "API" + "Key"). let prev_lower = - !current.is_empty() && current.chars().last().map_or(false, |c| c.is_lowercase()); + !current.is_empty() && current.chars().last().is_some_and(|c| c.is_lowercase()); let acronym_end = !current.is_empty() - && current.chars().last().map_or(false, |c| c.is_uppercase()) - && chars.get(i + 1).map_or(false, |c| c.is_lowercase()); + && current.chars().last().is_some_and(|c| c.is_uppercase()) + && chars.get(i + 1).is_some_and(|c| c.is_lowercase()); if prev_lower || acronym_end { words.push(current.to_lowercase()); current.clear(); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index bb3a400dfa..94df1bd74d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -2,9 +2,10 @@ use std::{path::PathBuf, process::Child}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BackendKind { + #[default] Local, Provider { id: String, @@ -12,12 +13,6 @@ pub enum BackendKind { }, } -impl Default for BackendKind { - fn default() -> Self { - Self::Local - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersonaRecord { pub id: String, diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs new file mode 100644 index 0000000000..9f8b6b752d --- /dev/null +++ b/desktop/src-tauri/src/migration.rs @@ -0,0 +1,286 @@ +//! Per-file migration from the legacy `com.wesb.sprout` app data directory +//! to the current `xyz.block.sprout.app` directory. +//! +//! On each launch, for every known file in [`LEGACY_FILES`]: +//! - If the file **already exists** at the new path → skip (it's its own guard). +//! - If the file exists at the old path but not the new → copy it over. +//! +//! Each `std::fs::copy` is atomic per-file, so there is no partial-migration +//! problem and no sentinel file is needed. +//! +//! The legacy directory is intentionally **not** deleted — users can clean it +//! up manually once they're satisfied everything works. +//! +//! Errors are logged but never fatal; the app must still start even if +//! individual file copies fail. +//! +//! **Note on dev/prod side-by-side:** Both the production build +//! (`xyz.block.sprout.app`) and the dev build (`xyz.block.sprout.app.dev`) +//! will attempt to migrate from the same legacy `com.wesb.sprout` directory, +//! resulting in duplicated identity keys. To avoid this, set the +//! `SPROUT_PRIVATE_KEY` env var when running the dev build — this bypasses +//! file-based identity resolution entirely. + +use std::path::{Path, PathBuf}; +use tauri::Manager; + +const LEGACY_DATA_DIR_NAME: &str = "com.wesb.sprout"; + +/// Known files to migrate from the legacy data directory. +/// +/// Agent logs and `.window-state.json` are excluded — logs are ephemeral and +/// the window-state plugin recreates its file automatically. +const LEGACY_FILES: &[&str] = &[ + "identity.key", + "agents/managed-agents.json", + "agents/personas.json", + "agents/teams.json", +]; + +/// Compute the legacy `com.wesb.sprout` data directory path by replacing the +/// last component of the current app data directory. +fn legacy_data_dir(current: &Path) -> Option { + current.parent().map(|p| p.join(LEGACY_DATA_DIR_NAME)) +} + +/// Copy a single file from `old_dir/rel` to `new_dir/rel`, creating parent +/// directories as needed. Skips the file if it already exists at the +/// destination. +/// +/// Returns `true` if the file was copied, `false` if skipped or missing. +fn migrate_file(old_dir: &Path, new_dir: &Path, rel: &str) -> bool { + let src = old_dir.join(rel); + let dst = new_dir.join(rel); + + if dst.exists() { + return false; // Already present — nothing to do. + } + + if !src.exists() { + return false; // Nothing to migrate. + } + + // Ensure parent directories exist (e.g. `agents/`). + if let Some(parent) = dst.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "sprout-desktop: migration: failed to create {}: {e}", + parent.display() + ); + return false; + } + } + + match std::fs::copy(&src, &dst) { + Ok(_) => { + eprintln!("sprout-desktop: migration: copied {rel}"); + true + } + Err(e) => { + eprintln!("sprout-desktop: migration: failed to copy {rel}: {e}"); + false + } + } +} + +/// Migrate known files from the legacy `com.wesb.sprout` app data directory +/// to the current directory. +/// +/// Called in `setup()` **before** `resolve_persisted_identity` so the persisted +/// key is available at the new path on first launch after the identifier change. +pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) { + let current_dir = match app.path().app_data_dir() { + Ok(dir) => dir, + Err(e) => { + eprintln!("sprout-desktop: migration: cannot resolve app data dir: {e}"); + return; + } + }; + + let old_dir = match legacy_data_dir(¤t_dir) { + Some(dir) => dir, + None => { + eprintln!("sprout-desktop: migration: cannot compute legacy data dir (no parent)"); + return; + } + }; + + if !old_dir.exists() { + return; // Nothing to migrate. + } + + let mut copied = 0u32; + for rel in LEGACY_FILES { + if migrate_file(&old_dir, ¤t_dir, rel) { + copied += 1; + } + } + + if copied > 0 { + eprintln!("sprout-desktop: migration: {copied} file(s) migrated from legacy data dir"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_data_dir_replaces_last_component() { + let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app"); + let legacy = legacy_data_dir(¤t).unwrap(); + assert_eq!( + legacy, + PathBuf::from("/Users/me/Library/Application Support/com.wesb.sprout") + ); + } + + #[test] + fn legacy_data_dir_returns_none_for_root() { + let current = PathBuf::from("/"); + let _ = legacy_data_dir(¤t); + } + + /// Helper: create a temp dir structure mimicking the old `com.wesb.sprout` + /// layout and return `(parent_dir, old_dir, new_dir)`. + fn setup_legacy_layout() -> (tempfile::TempDir, PathBuf, PathBuf) { + let parent = tempfile::tempdir().unwrap(); + let old_dir = parent.path().join(LEGACY_DATA_DIR_NAME); + let new_dir = parent.path().join("xyz.block.sprout.app"); + + std::fs::create_dir_all(old_dir.join("agents")).unwrap(); + std::fs::write(old_dir.join("identity.key"), "nsec1-fake-key-data").unwrap(); + std::fs::write( + old_dir.join("agents/managed-agents.json"), + r#"[{"id":"a1"}]"#, + ) + .unwrap(); + std::fs::write(old_dir.join("agents/personas.json"), "[]").unwrap(); + std::fs::write(old_dir.join("agents/teams.json"), "[]").unwrap(); + + (parent, old_dir, new_dir) + } + + #[test] + fn migrate_file_copies_when_missing_at_dest() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + assert!(migrate_file(&old_dir, &new_dir, "identity.key")); + assert_eq!( + std::fs::read_to_string(new_dir.join("identity.key")).unwrap(), + "nsec1-fake-key-data" + ); + } + + #[test] + fn migrate_file_creates_parent_dirs() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + // agents/ doesn't exist in new_dir yet. + assert!(migrate_file(&old_dir, &new_dir, "agents/teams.json")); + assert_eq!( + std::fs::read_to_string(new_dir.join("agents/teams.json")).unwrap(), + "[]" + ); + } + + #[test] + fn migrate_file_skips_when_dest_exists() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + // Pre-create the file at the new location with different content. + std::fs::create_dir_all(&new_dir).unwrap(); + std::fs::write(new_dir.join("identity.key"), "nsec1-new-key").unwrap(); + + // Should skip — returns false. + assert!(!migrate_file(&old_dir, &new_dir, "identity.key")); + + // Original content preserved. + assert_eq!( + std::fs::read_to_string(new_dir.join("identity.key")).unwrap(), + "nsec1-new-key" + ); + } + + #[test] + fn migrate_file_skips_when_source_missing() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + // File that doesn't exist in old dir. + assert!(!migrate_file(&old_dir, &new_dir, "nonexistent.json")); + assert!(!new_dir.join("nonexistent.json").exists()); + } + + #[test] + fn migrate_all_known_files() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + let mut copied = 0u32; + for rel in LEGACY_FILES { + if migrate_file(&old_dir, &new_dir, rel) { + copied += 1; + } + } + + assert_eq!(copied, 4); + assert_eq!( + std::fs::read_to_string(new_dir.join("identity.key")).unwrap(), + "nsec1-fake-key-data" + ); + assert_eq!( + std::fs::read_to_string(new_dir.join("agents/managed-agents.json")).unwrap(), + r#"[{"id":"a1"}]"# + ); + assert_eq!( + std::fs::read_to_string(new_dir.join("agents/personas.json")).unwrap(), + "[]" + ); + assert_eq!( + std::fs::read_to_string(new_dir.join("agents/teams.json")).unwrap(), + "[]" + ); + + // Old directory must still exist. + assert!(old_dir.exists()); + } + + #[test] + fn migrate_is_idempotent() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + // First pass: copies everything. + let first_pass: u32 = LEGACY_FILES + .iter() + .map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel))) + .sum(); + assert_eq!(first_pass, 4); + + // Second pass: skips everything (all files already exist). + let second_pass: u32 = LEGACY_FILES + .iter() + .map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel))) + .sum(); + assert_eq!(second_pass, 0); + } + + #[test] + fn migrate_partial_only_copies_missing() { + let (_parent, old_dir, new_dir) = setup_legacy_layout(); + + // Pre-create identity.key in new dir — only the other 3 should copy. + std::fs::create_dir_all(&new_dir).unwrap(); + std::fs::write(new_dir.join("identity.key"), "nsec1-already-here").unwrap(); + + let copied: u32 = LEGACY_FILES + .iter() + .map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel))) + .sum(); + assert_eq!(copied, 3); + + // identity.key should be untouched. + assert_eq!( + std::fs::read_to_string(new_dir.join("identity.key")).unwrap(), + "nsec1-already-here" + ); + } +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 580df1de7a..63a8fd2c03 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -171,7 +171,7 @@ pub fn build_nip98_auth_header_for_keys( let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|error| format!("sign failed: {error}"))?; Ok(format!( diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index d439248c88..ff65b5818d 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -27,6 +27,31 @@ pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String { raw.trim_end_matches('-').to_string() } +pub fn percent_encode(input: &str) -> String { + let mut encoded = String::with_capacity(input.len()); + + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + let high = char::from_digit((byte >> 4) as u32, 16) + .expect("nibble 0-15 is always a valid hex digit") + .to_ascii_uppercase(); + let low = char::from_digit((byte & 0x0f) as u32, 16) + .expect("nibble 0-15 is always a valid hex digit") + .to_ascii_uppercase(); + encoded.push('%'); + encoded.push(high); + encoded.push(low); + } + } + } + + encoded +} + #[cfg(test)] mod tests { use super::slugify; @@ -61,28 +86,3 @@ mod tests { assert_eq!(slugify("abcde fghij", "x", 10), "abcde"); } } - -pub fn percent_encode(input: &str) -> String { - let mut encoded = String::with_capacity(input.len()); - - for byte in input.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - _ => { - let high = char::from_digit((byte >> 4) as u32, 16) - .expect("nibble 0-15 is always a valid hex digit") - .to_ascii_uppercase(); - let low = char::from_digit((byte & 0x0f) as u32, 16) - .expect("nibble 0-15 is always a valid hex digit") - .to_ascii_uppercase(); - encoded.push('%'); - encoded.push(high); - encoded.push(low); - } - } - } - - encoded -} diff --git a/desktop/src-tauri/tauri.dev.conf.json b/desktop/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000000..30902f2f55 --- /dev/null +++ b/desktop/src-tauri/tauri.dev.conf.json @@ -0,0 +1,4 @@ +{ + "identifier": "xyz.block.sprout.app.dev", + "productName": "Sprout Dev" +} diff --git a/justfile b/justfile index 435d599be5..24c20b9c93 100644 --- a/justfile +++ b/justfile @@ -139,17 +139,17 @@ proxy: proxy-release: cargo run -p sprout-proxy --release -# Run the desktop Tauri app in dev mode +# Run the desktop Tauri app in dev mode (uses dev identifier for side-by-side with production) dev *ARGS: - cd {{desktop_dir}} && pnpm tauri dev {{ARGS}} + cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}} # Run the desktop frontend dev server desktop-dev: cd {{desktop_dir}} && pnpm dev -# Run the desktop Tauri app +# Run the desktop Tauri app (uses dev identifier for side-by-side with production) desktop-app *ARGS: - cd {{desktop_dir}} && pnpm tauri dev {{ARGS}} + cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}} # ─── Desktop Release ──────────────────────────────────────────────────────────