diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c70c6852d..a90353ed5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,7 +184,11 @@ cargo run -p agent --example probe -- claude \ `TCODE_DATA_DIR` points Tcode at a throwaway profile (its own sessions, settings and installed ACP agents) — useful for demos, screenshots and trying a change -without touching your real threads. +without touching your real threads. The data directory is otherwise `~/.tcode`. +`LEGACY_TCODE_DATA_DIR` names an older data directory that a start moves into +the data directory (by default an earlier version's platform app-data +directory, and only when `TCODE_DATA_DIR` is unset); set both to try the move +on a copy of real data. **Launch flags**: `--open-latest` reopens the most recent thread, `--connect ` starts attached to a paired host, `--pair ` diff --git a/Cargo.lock b/Cargo.lock index 6ec093588..4b54d3656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10767,6 +10767,7 @@ name = "tcode-ios" version = "0.1.0" dependencies = [ "async-channel", + "dirs 7.0.0", "gpui-ios", "gpui-pre", "log", @@ -10842,7 +10843,6 @@ dependencies = [ "async-channel", "base64 0.23.1", "core-foundation 0.9.4", - "dirs 7.0.0", "env_logger", "flate2", "futures-lite", diff --git a/README.md b/README.md index 84c71c142..3c17eed4d 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ can provide a shared project icon. The interface is localized and follows your system language; you can override it in Settings. Everything Tcode stores — sessions, settings, installed ACP agents — -lives under your platform's app-data directory. +lives in `~/.tcode` on every platform. A data directory an earlier version kept +in your platform's app-data directory moves there on the first start.
Tcode in dark mode diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index f9782394f..3024d92fa 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -417,7 +417,8 @@ fn main() { // The local kernel is process composition, not a property of the window's // current attachment. Open its store unconditionally and keep it alive even // when the window starts on, or later switches to, a remote host. - let store = SessionStore::open_default().expect("failed to open tcode data directory"); + let store = + SessionStore::open_host(None).unwrap_or_else(|error| exit_with_startup_failure(error)); let data_dir = store.root().clone(); // The UI never resolves a data directory of its own: whatever client-owned // files it needs (the WebView2 profile) live under this one. @@ -431,6 +432,23 @@ fn main() { ); if let Some(index) = args.iter().position(|arg| arg == "--pair") { + // Pairing writes hosts.json and device.json, at which a later move of + // an older data dir would stop. + match store.pending_relocation() { + Ok(None) => {} + Ok(Some(previous)) => { + eprintln!( + "tcode: {} has not been moved into {} yet; start Tcode once first, then pair", + previous.display(), + data_dir.display() + ); + std::process::exit(1); + } + Err(error) => { + eprintln!("tcode: {error}"); + std::process::exit(1); + } + } match pair_command(&args[index + 1..], &native_client) { Ok(host_id) => println!("{host_id}"), Err(error) => { @@ -441,22 +459,28 @@ fn main() { return; } - let initial_target = match arg_value(&args, "--connect") { - Some(host_id) => { - let Some(host) = native_client - .load_hosts() - .into_iter() - .find(|host| host.host_id == host_id) - else { - eprintln!( - "tcode: no added machine with id {host_id:?} in {}/hosts.json; add it first (the sidebar's Machines row, or tcode --pair )", - data_dir.display() - ); - std::process::exit(1); - }; - AttachmentTarget::Remote(host) + // Read once the data dir is in place, which may be after the migration. + let connect = arg_value(&args, "--connect"); + let initial_target = { + let native_client = native_client.clone(); + let data_dir = data_dir.clone(); + move || match connect { + Some(host_id) => { + let Some(host) = native_client + .load_hosts() + .into_iter() + .find(|host| host.host_id == host_id) + else { + eprintln!( + "tcode: no added machine with id {host_id:?} in {}/hosts.json; add it first (the sidebar's Machines row, or tcode --pair )", + data_dir.display() + ); + std::process::exit(1); + }; + AttachmentTarget::Remote(host) + } + None => AttachmentTarget::Local, } - None => AttachmentTarget::Local, }; // An older build's threads move into tcode.db before the kernel starts, // behind a window of their own. With nothing to migrate, the kernel @@ -526,6 +550,7 @@ fn main() { }); let launch = move |cx: &mut App, kernel: Rc| { + let initial_target = initial_target(); let local_settings = kernel.settings(); // Hosting belongs to the process-owned local kernel; it carries no // current-attachment mode. @@ -582,7 +607,7 @@ fn main() { setup: ShellSetup { client_host: Some(native_client.clone()), local: Some(Rc::new(move || local_kernel.transport())), - initial: Some(initial_target.clone()), + initial: Some(initial_target), initial_pairing_error: None, // Only here: bootstrap applies locale and theme from the // host's own settings before the first frame. diff --git a/crates/app/src/migration.rs b/crates/app/src/migration.rs index 66a0377b8..5f8d973ef 100644 --- a/crates/app/src/migration.rs +++ b/crates/app/src/migration.rs @@ -1,4 +1,5 @@ -//! The one-time migration into `tcode.db`, run before the local kernel starts +//! Preparing the data dir — moving an older build's data dir into it, then +//! migrating older threads into `tcode.db` — before the local kernel starts, //! behind a window that offers nothing but its progress and Quit. use std::sync::Arc; @@ -26,6 +27,7 @@ enum Status { struct MigrationView { status: Status, cancel: Arc, + data_dir: String, } /// The view a Quit reaches while the migration window is up. @@ -61,6 +63,7 @@ pub(crate) fn run( let view = cx.new(|_| MigrationView { status: Status::Running(None), cancel: cancel.clone(), + data_dir: store.root().display().to_string(), }); cx.set_global(Running(view.clone())); @@ -235,8 +238,8 @@ impl Render for MigrationView { Status::Running(progress) => ( progress.as_ref().map_or(0., overall_percent), progress.as_ref().map_or_else( - || phase_line(MigrationPhase::Scanning, None), - |progress| phase_line(progress.phase, Some(progress)), + || phase_line(MigrationPhase::Scanning, None, &self.data_dir), + |progress| phase_line(progress.phase, Some(progress), &self.data_dir), ), false, ), @@ -256,7 +259,10 @@ impl Render for MigrationView { .child( div() .text_color(theme.muted_foreground) - .child(tcode_ui::tr!("migration.description")), + .child(tcode_ui::tr!( + "migration.description", + path = self.data_dir.clone() + )), ) .when(!failed, |column| { column.child( @@ -277,9 +283,11 @@ impl Render for MigrationView { } } -/// Import and verification each go through every log once, but import -/// writes and syncs each chunk while verification only reads it back, which -/// takes a fraction of the time. The phases around them take a moment. +/// A move by rename counts entries; a copy across filesystems counts bytes, +/// and gets a bar of its own before the threads migrate. Import and +/// verification each go through every log once, but import writes and syncs +/// each chunk while verification only reads it back, which takes a fraction +/// of the time. The phases around them take a moment. fn overall_percent(progress: &MigrationProgress) -> f32 { let fraction = |done: u64, total: u64| { if total == 0 { @@ -290,6 +298,10 @@ fn overall_percent(progress: &MigrationProgress) -> f32 { }; let phase = fraction(progress.bytes_done, progress.bytes_total); match progress.phase { + MigrationPhase::Relocating if progress.bytes_total == 0 => { + fraction(progress.threads_done as u64, progress.threads_total as u64) * 100. + } + MigrationPhase::Relocating => phase * 100., MigrationPhase::Scanning => 0., MigrationPhase::Importing => phase * 85., MigrationPhase::Verifying => 85. + phase * 15., @@ -297,7 +309,11 @@ fn overall_percent(progress: &MigrationProgress) -> f32 { } } -fn phase_line(phase: MigrationPhase, progress: Option<&MigrationProgress>) -> String { +fn phase_line( + phase: MigrationPhase, + progress: Option<&MigrationProgress>, + data_dir: &str, +) -> String { let counts = |key: &str| { let progress = progress.copied().unwrap_or(MigrationProgress { phase, @@ -311,11 +327,18 @@ fn phase_line(phase: MigrationPhase, progress: Option<&MigrationProgress>) -> St done = progress.threads_done, total = progress.threads_total, bytes = tcode_ui::format_size(progress.bytes_done as usize), - total_bytes = tcode_ui::format_size(progress.bytes_total as usize) + total_bytes = tcode_ui::format_size(progress.bytes_total as usize), + path = data_dir ) .into_owned() }; match phase { + MigrationPhase::Relocating if progress.is_some_and(|progress| progress.bytes_total > 0) => { + counts("migration.relocating_copy") + } + MigrationPhase::Relocating => { + tcode_ui::tr!("migration.relocating", path = data_dir).into_owned() + } MigrationPhase::Scanning => tcode_ui::tr!("migration.scanning").into_owned(), MigrationPhase::Importing => counts("migration.importing"), MigrationPhase::Verifying => counts("migration.verifying"), diff --git a/crates/headless/src/main.rs b/crates/headless/src/main.rs index f6e401cd3..b389b7d21 100644 --- a/crates/headless/src/main.rs +++ b/crates/headless/src/main.rs @@ -108,12 +108,14 @@ fn serve_command(args: &[String]) -> Result<(), String> { "--port", ], )?; - let store = match data_dir { - Some(path) => SessionStore::open_at(path), - None => SessionStore::open_default(), - } - .map_err(|error| format!("could not open session store: {error}"))?; + let store = SessionStore::open_host(data_dir) + .map_err(|error| format!("could not open session store: {error}"))?; let remote_data_dir = store.root().clone(); + // The password lives in the data dir, which may still have to move in. + install_interrupt_handler(); + if migrate_store(&store)? == Migration::Cancelled { + return Ok(()); + } if let Some(password) = option_value(args, "--password").or_else(|| std::env::var("TCODE_PASSWORD").ok()) { @@ -121,10 +123,6 @@ fn serve_command(args: &[String]) -> Result<(), String> { } // Nothing else starts for a bind the listener would refuse anyway. check_bind(browser_listen, &remote_data_dir).map_err(|error| error.to_string())?; - install_interrupt_handler(); - if migrate_store(&store)? == Migration::Cancelled { - return Ok(()); - } let mut services = HostServices { background_startup_probes: true, ai_title_generation: true, @@ -240,19 +238,28 @@ fn serve_command(args: &[String]) -> Result<(), String> { Ok(()) } -/// Move an older build's threads into `tcode.db` before the host starts, -/// printing progress; an interrupt cancels it and leaves them as they were. +/// Move an older build's data dir in and its threads into `tcode.db` before +/// the host starts, printing progress; an interrupt cancels it, losing +/// nothing, and the next start continues. fn migrate_store(store: &SessionStore) -> Result { - let needed = store - .needs_migration() - .map_err(|error| format!("could not open session store: {error}"))?; + let open_error = |error: std::io::Error| format!("could not open session store: {error}"); + let needed = store.needs_migration().map_err(open_error)?; if !needed { return Ok(Migration::Completed); } - println!( - "Migrating threads into {}; the original files are kept in legacy/. Ctrl-C cancels.", - store.root().join("tcode.db").display() - ); + match store.pending_relocation().map_err(open_error)? { + Some(previous) => println!( + "Moving {} to {}, then migrating any older threads into tcode.db. Ctrl-C cancels; \ + the next start continues.", + previous.display(), + store.root().display() + ), + None => println!( + "Migrating threads into {}; each copy is verified before the original files are \ + removed. Ctrl-C cancels.", + store.root().join("tcode.db").display() + ), + } let mut last: Option<(MigrationPhase, Instant)> = None; let outcome = store .migrate( @@ -270,22 +277,25 @@ fn migrate_store(store: &SessionStore) -> Result { .map_err(|error| format!("migration failed: {error}"))?; match outcome { Migration::Completed => println!("Migration complete"), - Migration::Cancelled => println!("Migration cancelled; no file was changed"), + Migration::Cancelled => { + println!("Migration cancelled; nothing was lost, and the next start continues") + } } Ok(outcome) } fn migration_line(progress: &MigrationProgress) -> String { - let phase = match progress.phase { - MigrationPhase::Scanning => "scanning", - MigrationPhase::Importing => "importing", - MigrationPhase::Verifying => "verifying", - MigrationPhase::Publishing => "publishing", - MigrationPhase::Archiving => "archiving", + let (phase, items) = match progress.phase { + MigrationPhase::Relocating => ("moving", "entries"), + MigrationPhase::Scanning => ("scanning", "threads"), + MigrationPhase::Importing => ("importing", "threads"), + MigrationPhase::Verifying => ("verifying", "threads"), + MigrationPhase::Publishing => ("publishing", "threads"), + MigrationPhase::Archiving => ("archiving", "threads"), }; let mib = |bytes: u64| bytes as f64 / (1024. * 1024.); format!( - "{phase}: {}/{} threads, {:.1}/{:.1} MiB", + "{phase}: {}/{} {items}, {:.1}/{:.1} MiB", progress.threads_done, progress.threads_total, mib(progress.bytes_done), @@ -304,11 +314,19 @@ fn set_password_command(args: &[String]) -> Result<(), String> { let password = option_value(&values, "--password") .or_else(|| std::env::var("TCODE_PASSWORD").ok()) .ok_or("supply --password or TCODE_PASSWORD")?; - let store = match option_value(&values, "--data-dir") { - Some(path) => SessionStore::open_at(PathBuf::from(path)), - None => SessionStore::open_default(), + let store = SessionStore::open_host(option_value(&values, "--data-dir").map(PathBuf::from)) + .map_err(|error| error.to_string())?; + // The move would stop at the password file this writes. + if let Some(previous) = store + .pending_relocation() + .map_err(|error| error.to_string())? + { + return Err(format!( + "{} has not been moved into {} yet; run `tcode-headless serve` once first", + previous.display(), + store.root().display() + )); } - .map_err(|error| error.to_string())?; set_password(store.root(), &password, revoke).map_err(|error| error.to_string())?; println!( "Password changed. {}", @@ -356,11 +374,8 @@ fn sync_invitation_file(data_dir: &Path, invitation: Option<&Invitation>) -> Res fn pair_command(args: &[String]) -> Result<(), String> { reject_unknown_options(args, &["--data-dir"])?; - let store = match option_value(args, "--data-dir") { - Some(path) => SessionStore::open_at(PathBuf::from(path)), - None => SessionStore::open_default(), - } - .map_err(|error| error.to_string())?; + let store = SessionStore::open_host(option_value(args, "--data-dir").map(PathBuf::from)) + .map_err(|error| error.to_string())?; let path = store.root().join(INVITATION_FILE); let file: Option = std::fs::read(&path) .ok() diff --git a/crates/headless/tests/migration.rs b/crates/headless/tests/migration.rs index 3a8c47fc7..b53a474b1 100644 --- a/crates/headless/tests/migration.rs +++ b/crates/headless/tests/migration.rs @@ -54,6 +54,8 @@ fn sigint_cancels_the_startup_migration_and_leaves_every_source_as_it_was() { let mut serve = tcode_services::process::command(env!("CARGO_BIN_EXE_tcode-headless")) .args(["serve", "--traverse", "off", "--data-dir"]) .arg(&root) + // Would move that directory into `root`. + .env_remove("LEGACY_TCODE_DATA_DIR") .stdout(Stdio::piped()) .spawn() .unwrap(); diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml index 0e0a61cb3..4740afd5c 100644 --- a/crates/ios/Cargo.toml +++ b/crates/ios/Cargo.toml @@ -20,3 +20,4 @@ tcode-client = { path = "../client" } tcode-traverse = { path = "../traverse", default-features = false, features = ["native"] } tcode-ui = { path = "../ui", default-features = false } async-channel = "2" +dirs = "7" diff --git a/crates/ios/src/host.rs b/crates/ios/src/host.rs index fff0da3bb..fdf86a393 100644 --- a/crates/ios/src/host.rs +++ b/crates/ios/src/host.rs @@ -86,7 +86,12 @@ pub(crate) fn native_host(cx: &mut gpui::App) -> (Rc, Option HostFuture<'static, Result> { diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 49a37a1aa..42f56d4d2 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -1524,11 +1524,6 @@ mod tests { ); assert!(!data_root.join("sessions.json").exists(), "{start}"); assert!(!data_root.join("legacy-thread.jsonl").exists(), "{start}"); - assert_eq!( - std::fs::read_to_string(data_root.join("legacy/legacy-thread.jsonl")).unwrap(), - log, - "{start}" - ); } std::fs::remove_dir_all(data_root).unwrap(); } diff --git a/crates/services/src/store/migrate.rs b/crates/services/src/store/migrate.rs index aaa99107a..090fc8da3 100644 --- a/crates/services/src/store/migrate.rs +++ b/crates/services/src/store/migrate.rs @@ -16,6 +16,9 @@ //! `tcode.db.archive`, and the staged file is renamed to `tcode.db`; the //! directory is synced around both. //! 5. The sources move into `legacy/`, and the archive list is removed. +//! `legacy/` is deleted once the store has opened `tcode.db` +//! ([`remove_legacy`]), so a published database that does not open still +//! has its sources beside it. //! //! Nothing before step 4's rename writes, renames or removes a source: the //! steps before it only read them. A migration cancelled or failed before @@ -50,7 +53,9 @@ const VERIFY_ROWS: i64 = 4096; /// Where a running migration is. Each of the importing and verifying phases /// goes through every log once, so their counts start again from zero; -/// publishing and archiving report everything done. +/// publishing and archiving report everything done. While relocating, the +/// thread counts are the older data dir's entries and the byte counts what a +/// copy across filesystems has written; a move by rename reports no bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MigrationProgress { pub phase: MigrationPhase, @@ -62,6 +67,8 @@ pub struct MigrationProgress { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MigrationPhase { + /// Moving an older build's data dir into this one, before anything else. + Relocating, /// Listing the logs and importing the index; the totals are not known /// until it ends. Scanning, @@ -74,10 +81,12 @@ pub enum MigrationPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Migration { - /// `tcode.db` is complete and the sources are in `legacy/`. + /// The data dir is in place and `tcode.db` is complete. Completed, - /// The staging files are gone, no `tcode.db` exists and every source is - /// where it was. + /// Stopped before either was: entries already moved into the data dir + /// stay there and the rest stay in the older one, and a JSONL migration + /// left no staging file, no `tcode.db` and every source where it was. The + /// next start continues. Cancelled, } @@ -137,7 +146,7 @@ pub(super) fn run( if let Err(cleanup) = remove_staging(root) { log::warn!("could not discard the staged database: {cleanup}"); } - if error.get_ref().is_some_and(|inner| inner.is::()) { + if is_cancelled(&error) { log::info!( "migration into {} cancelled; the sources are unchanged", root.join(DB_FILE).display() @@ -167,7 +176,7 @@ pub(super) fn run( } #[derive(Debug)] -struct Cancelled; +pub(super) struct Cancelled; impl std::fmt::Display for Cancelled { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -177,14 +186,14 @@ impl std::fmt::Display for Cancelled { impl std::error::Error for Cancelled {} -struct Progress<'a> { - report: &'a mut dyn FnMut(MigrationProgress), - cancel: &'a AtomicBool, - current: MigrationProgress, +pub(super) struct Progress<'a> { + pub(super) report: &'a mut dyn FnMut(MigrationProgress), + pub(super) cancel: &'a AtomicBool, + pub(super) current: MigrationProgress, } impl Progress<'_> { - fn check(&self) -> io::Result<()> { + pub(super) fn check(&self) -> io::Result<()> { if self.cancel.load(Ordering::Relaxed) { return Err(io::Error::other(Cancelled)); } @@ -192,7 +201,12 @@ impl Progress<'_> { } /// Begin a cancellable phase over `threads` logs of `bytes` in all. - fn start(&mut self, phase: MigrationPhase, threads: usize, bytes: u64) -> io::Result<()> { + pub(super) fn start( + &mut self, + phase: MigrationPhase, + threads: usize, + bytes: u64, + ) -> io::Result<()> { self.check()?; self.current = MigrationProgress { phase, @@ -205,7 +219,7 @@ impl Progress<'_> { Ok(()) } - fn advance(&mut self, threads: usize, bytes: u64) -> io::Result<()> { + pub(super) fn advance(&mut self, threads: usize, bytes: u64) -> io::Result<()> { self.current.threads_done += threads; self.current.bytes_done += bytes; (self.report)(self.current); @@ -221,6 +235,21 @@ impl Progress<'_> { } } +pub(super) fn is_cancelled(error: &io::Error) -> bool { + error.get_ref().is_some_and(|inner| inner.is::()) +} + +/// Delete `legacy/` and the sources archived in it, once `tcode.db` has +/// opened. A failure is logged and retried by the next start. +pub(super) fn remove_legacy(root: &Path) { + let legacy = root.join(LEGACY_DIR); + match fs::remove_dir_all(&legacy) { + Ok(()) => log::info!("removed {}", legacy.display()), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => log::warn!("could not remove {}: {error}", legacy.display()), + } +} + /// An older build that ran after the migration writes JSON again; this build /// never reads it back. pub(super) fn warn_about_stray_sources(root: &Path) { @@ -448,8 +477,8 @@ fn build_staging(root: &Path, progress: &mut Progress) -> io::Result { } /// Today's tolerance: the object schema or the legacy bare array. An -/// unparseable file is left as it is, to be archived into `legacy/` with the -/// logs, which still migrate. +/// unparseable file is archived into `legacy/` as it is, with the logs, which +/// still migrate. fn read_legacy_index(root: &Path) -> io::Result { let path = root.join(LEGACY_INDEX); let bytes = match fs::read(&path) { @@ -466,7 +495,7 @@ fn read_legacy_index(root: &Path) -> io::Result { Ok(parsed.unwrap_or_else(|error| { log::warn!( "failed to parse {LEGACY_INDEX}: {error}; migrating the event logs without it, and \ - keeping it in {LEGACY_DIR}/" + archiving it with them" ); IndexFile::default() })) @@ -731,19 +760,19 @@ fn archive_sources(root: &Path) -> io::Result<()> { } /// Windows flushes only a handle opened for writing. -fn sync_file(path: &Path) -> io::Result<()> { +pub(super) fn sync_file(path: &Path) -> io::Result<()> { fs::OpenOptions::new().write(true).open(path)?.sync_all() } /// Make a directory's entries (a rename, a new file) durable. #[cfg(unix)] -fn sync_dir(dir: &Path) -> io::Result<()> { +pub(super) fn sync_dir(dir: &Path) -> io::Result<()> { File::open(dir)?.sync_all() } /// NTFS journals renames itself, and std cannot open a directory handle on /// Windows. #[cfg(not(unix))] -fn sync_dir(_dir: &Path) -> io::Result<()> { +pub(super) fn sync_dir(_dir: &Path) -> io::Result<()> { Ok(()) } diff --git a/crates/services/src/store/mod.rs b/crates/services/src/store/mod.rs index e0f7b9553..e762db744 100644 --- a/crates/services/src/store/mod.rs +++ b/crates/services/src/store/mod.rs @@ -1,8 +1,7 @@ //! Persistence for tcode threads. //! //! Projects, thread metadata and every thread's event log live in one Turso -//! database, `tcode.db`, in the platform data dir (e.g. -//! `~/Library/Application Support/tcode/`): +//! database, `tcode.db`, in the data dir ([`data_dir`]): //! * `projects` and `sessions` hold each [`Project`] / [`SessionMeta`] as the //! JSON serde produces for it. //! * `events` holds each thread's log as raw byte segments, one per line @@ -16,6 +15,7 @@ mod db; mod migrate; +mod relocate; #[cfg(test)] mod tests; @@ -38,6 +38,7 @@ use tcode_core::session::StoredEvent; use db::{Broken, Db, SCHEMA_VERSION, blob, integer, is_broken}; +const DATA_DIR_ENV: &str = "TCODE_DATA_DIR"; const DB_FILE: &str = "tcode.db"; /// Held with an OS file lock for as long as a host owns the data dir, which /// covers the migration as well as the open database. It is never deleted: @@ -67,6 +68,9 @@ struct EventEnvelopeRef<'a> { #[derive(Clone)] pub struct SessionStore { root: PathBuf, + /// An older build's data dir that moves into `root` before the store + /// opens ([`SessionStore::migrate`]). + previous: Option, shared: Arc, } @@ -74,10 +78,31 @@ impl std::fmt::Debug for SessionStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SessionStore") .field("root", &self.root) + .field("previous", &self.previous) .finish_non_exhaustive() } } +/// The data dir: `TCODE_DATA_DIR` when it is set — a throwaway profile (its +/// own sessions, settings and installed ACP agents) for demos and screenshots — +/// else `~/.tcode` on every platform. +pub fn data_dir() -> io::Result { + if let Some(dir) = std::env::var_os(DATA_DIR_ENV).filter(|dir| !dir.is_empty()) { + return Ok(PathBuf::from(dir)); + } + dirs::home_dir() + .map(|home| home.join(".tcode")) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "no home directory to keep Tcode's data in (~/.tcode); set {DATA_DIR_ENV} to \ + choose one" + ), + ) + }) +} + struct Shared { state: Mutex, /// Signalled whenever an operation finishes, for [`SessionStore::close`]. @@ -215,21 +240,26 @@ impl Mutation { } impl SessionStore { - /// Open (creating if needed) the store under the platform data dir, or under - /// `TCODE_DATA_DIR` when it is set — which gives a throwaway profile (its own - /// sessions, settings and installed ACP agents) for demos and screenshots. - pub fn open_default() -> io::Result { - let root = match std::env::var_os("TCODE_DATA_DIR") { - Some(dir) => PathBuf::from(dir), - None => dirs::data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("tcode"), + /// A host's store: in `root` when one is given (`tcode-headless + /// --data-dir`), else in [`data_dir`]. An older build's data dir moves in + /// first, from `LEGACY_TCODE_DATA_DIR` when it is set, else — unless the + /// data dir was chosen explicitly — from the platform data dir an older + /// build used; see [`SessionStore::needs_migration`]. + pub fn open_host(root: Option) -> io::Result { + let explicit = + root.is_some() || std::env::var_os(DATA_DIR_ENV).is_some_and(|dir| !dir.is_empty()); + let root = match root { + Some(root) => root, + None => data_dir()?, }; - Self::open_at(root) + let mut store = Self::open_at(root)?; + store.previous = relocate::source(explicit); + Ok(store) } - /// A handle to `root`, created if needed. The database is not opened until - /// [`SessionStore::open`] or the first operation that needs it. + /// A handle to `root`, created if needed, that never moves an older data + /// dir in. The database is not opened until [`SessionStore::open`] or the + /// first operation that needs it. pub fn open_at(root: PathBuf) -> io::Result { fs::create_dir_all(&root)?; let key = fs::canonicalize(&root)?; @@ -252,7 +282,11 @@ impl SessionStore { shared } }; - Ok(Self { root, shared }) + Ok(Self { + root, + previous: None, + shared, + }) } /// How many times [`SessionStore::read_events`] parsed a log through this @@ -277,10 +311,11 @@ impl SessionStore { } /// Take ownership of the data dir, keeping it for the database opened - /// later, and say whether it holds the JSON index or JSONL logs of an - /// older build that [`SessionStore::migrate`] must move into `tcode.db` - /// before the store opens. Fails with [`io::ErrorKind::ResourceBusy`] - /// while another host owns the directory. + /// later, and say whether [`SessionStore::migrate`] must prepare it before + /// the store opens: an older build's data dir is still to move in, or the + /// data dir holds the JSON index or JSONL logs of an older build that must + /// move into `tcode.db`. Fails with [`io::ErrorKind::ResourceBusy`] while + /// another host owns the directory. pub fn needs_migration(&self) -> io::Result { let mut state = self.shared.lock_state()?; match &*state { @@ -289,15 +324,29 @@ impl SessionStore { State::Open(_) => return Ok(false), other => return Err(unavailable(other, "check for a migration")), } - migrate::needed(&self.root) + Ok(relocate::needed(&self.root, self.previous.as_deref())? || migrate::needed(&self.root)?) } - /// Migrate an older build's files into `tcode.db` under the data dir's - /// ownership lock, which the store keeps for the database afterwards. - /// `progress` is called after every chunk of about 8 MiB and every log; - /// `cancel` is checked at the same points and between phases until the - /// migrated database is complete. A cancelled migration removes its - /// staging files and changes nothing else; a later one starts over. + /// The older data dir still to move into this one, without taking + /// ownership: whatever is written to the data dir before the move would + /// stop it with a collision. + pub fn pending_relocation(&self) -> io::Result> { + Ok(match self.previous.as_deref() { + Some(previous) if relocate::needed(&self.root, Some(previous))? => Some(previous), + _ => None, + }) + } + + /// Prepare the data dir under its ownership lock, which the store keeps + /// for the database afterwards: first move an older build's data dir in + /// ([`MigrationPhase::Relocating`]), then migrate an older build's JSON + /// index and JSONL logs into `tcode.db`. `progress` is called after every + /// entry moved, every chunk of about 8 MiB copied or imported, and every + /// log; `cancel` is checked at the same points and between phases until + /// the migrated database is complete. A cancelled move keeps the entries + /// it moved and is continued by the next start; a cancelled migration + /// removes its staging files and changes nothing else, and a later one + /// starts over. pub fn migrate( &self, mut progress: impl FnMut(MigrationProgress), @@ -326,7 +375,10 @@ impl SessionStore { } }; let outcome = catch_unwind(AssertUnwindSafe(|| { - migrate::run(&self.root, &mut progress, cancel) + match relocate::run(&self.root, self.previous.as_deref(), &mut progress, cancel)? { + Migration::Cancelled => Ok(Migration::Cancelled), + Migration::Completed => migrate::run(&self.root, &mut progress, cancel), + } })); let mut state = self.shared.lock_state()?; let result = match outcome { @@ -456,7 +508,9 @@ impl SessionStore { }; if let Some(ownership) = ownership { // Dropping the ownership on failure lets the next attempt start over. - match catch_unwind(AssertUnwindSafe(|| open_live(&self.root, ownership))) { + match catch_unwind(AssertUnwindSafe(|| { + open_live(&self.root, self.previous.as_deref(), ownership) + })) { Ok(Ok(live)) => *state = State::Open(live), Ok(Err(error)) => return Err(error), Err(panic) => { @@ -828,7 +882,18 @@ fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { } /// Bring `tcode.db` up to date in the data dir `ownership` holds, and open it. -fn open_live(root: &Path, ownership: File) -> io::Result { +/// A data dir an older one has still to move into is refused, as opening it +/// would create the data that makes the move look done. +fn open_live(root: &Path, previous: Option<&Path>, ownership: File) -> io::Result { + if let Some(previous) = previous + && relocate::needed(root, Some(previous))? + { + return Err(io::Error::other(format!( + "{} has not been moved into {} yet", + previous.display(), + root.display() + ))); + } migrate::prepare(root)?; let path = root.join(DB_FILE); let db = Db::open(&path, false)?; @@ -856,6 +921,7 @@ fn open_live(root: &Path, ownership: File) -> io::Result { } } migrate::warn_about_stray_sources(root); + migrate::remove_legacy(root); Ok(Live { db: Arc::new(db), ownership, diff --git a/crates/services/src/store/relocate.rs b/crates/services/src/store/relocate.rs new file mode 100644 index 000000000..155732401 --- /dev/null +++ b/crates/services/src/store/relocate.rs @@ -0,0 +1,509 @@ +//! Moving the data dir an older build kept in the platform data directory +//! (`~/Library/Application Support/tcode`, `~/.local/share/tcode`, +//! `%APPDATA%\tcode`), or the one `LEGACY_TCODE_DATA_DIR` names, into the data +//! dir, before anything else reads either. +//! +//! Every entry of the older directory is renamed into the data dir. Across +//! filesystems an entry is instead copied into `tcode.relocating/` in the data +//! dir and compared byte for byte with its source; the source is then retired +//! into the older directory's `tcode.relocated/`, the copy renamed into place +//! and the retired source deleted. `tcode.relocating/` marks the move as +//! started and is removed last, after the emptied older directory. +//! +//! A start after an interruption publishes a staged copy whose source was +//! retired (it was verified first), discards any other staged copy, deletes +//! retired sources whose copy is in place, and moves what is left. + +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +use super::migrate::{ + CHUNK_BYTES, Migration, MigrationPhase, MigrationProgress, Progress, is_cancelled, sync_dir, +}; +use super::{DB_FILE, LOCK_FILE}; + +const LEGACY_DATA_DIR_ENV: &str = "LEGACY_TCODE_DATA_DIR"; +/// In the data dir: present while a move is unfinished, and the staging area +/// of a copy. +const STAGING_DIR: &str = "tcode.relocating"; +/// In the older directory: sources whose verified copy is staged. +const RETIRED_DIR: &str = "tcode.relocated"; +/// Not data: the older directory's lock and Finder's view settings are deleted +/// with it rather than moved, so they never collide with the data dir's own. +const LEFT_BEHIND: [&str; 2] = [LOCK_FILE, ".DS_Store"]; +/// What makes a directory a data dir rather than, say, `~/.tcode` holding only +/// Orchestrate's `worktrees/`. +const DATA_FILES: [&str; 7] = [ + DB_FILE, + "tcode.db.migrating", + "sessions.json", + "settings.json", + "secrets.json", + "traverse.json", + "device.json", +]; + +/// The directory to move from: `LEGACY_TCODE_DATA_DIR`, else the platform +/// data directory an older build used, unless the data dir was chosen +/// explicitly (`TCODE_DATA_DIR` or `--data-dir`). +pub(super) fn source(explicit_root: bool) -> Option { + match std::env::var_os(LEGACY_DATA_DIR_ENV).filter(|dir| !dir.is_empty()) { + Some(dir) => Some(PathBuf::from(dir)), + None if explicit_root => None, + None => dirs::data_dir().map(|dir| dir.join("tcode")), + } +} + +/// Whether `previous` has to move into `root` before the store opens: a move +/// was started, or `root` holds no data and `previous`, another directory, +/// does. +pub(super) fn needed(root: &Path, previous: Option<&Path>) -> io::Result { + let Some(previous) = previous else { + return Ok(false); + }; + if root.join(STAGING_DIR).exists() { + return Ok(true); + } + let previous = match fs::canonicalize(previous) { + Ok(previous) => previous, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if previous == fs::canonicalize(root)? { + return Ok(false); + } + Ok(!holds_data(root)? && holds_data(&previous)?) +} + +fn holds_data(dir: &Path) -> io::Result { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if DATA_FILES.contains(&name.as_ref()) + || (name.ends_with(".jsonl") && entry.file_type()?.is_file()) + { + return Ok(true); + } + } + Ok(false) +} + +/// Move `previous` into `root` if [`needed`], reporting +/// [`MigrationPhase::Relocating`]. `cancel` is checked after every entry and +/// every chunk copied; a cancelled move keeps what it moved, discards a +/// partial copy and is continued by the next start. Called with the data dir's +/// ownership lock held. +pub(super) fn run( + root: &Path, + previous: Option<&Path>, + report: &mut dyn FnMut(MigrationProgress), + cancel: &AtomicBool, +) -> io::Result { + let Some(previous) = previous else { + return Ok(Migration::Completed); + }; + if !needed(root, Some(previous))? { + return Ok(Migration::Completed); + } + let mut progress = Progress { + report, + cancel, + current: MigrationProgress { + phase: MigrationPhase::Relocating, + threads_done: 0, + threads_total: 0, + bytes_done: 0, + bytes_total: 0, + }, + }; + match relocate(root, previous, &mut progress) { + Ok(()) => Ok(Migration::Completed), + Err(error) if is_cancelled(&error) => { + log::info!( + "moving {} into {} cancelled; the next start continues", + previous.display(), + root.display() + ); + Ok(Migration::Cancelled) + } + Err(error) => Err(error), + } +} + +fn relocate(root: &Path, previous: &Path, progress: &mut Progress) -> io::Result<()> { + let staging = root.join(STAGING_DIR); + if !previous.exists() { + // Interrupted after the older directory was removed. + fs::remove_dir_all(&staging)?; + return sync_dir(root); + } + let (canonical_root, canonical_previous) = + (fs::canonicalize(root)?, fs::canonicalize(previous)?); + if canonical_root.starts_with(&canonical_previous) + || canonical_previous.starts_with(&canonical_root) + { + return Err(io::Error::other(format!( + "cannot move {} into {}: one contains the other", + previous.display(), + root.display() + ))); + } + let lock = lock(previous)?; + recover(root, previous)?; + let entries = entries(previous)?; + if let Some(name) = entries + .iter() + .find(|name| fs::symlink_metadata(root.join(name)).is_ok()) + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "cannot move {} into {}: both contain {name}; stopped without moving or deleting \ + anything. Move or remove one of them, then start Tcode again", + previous.display(), + root.display(), + name = name.to_string_lossy() + ), + )); + } + progress.start(MigrationPhase::Relocating, entries.len(), 0)?; + fs::create_dir_all(&staging)?; + sync_dir(root)?; + let mut copying = false; + for (index, name) in entries.iter().enumerate() { + let source = previous.join(name); + if !copying { + match fs::rename(&source, root.join(name)) { + Ok(()) => { + progress.advance(1, 0)?; + continue; + } + Err(error) if error.kind() == io::ErrorKind::CrossesDevices => { + copying = true; + let mut total = 0; + for name in &entries[index..] { + total += tree_size(&previous.join(name))?; + } + progress.current.bytes_total = total; + log::info!( + "{} and {} are on different filesystems; copying {total} bytes", + previous.display(), + root.display() + ); + } + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!( + "could not move {} into {}: {error}", + source.display(), + root.display() + ), + )); + } + } + } + copy_entry(root, previous, name, progress)?; + progress.advance(1, 0)?; + } + sync_dir(root)?; + + // Windows keeps a deleted file's name until its last handle closes. + drop(lock); + for name in LEFT_BEHIND { + remove_if_present(&previous.join(name))?; + } + remove_if_present(&previous.join(RETIRED_DIR))?; + fs::remove_dir(previous).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "moved every entry of {} into {}, but could not remove it: {error}", + previous.display(), + root.display() + ), + ) + })?; + if let Some(parent) = previous.parent() { + sync_dir(parent)?; + } + fs::remove_dir_all(&staging)?; + sync_dir(root)?; + log::info!( + "moved {} entries from {} into {}", + entries.len(), + previous.display(), + root.display() + ); + Ok(()) +} + +/// Hold the older directory's lock, which the build that used it takes while +/// it runs. Kept until the lock file is deleted with the directory. +fn lock(previous: &Path) -> io::Result { + let path = previous.join(LOCK_FILE); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path)?; + match file.try_lock() { + Ok(()) => Ok(file), + Err(fs::TryLockError::Error(error)) => Err(error), + Err(fs::TryLockError::WouldBlock) => Err(io::Error::new( + io::ErrorKind::ResourceBusy, + format!( + "another Tcode is still using {}; quit it, then start this one again to move \ + its data", + previous.display() + ), + )), + } +} + +/// The older directory's entries to move, by name. +fn entries(previous: &Path) -> io::Result> { + let mut names = Vec::new(); + for entry in fs::read_dir(previous)? { + let name = entry?.file_name(); + if name != RETIRED_DIR && !LEFT_BEHIND.iter().any(|left| name == *left) { + names.push(name); + } + } + names.sort(); + Ok(names) +} + +/// Finish what an interrupted copy left: a retired source's staged copy is +/// complete and is published, a retired source whose copy is in place is +/// deleted, and any other staged copy is partial and discarded. +fn recover(root: &Path, previous: &Path) -> io::Result<()> { + let staging = root.join(STAGING_DIR); + let retired = previous.join(RETIRED_DIR); + if retired.exists() { + for entry in fs::read_dir(&retired)? { + let name = entry?.file_name(); + let staged = staging.join(&name); + let destination = root.join(&name); + if fs::symlink_metadata(&staged).is_ok() { + if fs::symlink_metadata(&destination).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "cannot finish moving {}: {} already exists", + retired.join(&name).display(), + destination.display() + ), + )); + } + fs::rename(&staged, &destination)?; + sync_dir(root)?; + } else if fs::symlink_metadata(&destination).is_err() { + // Retiring follows the staged copy, so this is not one of + // ours; it goes back to be moved like any other entry. + fs::rename(retired.join(&name), previous.join(&name))?; + continue; + } + remove_if_present(&retired.join(&name))?; + } + } + if staging.exists() { + for entry in fs::read_dir(&staging)? { + let path = entry?.path(); + log::warn!("discarding {}, a partial copy", path.display()); + remove_if_present(&path)?; + } + } + Ok(()) +} + +/// Move one entry across filesystems: stage a verified copy, retire the +/// source, publish the copy, delete the source. +fn copy_entry( + root: &Path, + previous: &Path, + name: &std::ffi::OsStr, + progress: &mut Progress, +) -> io::Result<()> { + let staging = root.join(STAGING_DIR); + let staged = staging.join(name); + let source = previous.join(name); + if let Err(error) = copy_tree(&source, &staged, progress) { + if let Err(cleanup) = remove_if_present(&staged) { + log::warn!("could not discard {}: {cleanup}", staged.display()); + } + return Err(error); + } + sync_dir(&staging)?; + let retired = previous.join(RETIRED_DIR); + fs::create_dir_all(&retired)?; + fs::rename(&source, retired.join(name))?; + sync_dir(previous)?; + sync_dir(&retired)?; + fs::rename(&staged, root.join(name))?; + sync_dir(root)?; + remove_if_present(&retired.join(name)) +} + +fn copy_tree(source: &Path, destination: &Path, progress: &mut Progress) -> io::Result<()> { + let metadata = fs::symlink_metadata(source)?; + let kind = metadata.file_type(); + if kind.is_symlink() { + let target = fs::read_link(source)?; + symlink(source, &target, destination)?; + if fs::read_link(destination)? != target { + return Err(mismatch(source, destination)); + } + } else if kind.is_dir() { + fs::create_dir(destination)?; + let mut names = fs::read_dir(source)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>()?; + names.sort(); + for name in names { + copy_tree(&source.join(&name), &destination.join(&name), progress)?; + } + fs::set_permissions(destination, metadata.permissions())?; + sync_dir(destination)?; + } else if kind.is_file() { + copy_file(source, destination, progress)?; + fs::set_permissions(destination, metadata.permissions())?; + } else { + log::warn!( + "not moving {}: not a file, directory or link", + source.display() + ); + } + Ok(()) +} + +fn copy_file(source: &Path, destination: &Path, progress: &mut Progress) -> io::Result<()> { + let mut reader = File::open(source)?; + let mut writer = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + let mut buffer = vec![0; CHUNK_BYTES]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + writer.write_all(&buffer[..read])?; + progress.advance(0, read as u64)?; + } + writer.sync_all()?; + drop(writer); + verify_file(source, destination) +} + +/// Read the copy back and compare it with its source, length and bytes. +fn verify_file(source: &Path, destination: &Path) -> io::Result<()> { + if fs::metadata(source)?.len() != fs::metadata(destination)?.len() { + return Err(mismatch(source, destination)); + } + let (mut original, mut copy) = (File::open(source)?, File::open(destination)?); + let (mut expected, mut actual) = (vec![0; CHUNK_BYTES], vec![0; CHUNK_BYTES]); + loop { + let read = original.read(&mut expected)?; + if read == 0 { + return if copy.read(&mut actual[..1])? == 0 { + Ok(()) + } else { + Err(mismatch(source, destination)) + }; + } + copy.read_exact(&mut actual[..read])?; + if expected[..read] != actual[..read] { + return Err(mismatch(source, destination)); + } + } +} + +fn mismatch(source: &Path, destination: &Path) -> io::Error { + io::Error::other(format!( + "the copy {} differs from {}; the source is kept", + destination.display(), + source.display() + )) +} + +/// Bytes of every file under `path`, not following links. +fn tree_size(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() { + return Ok(if metadata.is_file() { + metadata.len() + } else { + 0 + }); + } + let mut total = 0; + for entry in fs::read_dir(path)? { + total += tree_size(&entry?.path())?; + } + Ok(total) +} + +/// Delete a file, link or directory tree, without following links. +fn remove_if_present(path: &Path) -> io::Result<()> { + let result = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => { + make_removable(path).and_then(|()| fs::remove_dir_all(path)) + } + Ok(_) => fs::remove_file(path), + Err(error) => Err(error), + }; + match result { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +/// Unix removes an entry only from a writable directory, and a profile +/// home's Go module cache keeps its directories read-only. +#[cfg(unix)] +fn make_removable(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = fs::symlink_metadata(dir)?.permissions(); + if permissions.mode() & 0o700 != 0o700 { + permissions.set_mode(permissions.mode() | 0o700); + fs::set_permissions(dir, permissions)?; + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + make_removable(&entry.path())?; + } + } + Ok(()) +} + +/// std's Windows `remove_dir_all` deletes with POSIX semantics, which ignore +/// the read-only attribute on NTFS. +#[cfg(not(unix))] +fn make_removable(_dir: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn symlink(_source: &Path, target: &Path, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +/// Windows distinguishes links to directories from links to files. +#[cfg(windows)] +fn symlink(source: &Path, target: &Path, link: &Path) -> io::Result<()> { + if fs::metadata(source).is_ok_and(|metadata| metadata.is_dir()) { + std::os::windows::fs::symlink_dir(target, link) + } else { + std::os::windows::fs::symlink_file(target, link) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/services/src/store/relocate/tests.rs b/crates/services/src/store/relocate/tests.rs new file mode 100644 index 000000000..334f7b471 --- /dev/null +++ b/crates/services/src/store/relocate/tests.rs @@ -0,0 +1,244 @@ +use std::sync::atomic::Ordering; + +use super::super::tests::{tree, write_older_data_dir}; +use super::*; + +/// An older data dir and the data dir to move it into, under one temporary +/// directory removed when the test ends. +struct Dirs { + base: PathBuf, + previous: PathBuf, + root: PathBuf, +} + +impl Dirs { + fn new() -> Self { + let base = + std::env::temp_dir().join(format!("tcode-relocate-test-{}", uuid::Uuid::new_v4())); + let previous = base.join("old"); + let root = base.join("new"); + write_older_data_dir(&previous); + fs::create_dir_all(root.join("worktrees")).unwrap(); + Self { + base, + previous, + root, + } + } + + fn run( + &self, + report: &mut dyn FnMut(MigrationProgress), + cancel: &AtomicBool, + ) -> io::Result { + run(&self.root, Some(&self.previous), report, cancel) + } + + fn run_to_completion(&self) { + assert_eq!( + self.run(&mut |_| {}, &AtomicBool::new(false)).unwrap(), + Migration::Completed + ); + } + + /// What a finished move leaves in the data dir: the older entries + /// without what is not data, and the data dir's own. + fn moved( + &self, + older: &std::collections::BTreeMap>, + ) -> std::collections::BTreeMap> { + let mut expected = older.clone(); + expected.retain(|path, _| !LEFT_BEHIND.iter().any(|name| path == Path::new(name))); + expected.insert("worktrees".into(), b"".to_vec()); + expected + } +} + +impl Drop for Dirs { + fn drop(&mut self) { + let _ = remove_if_present(&self.base); + } +} + +#[test] +fn a_name_in_both_directories_stops_the_move_before_anything_moves() { + let dirs = Dirs::new(); + fs::create_dir(dirs.root.join("attachments")).unwrap(); + let (previous, root) = (tree(&dirs.previous), tree(&dirs.root)); + + let error = dirs.run(&mut |_| {}, &AtomicBool::new(false)).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(error.to_string().contains("attachments"), "{error}"); + + let mut after = tree(&dirs.previous); + // Taken to keep an older build out while moving. + assert!(after.remove(Path::new(LOCK_FILE)).is_some()); + assert_eq!(after, previous); + assert_eq!(tree(&dirs.root), root); + assert!(needed(&dirs.root, Some(&dirs.previous)).unwrap()); +} + +#[test] +fn a_cancelled_move_keeps_what_it_moved_and_the_next_start_finishes_it() { + let dirs = Dirs::new(); + let older = tree(&dirs.previous); + let cancel = AtomicBool::new(false); + let outcome = dirs + .run( + &mut |progress| { + if progress.threads_done == 2 { + cancel.store(true, Ordering::Relaxed); + } + }, + &cancel, + ) + .unwrap(); + assert_eq!(outcome, Migration::Cancelled); + + // Nothing lost: every older entry is in exactly one of the two. + let (left, moved) = (tree(&dirs.previous), tree(&dirs.root)); + for (path, bytes) in dirs.moved(&older) { + let found = [left.get(&path), moved.get(&path)]; + assert!( + found.iter().flatten().count() == 1 + && found.iter().flatten().all(|found| **found == bytes), + "{path:?}: {found:?}" + ); + } + assert!(moved.contains_key(Path::new("attachments"))); + assert!(left.contains_key(Path::new("settings.json"))); + assert!(needed(&dirs.root, Some(&dirs.previous)).unwrap()); + + dirs.run_to_completion(); + assert!(!dirs.previous.exists()); + assert_eq!(tree(&dirs.root), dirs.moved(&older)); + assert!(!needed(&dirs.root, Some(&dirs.previous)).unwrap()); +} + +/// The branch a failed `rename` across filesystems takes, on one entry with +/// nested directories, a file of several chunks, an empty file and a link. +#[test] +fn a_copied_entry_arrives_verified_and_its_source_is_deleted() { + let dirs = Dirs::new(); + let attachments = dirs.previous.join("attachments"); + let large: Vec = (0..CHUNK_BYTES + 4096).map(|index| index as u8).collect(); + fs::write(attachments.join("session-1/large.bin"), &large).unwrap(); + fs::write(attachments.join("empty"), b"").unwrap(); + // A Go module cache in a profile home is read-only, directories included. + let module = attachments.join("mod/pkg@v1"); + fs::create_dir_all(&module).unwrap(); + fs::write(module.join("go.mod"), b"module pkg").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::os::unix::fs::symlink("session-1/a.png", attachments.join("latest")).unwrap(); + fs::set_permissions(module.join("go.mod"), fs::Permissions::from_mode(0o444)).unwrap(); + fs::set_permissions(&module, fs::Permissions::from_mode(0o555)).unwrap(); + } + let source = tree(&attachments); + fs::create_dir(dirs.root.join(STAGING_DIR)).unwrap(); + + let mut reports = Vec::new(); + let mut report = |progress| reports.push(progress); + let cancel = AtomicBool::new(false); + let mut progress = Progress { + report: &mut report, + cancel: &cancel, + current: MigrationProgress { + phase: MigrationPhase::Relocating, + threads_done: 0, + threads_total: 1, + bytes_done: 0, + bytes_total: tree_size(&attachments).unwrap(), + }, + }; + copy_entry( + &dirs.root, + &dirs.previous, + "attachments".as_ref(), + &mut progress, + ) + .unwrap(); + + assert_eq!(tree(&dirs.root.join("attachments")), source); + assert!(!attachments.exists()); + assert!( + fs::read_dir(dirs.root.join(STAGING_DIR)) + .unwrap() + .next() + .is_none() + ); + assert!(!dirs.previous.join(RETIRED_DIR).join("attachments").exists()); + let last = reports.last().unwrap(); + assert_eq!(last.bytes_done, last.bytes_total); + assert_eq!(last.bytes_total, large.len() as u64 + 4 + 10); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; + let copied = dirs.root.join("attachments/mod/pkg@v1"); + assert_eq!( + (mode(&copied), mode(&copied.join("go.mod"))), + (0o555, 0o444) + ); + } +} + +/// Killed while copying: one entry's copy is staged but partial, another's +/// is complete and its source already retired. +#[test] +fn a_start_after_an_interrupted_copy_recopies_a_partial_file_and_publishes_a_verified_one() { + let dirs = Dirs::new(); + let older = tree(&dirs.previous); + let staging = dirs.root.join(STAGING_DIR); + fs::create_dir_all(&staging).unwrap(); + fs::write(staging.join("settings.json"), br#"{"loc"#).unwrap(); + fs::create_dir(dirs.previous.join(RETIRED_DIR)).unwrap(); + fs::rename( + dirs.previous.join("profile-homes"), + dirs.previous.join(RETIRED_DIR).join("profile-homes"), + ) + .unwrap(); + fs::create_dir_all(staging.join("profile-homes/claude/.claude")).unwrap(); + fs::write( + staging.join("profile-homes/claude/.claude/settings.json"), + b"{}", + ) + .unwrap(); + // And one entry already published before the kill. + fs::rename( + dirs.previous.join("project-icons"), + dirs.root.join("project-icons"), + ) + .unwrap(); + + assert!(needed(&dirs.root, Some(&dirs.previous)).unwrap()); + dirs.run_to_completion(); + assert!(!dirs.previous.exists()); + assert_eq!(tree(&dirs.root), dirs.moved(&older)); +} + +#[test] +fn the_same_directory_under_another_name_is_not_moved() { + let dirs = Dirs::new(); + let before = tree(&dirs.previous); + for alias in [dirs.previous.join("."), dirs.base.join("old/../old")] { + assert_eq!( + run( + &dirs.previous, + Some(&alias), + &mut |_| {}, + &AtomicBool::new(false) + ) + .unwrap(), + Migration::Completed + ); + } + #[cfg(unix)] + { + let link = dirs.base.join("link"); + std::os::unix::fs::symlink(&dirs.previous, &link).unwrap(); + assert!(!needed(&link, Some(&dirs.previous)).unwrap()); + } + assert_eq!(tree(&dirs.previous), before); +} diff --git a/crates/services/src/store/tests.rs b/crates/services/src/store/tests.rs index 19db868a1..6162fb208 100644 --- a/crates/services/src/store/tests.rs +++ b/crates/services/src/store/tests.rs @@ -151,24 +151,8 @@ fn assert_fixture_migrated(store: &SessionStore, root: &Path) { for name in LEGACY_FILES { assert!(!root.join(name).exists(), "{name} left in the data dir"); } - assert_eq!( - fs::read(root.join("legacy/sessions.json")).unwrap(), - legacy_index().to_string().as_bytes() - ); - assert_eq!( - fs::read(root.join("legacy/mixed.jsonl")).unwrap(), - MIXED_LOG - ); - assert_eq!( - fs::read(root.join("legacy/orphan.jsonl")).unwrap(), - ORPHAN_LOG - ); - assert!( - fs::read(root.join("legacy/empty.jsonl")) - .unwrap() - .is_empty() - ); for leftover in [ + LEGACY_DIR_NAME, "tcode.db.migrating", "tcode.db.migrating-wal", "tcode.db.archive", @@ -218,10 +202,6 @@ fn first_start_migrates_the_json_index_and_every_log_byte_for_byte() { assert_eq!(reopened.read_event_log("mixed").unwrap(), expected); assert_eq!(reopened.read_file().unwrap().sessions.len(), 2); assert_eq!(fs::read(dir.path().join("sessions.json")).unwrap(), b"[]"); - assert_eq!( - fs::read(dir.path().join("legacy/mixed.jsonl")).unwrap(), - MIXED_LOG - ); } #[test] @@ -277,7 +257,7 @@ fn legacy_bare_array_index_migrates_with_one_derived_project_per_root() { } #[test] -fn an_unparseable_index_is_archived_unchanged_and_the_logs_still_migrate() { +fn an_unparseable_index_does_not_stop_the_logs_migrating() { let dir = DataDir::new(); let corrupt = b"not valid session json"; fs::write(dir.path().join("sessions.json"), corrupt).unwrap(); @@ -288,10 +268,6 @@ fn an_unparseable_index_is_archived_unchanged_and_the_logs_still_migrate() { assert!(file.projects.is_empty() && file.sessions.is_empty()); assert_eq!(store.read_event_log("orphan").unwrap(), ORPHAN_LOG); assert!(!dir.path().join("sessions.json").exists()); - assert_eq!( - fs::read(dir.path().join("legacy/sessions.json")).unwrap(), - corrupt - ); } #[test] @@ -322,13 +298,6 @@ fn duplicate_index_ids_fail_the_migration_and_leave_the_sources_alone() { const LEGACY_DIR_NAME: &str = "legacy"; -/// Put the migrated sources back where an interruption would have left them. -fn restore_sources(root: &Path) { - for name in LEGACY_FILES { - fs::rename(root.join("legacy").join(name), root.join(name)).unwrap(); - } -} - fn archive_list(names: &[&str]) -> Vec { serde_json::to_vec(names).unwrap() } @@ -352,7 +321,7 @@ fn a_start_after_an_interrupted_migration_discards_the_staging_files_and_starts_ dir.path().join("tcode.db.migrating"), ) .unwrap(); - restore_sources(dir.path()); + write_legacy_fixture(dir.path()); fs::write( dir.path().join("tcode.db.archive"), archive_list(&LEGACY_FILES), @@ -487,14 +456,14 @@ fn a_cancelled_migration_changes_no_source_and_the_next_one_completes() { } #[test] -fn a_start_after_an_interrupted_archival_finishes_it_without_overwriting() { +fn a_start_after_an_interrupted_archival_finishes_it_and_loses_no_source() { let dir = DataDir::new(); write_legacy_fixture(dir.path()); dir.migrated().close().unwrap(); // Killed after the rename while moving the sources: two are still in the - // data dir. One of them also has a copy in legacy/ already, which must - // survive untouched while the source stays where it is. + // data dir. One of them also has a copy in legacy/ already, so the source + // stays where it is rather than replace it. fs::rename( dir.path().join("legacy/mixed.jsonl"), dir.path().join("mixed.jsonl"), @@ -516,18 +485,12 @@ fn a_start_after_an_interrupted_archival_finishes_it_without_overwriting() { store.open().unwrap(); assert!(!dir.path().join("tcode.db.archive").exists()); assert!(!dir.path().join("mixed.jsonl").exists()); - assert_eq!( - fs::read(dir.path().join("legacy/mixed.jsonl")).unwrap(), - MIXED_LOG - ); assert_eq!( fs::read(dir.path().join("orphan.jsonl")).unwrap(), ORPHAN_LOG ); - assert_eq!( - fs::read(dir.path().join("legacy/orphan.jsonl")).unwrap(), - b"older copy" - ); + // Removed with the archived sources once the database opened. + assert!(!dir.path().join(LEGACY_DIR_NAME).exists()); assert_eq!(store.read_event_log("mixed").unwrap(), MIXED_LOG); } @@ -562,11 +525,18 @@ fn an_unfinished_newer_or_corrupt_database_is_refused_and_left_untouched() { .unwrap(); dir.store().close().unwrap(); set_version(dir.path(), version); + // The sources a migration archived stay until a database opens. + fs::create_dir(dir.path().join(LEGACY_DIR_NAME)).unwrap(); + fs::write(dir.path().join("legacy/mixed.jsonl"), MIXED_LOG).unwrap(); let before = fs::read(dir.path().join(DB_FILE)).unwrap(); let error = dir.store().open().unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert!(error.to_string().contains(expected), "{error}"); assert_eq!(fs::read(dir.path().join(DB_FILE)).unwrap(), before); + assert_eq!( + fs::read(dir.path().join("legacy/mixed.jsonl")).unwrap(), + MIXED_LOG + ); } let dir = DataDir::new(); @@ -715,17 +685,23 @@ fn command_cache_roundtrips_per_provider_and_acp_agent() { ); } -/// This test binary, re-run as another process that opens `root` and holds -/// it until its stdin closes. It reports `OPEN` or `ERR `. +/// This test binary, re-run as another process that starts as a host does — +/// with `TCODE_DATA_DIR` set to `root`, and `LEGACY_TCODE_DATA_DIR` to +/// `previous` if given — and holds the data dir until its stdin closes. It +/// reports the phases its preparation went through, then `OPEN` or +/// `ERR `. struct Child { process: std::process::Child, stdout: BufReader, + /// Each phase by its `Debug` name. + phases: Vec, report: String, } impl Child { - fn spawn(root: &Path) -> std::process::Child { - crate::process::command(std::env::current_exe().unwrap()) + fn spawn(root: &Path, previous: Option<&Path>) -> std::process::Child { + let mut command = crate::process::command(std::env::current_exe().unwrap()); + command .args([ "--exact", "store::tests::store_owner_process", @@ -733,7 +709,12 @@ impl Child { "--nocapture", "--test-threads=1", ]) - .env("TCODE_STORE_OWNER_DIR", root) + .env("TCODE_DATA_DIR", root) + .env_remove("LEGACY_TCODE_DATA_DIR"); + if let Some(previous) = previous { + command.env("LEGACY_TCODE_DATA_DIR", previous); + } + command .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() @@ -745,12 +726,16 @@ impl Child { let mut stdout = BufReader::new(process.stdout.take().unwrap()); // libtest prints the test's name on the same line first. let mut line = String::new(); + let mut phases = Vec::new(); let report = loop { line.clear(); assert!( stdout.read_line(&mut line).unwrap() > 0, "the child exited without reporting" ); + if let Some((_, phase)) = line.trim_end().split_once(OWNER_PHASE) { + phases.push(phase.to_owned()); + } if let Some((_, report)) = line.trim_end().split_once(OWNER_REPORT) { break report.to_owned(); } @@ -758,6 +743,7 @@ impl Child { Self { process, stdout, + phases, report, } } @@ -771,15 +757,25 @@ impl Child { } const OWNER_REPORT: &str = "store owner: "; +const OWNER_PHASE: &str = "store owner phase: "; #[test] #[ignore = "the other process of the ownership tests below, which run it"] fn store_owner_process() { - let root = PathBuf::from(std::env::var_os("TCODE_STORE_OWNER_DIR").unwrap()); - let store = SessionStore::open_at(root).unwrap(); + let store = SessionStore::open_host(None).unwrap(); let opened = store.needs_migration().and_then(|needed| { if needed { - migrate(&store)?; + let mut last = None; + let outcome = store.migrate( + |progress| { + if last != Some(progress.phase) { + println!("{OWNER_PHASE}{:?}", progress.phase); + last = Some(progress.phase); + } + }, + &AtomicBool::new(false), + )?; + assert_eq!(outcome, Migration::Completed); } store.open() }); @@ -795,7 +791,7 @@ fn store_owner_process() { #[test] fn a_second_process_is_refused_and_a_relaunch_waits_for_the_owner_to_exit() { let dir = DataDir::new(); - let owner = Child::report(Child::spawn(dir.path())); + let owner = Child::report(Child::spawn(dir.path(), None)); assert_eq!(owner.report, "OPEN"); let error = dir.store().open().unwrap_err(); @@ -832,7 +828,10 @@ fn a_second_process_is_refused_and_a_relaunch_waits_for_the_owner_to_exit() { fn two_simultaneous_first_launches_migrate_once() { let dir = DataDir::new(); write_legacy_fixture(dir.path()); - let (first, second) = (Child::spawn(dir.path()), Child::spawn(dir.path())); + let (first, second) = ( + Child::spawn(dir.path(), None), + Child::spawn(dir.path(), None), + ); let (first, second) = (Child::report(first), Child::report(second)); let mut reports = [first.report.clone(), second.report.clone()]; reports.sort(); @@ -845,6 +844,97 @@ fn two_simultaneous_first_launches_migrate_once() { assert_fixture_migrated(&store, dir.path()); } +/// Every file, directory and link under `root` by relative path: a file's +/// bytes, a link's target, or ``. +pub(super) fn tree(root: &Path) -> std::collections::BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut std::collections::BTreeMap>) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let kind = fs::symlink_metadata(&path).unwrap().file_type(); + let relative = path.strip_prefix(root).unwrap().to_owned(); + if kind.is_symlink() { + let target = fs::read_link(&path).unwrap(); + out.insert(relative, target.to_string_lossy().as_bytes().to_vec()); + } else if kind.is_dir() { + out.insert(relative, b"".to_vec()); + walk(root, &path, out); + } else { + out.insert(relative, fs::read(&path).unwrap()); + } + } + } + let mut out = std::collections::BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// An older build's data dir with every kind of entry it can hold. +pub(super) fn write_older_data_dir(previous: &Path) { + fs::create_dir_all(previous.join("attachments/session-1")).unwrap(); + fs::create_dir_all(previous.join("project-icons")).unwrap(); + fs::create_dir_all(previous.join("profile-homes/claude/.claude")).unwrap(); + fs::create_dir_all(previous.join("legacy")).unwrap(); + fs::write(previous.join("settings.json"), br#"{"locale":"en"}"#).unwrap(); + fs::write(previous.join("traverse.json"), br#"{"key":"machine"}"#).unwrap(); + fs::write( + previous.join("attachments/session-1/a.png"), + [0x89, b'P', b'N', b'G'], + ) + .unwrap(); + fs::write(previous.join("project-icons/p1.png"), b"icon").unwrap(); + fs::write( + previous.join("profile-homes/claude/.claude/settings.json"), + b"{}", + ) + .unwrap(); + fs::write(previous.join("legacy/older.jsonl"), ORPHAN_LOG).unwrap(); + fs::write(previous.join(".DS_Store"), b"finder").unwrap(); +} + +#[test] +fn a_start_moves_an_older_data_dir_in_then_migrates_it_and_leaves_nothing_behind() { + let home = DataDir::new(); + let previous = home.path().join("Application Support/tcode"); + let root = home.path().join(".tcode"); + write_older_data_dir(&previous); + write_legacy_fixture(&previous); + // Orchestrate's worktrees already live under ~/.tcode. + fs::create_dir_all(root.join("worktrees/thread-1")).unwrap(); + fs::write(root.join("worktrees/thread-1/README"), b"checkout").unwrap(); + let mut expected = tree(&previous); + expected.retain(|path, _| { + !path.starts_with("legacy") + && path != Path::new(".DS_Store") + && !LEGACY_FILES.iter().any(|name| path == Path::new(name)) + }); + expected.extend(tree(&root)); + + let owner = Child::report(Child::spawn(&root, Some(&previous))); + assert_eq!(owner.report, "OPEN"); + assert_eq!( + owner.phases, + [ + MigrationPhase::Relocating, + MigrationPhase::Scanning, + MigrationPhase::Importing, + MigrationPhase::Verifying, + MigrationPhase::Publishing, + MigrationPhase::Archiving, + ] + .map(|phase| format!("{phase:?}")) + ); + owner.release(); + + assert!(!previous.exists(), "the older data dir is left behind"); + let mut after = tree(&root); + // The store's own files, `tcode.db` among them. + after.retain(|path, _| { + path != Path::new(LOCK_FILE) && !path.to_string_lossy().starts_with(DB_FILE) + }); + assert_eq!(after, expected); + assert_fixture_migrated(&SessionStore::open_at(root.clone()).unwrap(), &root); +} + /// Kill the migration at random points — while logs are copied, around the /// checkpoint, the rename, the directory syncs and the archival — and restart /// it: there is either no `tcode.db` and every source is where it was, or a @@ -885,7 +975,7 @@ fn migration_survives_sigkill() { fs::copy(originals.path().join(name), calibration.path().join(name)).unwrap(); } let started = Instant::now(); - let full = Child::report(Child::spawn(calibration.path())); + let full = Child::report(Child::spawn(calibration.path(), None)); let window = started.elapsed().as_millis() as u64; assert_eq!(full.report, "OPEN"); full.release(); @@ -898,13 +988,15 @@ fn migration_survives_sigkill() { for name in &names { fs::copy(originals.path().join(name), dir.path().join(name)).unwrap(); } - let mut owner = Child::spawn(dir.path()); + let mut owner = Child::spawn(dir.path(), None); std::thread::sleep(Duration::from_millis(seed % (window + window / 10))); owner.kill().unwrap(); owner.wait().unwrap(); - // What the kill left: no database and untouched sources, or a - // completed database and every source in the data dir or legacy/. + // What the kill left: no database and untouched sources, a published + // database and every source in the data dir or legacy/, or a complete + // one whose sources the first open removes (checked below against the + // database instead). let source = |name: &str| { [dir.path().join(name), dir.path().join("legacy").join(name)] .into_iter() @@ -933,7 +1025,7 @@ fn migration_survives_sigkill() { } }; *outcomes.entry(outcome).or_default() += 1; - for name in &names { + for name in names.iter().filter(|_| outcome != "complete") { assert_eq!( fs::read(source(name).unwrap_or_else(|| panic!("run {run}: {name} lost"))).unwrap(), fs::read(originals.path().join(name)).unwrap(), diff --git a/crates/traverse/Cargo.toml b/crates/traverse/Cargo.toml index b6bd80240..812b93b3e 100644 --- a/crates/traverse/Cargo.toml +++ b/crates/traverse/Cargo.toml @@ -10,7 +10,6 @@ fast-apple-datapath = ["iroh/fast-apple-datapath"] # The native client host and the Preview adapters a native client shows a # machine's pages through. native = [ - "dep:dirs", "dep:libc", "dep:core-foundation", "dep:system-configuration-sys", @@ -33,7 +32,6 @@ tcode-protocol = { path = "../protocol", default-features = false } async-channel = "2" flate2 = "1" base64 = { version = "0.23", optional = true } -dirs = { version = "7", optional = true } futures-lite = "2" futures-util = { version = "0.3", default-features = false, features = ["sink"], optional = true } getrandom = "0.4" diff --git a/crates/traverse/src/native_host.rs b/crates/traverse/src/native_host.rs index 0846bf9df..9e6023142 100644 --- a/crates/traverse/src/native_host.rs +++ b/crates/traverse/src/native_host.rs @@ -91,22 +91,6 @@ impl NativeClientHost { } } - /// `TCODE_DATA_DIR`, else the platform data dir; hostname as device name. - pub fn from_env() -> Self { - Self::from_env_with_device_name(default_device_name()) - } - - /// Uses the normal platform data directory with a platform-provided name. - pub fn from_env_with_device_name(device_name: impl Into) -> Self { - let data_dir = match std::env::var_os("TCODE_DATA_DIR") { - Some(dir) => PathBuf::from(dir), - None => dirs::data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("tcode"), - }; - Self::new(data_dir, device_name) - } - /// Operating system name and version reported to hosts, e.g. `Android 15`. pub fn with_platform(mut self, platform: impl Into) -> Self { self.platform = Some(platform.into()); diff --git a/crates/ui/examples/phone.rs b/crates/ui/examples/phone.rs index c71bc2deb..4620a4566 100644 --- a/crates/ui/examples/phone.rs +++ b/crates/ui/examples/phone.rs @@ -29,7 +29,11 @@ fn main() { } else { size(px(393.), px(852.)) }; - let host: Rc = Rc::new(tcode_traverse::NativeClientHost::from_env()); + let data_dir = tcode_services::store::data_dir().expect("no data directory"); + let host: Rc = Rc::new(tcode_traverse::NativeClientHost::new( + data_dir, + tcode_traverse::native_host::default_device_name(), + )); tcode_ui::run_shell( cx, host.clone(), diff --git a/docs/remote.md b/docs/remote.md index 78b73cdf0..7fd0a3eb9 100644 --- a/docs/remote.md +++ b/docs/remote.md @@ -166,8 +166,8 @@ process. The password and browser tokens are separate from native pairing: ### Data directory The `tcode-headless --data-dir` option takes precedence over `TCODE_DATA_DIR`. -Without the option, `TCODE_DATA_DIR` selects the store; otherwise Tcode uses the -platform app-data directory with a `tcode` subdirectory. This includes settings, +Without the option, `TCODE_DATA_DIR` selects the store; otherwise Tcode uses +`~/.tcode` on every platform. This includes settings, threads, the machine key and allow list (`traverse.json`), browser login records (`remote.json`), the cached Traverse manifest and the current invitation. It does not move project working directories into the store. @@ -185,6 +185,17 @@ address. Use a separate directory for a separate machine identity. The desktop app also honors `TCODE_DATA_DIR`, including as a device when adding or connecting to a machine. +Earlier versions kept the data directory in the platform app-data directory +(`~/Library/Application Support/tcode`, `~/.local/share/tcode`, +`%APPDATA%\tcode`). When neither the option nor `TCODE_DATA_DIR` is given, the +first start of this version moves that directory into `~/.tcode`, provided +`~/.tcode` holds no data yet; `serve` prints its progress, Ctrl-C stops it +without losing anything, and the next start continues. `LEGACY_TCODE_DATA_DIR` +names another directory to move from, and moves it even into a data directory +chosen with `--data-dir` or `TCODE_DATA_DIR`. A name present in both +directories stops the move before anything is moved. Until the move is done, +`set-password` refuses to write into the new directory. + ### Run with systemd On a Linux system with systemd, create a user unit at diff --git a/locales/en.yml b/locales/en.yml index 40b20c107..c072f3633 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -220,14 +220,16 @@ quit: confirm: "Quit" startup_failed: "Tcode could not start" migration: - title: "Upgrading thread storage" - description: "Tcode is copying your threads into a new database, tcode.db. The original files are kept in the data folder's legacy/ directory." + title: "Upgrading Tcode's data" + description: "Tcode keeps its data in %{path} and is moving it there, then copying older threads into one database, tcode.db. Every copy is verified before the originals are removed." + relocating: "Moving the data folder to %{path}…" + relocating_copy: "Copying the data folder to %{path}: %{bytes} of %{total_bytes}" scanning: "Looking for threads…" importing: "Copying threads: %{done} of %{total} · %{bytes} of %{total_bytes}" verifying: "Checking the copy: %{done} of %{total} · %{bytes} of %{total_bytes}" publishing: "Finishing…" - archiving: "Moving the original files into legacy/…" - quitting: "Stopping. The original files are unchanged." + archiving: "Removing the original files…" + quitting: "Stopping. Nothing is lost; the upgrade continues the next time Tcode starts." failed: "The upgrade could not finish: %{reason}" settings: title: "Settings" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b6828f15c..1c9eb0327 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -220,14 +220,16 @@ quit: confirm: "退出" startup_failed: "Tcode 无法启动" migration: - title: "正在升级对话存储" - description: "Tcode 正在把你的对话复制到新的数据库 tcode.db,原始文件会保留在数据目录的 legacy/ 文件夹中。" + title: "正在升级 Tcode 数据" + description: "Tcode 的数据现在保存在 %{path},正在把数据移到这里,并把旧版对话复制到统一的数据库 tcode.db。每份副本都会先校验,再删除原始文件。" + relocating: "正在把数据目录移到 %{path}…" + relocating_copy: "正在把数据目录复制到 %{path}:%{bytes}/%{total_bytes}" scanning: "正在查找对话…" importing: "正在复制对话:%{done}/%{total} · %{bytes}/%{total_bytes}" verifying: "正在校验副本:%{done}/%{total} · %{bytes}/%{total_bytes}" publishing: "即将完成…" - archiving: "正在把原始文件移入 legacy/…" - quitting: "正在停止,原始文件未被改动。" + archiving: "正在删除原始文件…" + quitting: "正在停止。数据不会丢失,下次启动 Tcode 时会继续升级。" failed: "升级未能完成:%{reason}" settings: title: "设置"