1pub mod app;
23pub mod capture;
24pub mod merge;
25#[cfg(any(target_os = "macos", test))]
26pub mod prepare;
27
28use std::collections::BTreeSet;
29use std::path::{Path, PathBuf};
30
31use serde::Deserialize;
32
33use crate::config::AnthropicConfig;
34use crate::display::sanitize_untrusted_path;
35use crate::error::{AppError, Result};
36
37use app::AppControl;
38use merge::{ScheduledMerge, SessionMerge};
39
40const CONFIG_JSON: &str = "config.json";
42const SESSIONS_DIR: &str = "claude-code-sessions";
44const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
47const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
49const BRIDGE_FILE: &str = "bridge-state.json";
54const DEVICE_REGISTRY: &str = "ant-device-registry.json";
57
58const TOKEN_CACHE: &str = "config-tokenCache";
60const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
61const DESKTOP_STATE: &str = "desktop-state";
62const META_JSON: &str = "meta.json";
63pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
67
68#[derive(Debug, Clone)]
72pub struct Paths {
73 pub data_dir: PathBuf,
74 pub profiles_dir: PathBuf,
75 pub backups_dir: PathBuf,
76}
77
78impl Paths {
79 pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
81 Self {
82 data_dir,
83 profiles_dir,
84 backups_dir,
85 }
86 }
87
88 pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
91 let home = crate::cache::home_dir()?;
92 let profiles_dir = anthropic
93 .desktop_profiles_dir
94 .clone()
95 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
96 let backups_dir = profiles_dir
97 .parent()
98 .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
99 .join("backups");
100 Ok(Self {
101 data_dir: home.join("Library/Application Support/Claude"),
102 profiles_dir,
103 backups_dir,
104 })
105 }
106
107 pub fn for_data_dir(data_dir: PathBuf, anthropic: &AnthropicConfig) -> Result<Self> {
122 let home = crate::cache::home_dir()?;
123 let profiles_dir = anthropic
124 .desktop_profiles_dir
125 .clone()
126 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
127 let mut sibling = data_dir.clone().into_os_string();
130 sibling.push("-ai-usagebar");
131 Ok(Self {
132 data_dir,
133 profiles_dir,
134 backups_dir: PathBuf::from(sibling).join("backups"),
135 })
136 }
137
138 pub fn available(&self) -> bool {
141 self.data_dir.is_dir()
142 }
143
144 pub fn config_json(&self) -> PathBuf {
145 self.data_dir.join(CONFIG_JSON)
146 }
147
148 pub fn sessions_root(&self) -> PathBuf {
149 self.data_dir.join(SESSIONS_DIR)
150 }
151
152 pub fn profile_dir(&self, label: &str) -> PathBuf {
153 self.profiles_dir.join(label)
154 }
155
156 pub fn account_switch_lock(&self) -> PathBuf {
159 self.backups_dir.join(".account-switch.lock")
160 }
161
162 pub fn prelogin_dir(&self) -> PathBuf {
165 self.backups_dir.with_file_name("prelogin-backup")
166 }
167
168 pub fn synced_path(&self) -> PathBuf {
173 self.backups_dir.with_file_name("synced.json")
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ProfileMeta {
180 pub label: String,
183 pub email: Option<String>,
184 pub account_uuid: String,
185 pub org_uuid: Option<String>,
189 pub has_credentials: bool,
190 pub has_desktop_state: bool,
191}
192
193#[derive(Debug, Deserialize)]
194struct RawMeta {
195 email: Option<String>,
196 #[serde(rename = "accountUuid")]
197 account_uuid: Option<String>,
198 #[serde(rename = "orgUuid")]
199 org_uuid: Option<String>,
200}
201
202pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
206 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
207 return Vec::new();
208 };
209 let mut profiles: Vec<ProfileMeta> = entries
210 .flatten()
211 .filter_map(|entry| {
212 let dir = entry.path();
213 let label = dir.file_name()?.to_str()?.to_string();
214 let raw: RawMeta =
215 serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
216 let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
217 Some(ProfileMeta {
218 label,
219 email: raw.email.filter(|email| !email.is_empty()),
220 account_uuid,
221 org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
222 has_credentials: dir.join(TOKEN_CACHE).is_file()
223 && dir.join(TOKEN_CACHE_V2).is_file(),
224 has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
225 })
226 })
227 .collect();
228 profiles.sort_by(|a, b| a.label.cmp(&b.label));
229 profiles
230}
231
232pub fn load_synced(path: &Path) -> merge::Synced {
236 std::fs::read(path)
237 .map(|bytes| merge::parse_synced(&bytes))
238 .unwrap_or_default()
239}
240
241pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
244 crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
245}
246
247pub fn active_account_uuid(config_json: &Path) -> Option<String> {
250 let bytes = std::fs::read(config_json).ok()?;
251 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
252 value
253 .get("lastKnownAccountUuid")?
254 .as_str()
255 .filter(|uuid| !uuid.is_empty())
256 .map(str::to_string)
257}
258
259pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
260 profiles
261 .iter()
262 .find(|profile| profile.account_uuid == account_uuid)
263 .map(|profile| profile.label.as_str())
264}
265
266pub fn switchable(paths: &Paths) -> Option<(Vec<String>, Option<String>)> {
269 if !paths.available() {
270 return None;
271 }
272 let profiles = load_profiles(&paths.profiles_dir);
273 if profiles.is_empty() {
274 return None;
275 }
276 let active = active_account_uuid(&paths.config_json())
277 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
278 Some((
279 profiles.into_iter().map(|profile| profile.label).collect(),
280 active,
281 ))
282}
283
284pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
286 let Some(org) = &profile.org_uuid else {
287 return 0;
288 };
289 let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
290 return 0;
291 };
292 entries
293 .flatten()
294 .filter(|entry| {
295 entry
296 .path()
297 .extension()
298 .is_some_and(|extension| extension == "json")
299 })
300 .count()
301}
302
303#[derive(Debug, Clone)]
305pub struct SwitchOpts {
306 pub keep_bridge: bool,
310 pub backup_sessions: bool,
315 pub keep_backups: usize,
317}
318
319impl Default for SwitchOpts {
320 fn default() -> Self {
321 Self {
322 keep_bridge: false,
323 backup_sessions: false,
324 keep_backups: 10,
325 }
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct SavedTokens {
333 pub token_cache: String,
334 pub token_cache_v2: String,
335}
336
337#[derive(Debug)]
339pub struct SwitchPlan {
340 pub target: ProfileMeta,
341 pub outgoing: Option<String>,
343 pub sessions: SessionMerge,
344 pub scheduled: Option<ScheduledMerge>,
347 pub tokens: SavedTokens,
351 pub archive: PathBuf,
352 pub archive_members: Vec<String>,
353 pub restores_desktop_state: bool,
354 pub opts: SwitchOpts,
355 pub deletions: Vec<merge::DeletionCandidate>,
359 pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
362 pub prior_synced: merge::Synced,
365}
366
367pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
372 let profiles = load_profiles(&paths.profiles_dir);
373 let target = profiles
374 .iter()
375 .find(|profile| profile.label == label)
376 .cloned()
377 .ok_or_else(|| {
378 let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
379 AppError::Credentials(format!(
380 "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
381 Capture one with `claude-acc add {label}` \
382 (https://github.com/ohmaseclaro/claude-acc)",
383 sanitize_untrusted_path(&paths.profiles_dir)
384 ))
385 })?;
386
387 let sessions_root = paths.sessions_root();
388 let synced = load_synced(&paths.synced_path());
389 let (sessions, scheduled) = match &target.org_uuid {
390 Some(org) => (
391 merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
392 Some(merge::plan_scheduled_merge(
393 &sessions_root,
394 &target.account_uuid,
395 org,
396 &synced,
397 )?),
398 ),
399 None => (SessionMerge::default(), None),
400 };
401 let deletions = merge::deletion_candidates(&sessions_root, &synced);
402
403 let outgoing = active_account_uuid(&paths.config_json())
404 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
405
406 let profile_dir = paths.profile_dir(label);
407 let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
408 AppError::Credentials(format!(
409 "no complete saved Desktop credential for {label:?}; capture or sign into that \
410 account before switching"
411 ))
412 })?;
413 let token_cache_v2 =
414 std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
415 AppError::Credentials(format!(
416 "no complete saved Desktop credential for {label:?}; capture or sign into that \
417 account before switching"
418 ))
419 })?;
420 if token_cache.is_empty() || token_cache_v2.is_empty() {
421 return Err(AppError::Credentials(format!(
422 "the saved Desktop credential for {label:?} is empty; capture it again before switching"
423 )));
424 }
425 let tokens = SavedTokens {
426 token_cache,
427 token_cache_v2,
428 };
429 if !profile_dir.join(DESKTOP_STATE).is_dir() {
430 return Err(AppError::Credentials(format!(
431 "no saved Desktop browser state for {label:?}; capture that account again before switching"
432 )));
433 }
434
435 let stamp = crate::claude_desktop::timestamp();
436 Ok(SwitchPlan {
437 archive: paths
438 .backups_dir
439 .join(format!("switch-{stamp}-{label}.tar.gz")),
440 archive_members: archive_members(paths, &opts),
441 restores_desktop_state: true,
442 target,
443 outgoing,
444 sessions,
445 scheduled,
446 tokens,
447 opts,
448 deletions,
449 confirmed_deletions: BTreeSet::new(),
450 prior_synced: synced,
451 })
452}
453
454#[derive(Debug)]
463pub struct HistoryMerge {
464 pub account_uuid: String,
465 pub org_uuid: Option<String>,
468 pub sessions: SessionMerge,
469 pub scheduled: Option<ScheduledMerge>,
470 prior_synced: merge::Synced,
471}
472
473impl HistoryMerge {
474 #[must_use]
475 pub fn is_empty(&self) -> bool {
476 let scheduled_idle = match &self.scheduled {
477 Some(scheduled) => scheduled.added == 0 && scheduled.updated == 0,
478 None => true,
479 };
480 self.sessions.is_empty() && scheduled_idle
481 }
482}
483
484fn resolve_org(sessions_root: &Path, account_uuid: &str, config: &[u8]) -> Option<String> {
488 let dir = sessions_root.join(account_uuid);
489 let mut orgs: Vec<String> = std::fs::read_dir(&dir)
490 .ok()?
491 .flatten()
492 .filter(|entry| entry.path().is_dir())
493 .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
494 .collect();
495 orgs.sort();
496 if orgs.len() > 1 {
497 let known = merge::orgs_in_config(config);
498 let mut narrowed: Vec<String> = orgs
499 .iter()
500 .filter(|org| known.contains(org))
501 .cloned()
502 .collect();
503 if narrowed.len() == 1 {
504 return narrowed.pop();
505 }
506 orgs.sort_by_key(|org| {
507 std::fs::metadata(dir.join(org))
508 .and_then(|meta| meta.modified())
509 .ok()
510 });
511 }
512 orgs.pop()
513}
514
515pub fn history_target(paths: &Paths) -> Result<(String, Option<String>)> {
517 let config_json = paths.config_json();
518 let config = std::fs::read(&config_json).map_err(|e| AppError::io_at(&config_json, e))?;
519 let account_uuid = merge::logged_in_account(&config).ok_or_else(|| {
520 AppError::Credentials(format!(
521 "no account is signed into {}; sign in before merging history",
522 sanitize_untrusted_path(&paths.data_dir)
523 ))
524 })?;
525 let org_uuid = resolve_org(&paths.sessions_root(), &account_uuid, &config);
526 Ok((account_uuid, org_uuid))
527}
528
529#[derive(Debug, Default)]
531pub struct Staged {
532 pub sessions: usize,
534 pub registries: usize,
536 pub unreadable: Vec<PathBuf>,
540}
541
542pub fn stage_history_sources(
558 paths: &Paths,
559 sources: &[PathBuf],
560 own: Option<(&str, &str)>,
561 dry_run: bool,
562) -> Staged {
563 let mut staged = Staged::default();
564 let root = paths.sessions_root();
565 let own_dir = own.map(|(account, org)| root.join(account).join(org));
566
567 for source in sources {
568 let source_root = source.join(SESSIONS_DIR);
569 let Ok(accounts) = std::fs::read_dir(&source_root) else {
570 if source_root.exists() {
571 staged.unreadable.push(source_root);
572 }
573 continue;
574 };
575 for account in accounts.flatten() {
576 let Ok(orgs) = std::fs::read_dir(account.path()) else {
577 staged.unreadable.push(account.path());
578 continue;
579 };
580 for org in orgs.flatten().filter(|org| org.path().is_dir()) {
581 let Some(account_name) = account.file_name().to_str().map(str::to_string) else {
582 continue;
583 };
584 let Some(org_name) = org.file_name().to_str().map(str::to_string) else {
585 continue;
586 };
587 let destination_dir = root.join(&account_name).join(&org_name);
588 if !destination_dir.starts_with(&root) {
590 continue;
591 }
592 let Ok(entries) = std::fs::read_dir(org.path()) else {
593 staged.unreadable.push(org.path());
594 continue;
595 };
596 for entry in entries.flatten() {
597 let from = entry.path();
598 let Some(name) = from.file_name().and_then(|n| n.to_str()) else {
599 continue;
600 };
601 let is_index = name.starts_with("local_") && name.ends_with(".json");
602 let is_registry = name == merge::SCHEDULED_TASKS;
603 if !is_index && !is_registry {
604 continue;
605 }
606 let to = destination_dir.join(name);
607 let absent_only =
610 is_registry && own_dir.as_deref() == Some(destination_dir.as_path());
611 if !should_place(&from, &to, absent_only) {
612 continue;
613 }
614 if dry_run || copy_file(&from, &to).is_ok() {
615 if is_index {
616 staged.sessions += 1;
617 } else {
618 staged.registries += 1;
619 }
620 } else {
621 staged.unreadable.push(from);
622 }
623 }
624 }
625 }
626 }
627 staged
628}
629
630fn should_place(from: &Path, to: &Path, absent_only: bool) -> bool {
634 let Ok(destination) = std::fs::metadata(to) else {
635 return true;
636 };
637 if absent_only {
638 return false;
639 }
640 let newer = |meta: std::fs::Metadata| meta.modified().ok();
641 match (
642 std::fs::metadata(from).ok().and_then(newer),
643 newer(destination),
644 ) {
645 (Some(source), Some(existing)) => source > existing,
646 _ => false,
647 }
648}
649
650pub fn plan_history_merge(paths: &Paths) -> Result<HistoryMerge> {
652 let config_json = paths.config_json();
653 let config = std::fs::read(&config_json).map_err(|e| AppError::io_at(&config_json, e))?;
654 let account_uuid = merge::logged_in_account(&config).ok_or_else(|| {
655 AppError::Credentials(format!(
656 "no account is signed into {}; sign in before merging history",
657 sanitize_untrusted_path(&paths.data_dir)
658 ))
659 })?;
660
661 let sessions_root = paths.sessions_root();
662 let synced = load_synced(&paths.synced_path());
663 let org_uuid = resolve_org(&sessions_root, &account_uuid, &config);
664 let (sessions, scheduled) = match &org_uuid {
665 Some(org) => (
666 merge::plan_session_merge(&sessions_root, &account_uuid, org),
667 Some(merge::plan_scheduled_merge(
668 &sessions_root,
669 &account_uuid,
670 org,
671 &synced,
672 )?),
673 ),
674 None => (SessionMerge::default(), None),
675 };
676
677 Ok(HistoryMerge {
678 account_uuid,
679 org_uuid,
680 sessions,
681 scheduled,
682 prior_synced: synced,
683 })
684}
685
686pub fn apply_history_merge(paths: &Paths, plan: &HistoryMerge) -> Result<Vec<String>> {
693 let mut notes = Vec::new();
694 let display_names = plan
695 .scheduled
696 .as_ref()
697 .map(ScheduledMerge::display_names)
698 .transpose()?;
699
700 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
701 copy_file(source, destination)?;
702 }
703 if let Some(scheduled) = &plan.scheduled {
704 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
705 }
706 if let Some(display_names) = &display_names {
707 match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
708 Ok(convergence) => {
709 for (path, bytes) in convergence.rewrites {
710 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
711 notes.push(format!(
712 "could not converge routine names in {}: {error}",
713 sanitize_untrusted_path(&path)
714 ));
715 }
716 }
717 }
718 Err(error) => notes.push(format!("name convergence skipped: {error}")),
719 }
720 }
721
722 let mut synced = merge::current_state(&paths.sessions_root());
725 let mut canonical = plan
726 .scheduled
727 .as_ref()
728 .map(|scheduled| scheduled.canonical_routines.clone())
729 .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
730 let present: BTreeSet<String> = synced
731 .values()
732 .flat_map(|account| account.routines.iter().cloned())
733 .collect();
734 canonical.retain(|id, _| present.contains(id));
735 merge::set_canonical_routines(&mut synced, &canonical);
736 if let Err(error) = save_synced(&paths.synced_path(), &synced) {
737 notes.push(format!("could not record the schedule sync: {error}"));
738 }
739 Ok(notes)
740}
741
742pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
748 let mut notes = Vec::new();
749 let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
750
751 if let Err(error) = app.quit() {
753 return match app.relaunch() {
758 Ok(()) => Err(error),
759 Err(relaunch) => Err(AppError::Other(format!(
760 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
761 ))),
762 };
763 }
764 let mut archived = false;
765 let mut result = (|| {
766 if !members.is_empty() {
767 app.archive(&plan.archive, &paths.data_dir, &members)?;
768 archived = true;
769 prune_archives(
772 &paths.backups_dir,
773 plan.opts.keep_backups.max(1),
774 &mut notes,
775 );
776 }
777 apply_switch_while_stopped(paths, plan, &mut notes)
778 })();
779
780 if result.is_err()
781 && archived
782 && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
783 {
784 let original = result.unwrap_err();
785 result = Err(AppError::Other(format!(
786 "{original}; automatic Desktop rollback was incomplete: {rollback}"
787 )));
788 }
789
790 let relaunch = app.relaunch();
791 match (result, relaunch) {
792 (Ok(()), Ok(())) => Ok(notes),
793 (Err(error), Ok(())) => Err(error),
794 (Ok(()), Err(error)) => Err(error),
795 (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
796 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
797 ))),
798 }
799}
800
801fn apply_switch_while_stopped(
802 paths: &Paths,
803 plan: &SwitchPlan,
804 notes: &mut Vec<String>,
805) -> Result<()> {
806 let display_names = plan
809 .scheduled
810 .as_ref()
811 .map(ScheduledMerge::display_names)
812 .transpose()?;
813
814 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
818 copy_file(source, destination)?;
819 }
820 if let Some(scheduled) = &plan.scheduled {
821 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
822 }
823 match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
828 Ok(sweep) => {
829 for (path, bytes) in sweep.rewrites {
830 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
831 notes.push(format!(
832 "could not apply deletion to {}: {error}",
833 sanitize_untrusted_path(&path)
834 ));
835 }
836 }
837 for path in sweep.removals {
842 if let Err(error) = std::fs::remove_file(&path) {
843 notes.push(format!(
844 "could not remove {}: {error}",
845 sanitize_untrusted_path(&path)
846 ));
847 }
848 }
849 }
850 Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
851 }
852 if let Some(display_names) = &display_names {
858 match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
859 Ok(convergence) => {
860 let mut fully_applied = true;
861 for (path, bytes) in convergence.rewrites {
862 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
863 fully_applied = false;
864 notes.push(format!(
865 "could not converge routine names in {}: {error}",
866 sanitize_untrusted_path(&path)
867 ));
868 }
869 }
870 if fully_applied && convergence.converged > 0 {
871 notes.push(format!(
872 "converged {} routine name(s) across accounts",
873 convergence.converged
874 ));
875 }
876 }
877 Err(error) => notes.push(format!("name convergence skipped: {error}")),
878 }
879 }
880 let mut synced = merge::current_state(&paths.sessions_root());
883 let mut canonical = plan
884 .scheduled
885 .as_ref()
886 .map(|scheduled| scheduled.canonical_routines.clone())
887 .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
888 for key in &plan.confirmed_deletions {
889 if key.kind == merge::ConflictKind::Routine {
890 canonical.remove(&key.id);
891 }
892 }
893 let present: BTreeSet<String> = synced
894 .values()
895 .flat_map(|account| account.routines.iter().cloned())
896 .collect();
897 canonical.retain(|id, _| present.contains(id));
898 merge::set_canonical_routines(&mut synced, &canonical);
899 if let Err(error) = save_synced(&paths.synced_path(), &synced) {
900 notes.push(format!("could not record the schedule sync: {error}"));
901 }
902
903 let live_config = paths.config_json();
908 let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
909 label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
910 });
911 if let Some(label) = &outgoing {
912 snapshot_profile(paths, label, notes)?;
913 }
914
915 swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
916
917 restore_desktop_state(paths, &plan.target.label)?;
918
919 if !plan.opts.keep_bridge {
920 let bridge = paths.data_dir.join(BRIDGE_FILE);
921 if bridge.is_file()
922 && let Err(error) = std::fs::remove_file(&bridge)
923 {
924 notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
925 }
926 }
927 restore_device_registry(paths, &plan.target.label, notes);
928
929 Ok(())
930}
931
932fn merge_history_into(
939 paths: &Paths,
940 account_uuid: &str,
941 org_uuid: &str,
942 notes: &mut Vec<String>,
943) -> (usize, usize) {
944 let sessions_root = paths.sessions_root();
945 let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
946 let mut copied = 0;
947 for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
948 match copy_file(source, destination) {
949 Ok(()) => copied += 1,
950 Err(error) => notes.push(format!(
951 "could not seed {}: {error}",
952 sanitize_untrusted_path(destination)
953 )),
954 }
955 }
956 let routines = match merge::plan_scheduled_merge(
957 &sessions_root,
958 account_uuid,
959 org_uuid,
960 &load_synced(&paths.synced_path()),
961 ) {
962 Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
963 Ok(()) => scheduled.added + scheduled.updated,
964 Err(error) => {
965 notes.push(format!("schedule seed skipped: {error}"));
966 0
967 }
968 },
969 Err(error) => {
970 notes.push(format!("schedule seed skipped: {error}"));
971 0
972 }
973 };
974 (copied, routines)
975}
976
977fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
985 let profile_dir = paths.profile_dir(label);
986 std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
987 restrict(&profile_dir, 0o700, notes);
988
989 let bytes =
990 std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
991 let value: serde_json::Value = serde_json::from_slice(&bytes)?;
992 let live_tokens = SavedTokens {
993 token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
994 token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
995 };
996 write_saved_tokens(&profile_dir, &live_tokens, notes)?;
997
998 let registry = paths.data_dir.join(DEVICE_REGISTRY);
1001 if registry.is_file() {
1002 let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
1003 crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
1004 }
1005
1006 snapshot_desktop_state(paths, &profile_dir, notes)?;
1007 Ok(())
1009}
1010
1011fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1012 value
1013 .get(key)
1014 .and_then(serde_json::Value::as_str)
1015 .filter(|blob| !blob.is_empty())
1016 .ok_or_else(|| {
1017 AppError::Credentials(format!(
1018 "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
1019 ))
1020 })
1021}
1022
1023fn write_saved_tokens(
1024 profile_dir: &Path,
1025 tokens: &SavedTokens,
1026 notes: &mut Vec<String>,
1027) -> Result<()> {
1028 let token_path = profile_dir.join(TOKEN_CACHE);
1029 let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
1030 let original = read_optional_file(&token_path)?;
1031 let original_v2 = read_optional_file(&token_v2_path)?;
1032 let write_result = (|| {
1033 crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
1034 crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
1035 Ok(())
1036 })();
1037 if let Err(error) = write_result {
1038 let mut rollback = Vec::new();
1039 if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
1040 rollback.push(failure.to_string());
1041 }
1042 if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
1043 rollback.push(failure.to_string());
1044 }
1045 return if rollback.is_empty() {
1046 Err(error)
1047 } else {
1048 Err(AppError::Other(format!(
1049 "{error}; saved-token rollback was incomplete: {}",
1050 rollback.join("; ")
1051 )))
1052 };
1053 }
1054 restrict(&token_path, 0o600, notes);
1055 restrict(&token_v2_path, 0o600, notes);
1056 Ok(())
1057}
1058
1059fn snapshot_desktop_state(
1060 paths: &Paths,
1061 profile_dir: &Path,
1062 notes: &mut Vec<String>,
1063) -> Result<()> {
1064 let state_dir = profile_dir.join(DESKTOP_STATE);
1065 let previous = profile_dir.join(".desktop-state.previous");
1066 if previous.exists() && !state_dir.exists() {
1067 std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
1068 } else {
1069 remove_if_present(&previous)?;
1070 }
1071
1072 let staged = tempfile::Builder::new()
1073 .prefix(".desktop-state.pending-")
1074 .tempdir_in(profile_dir)
1075 .map_err(|e| AppError::io_at(profile_dir, e))?;
1076 restrict(staged.path(), 0o700, notes);
1077 for name in COOKIE_FILES {
1078 let source = paths.data_dir.join(name);
1079 let destination = staged.path().join(name);
1080 if source.is_file() {
1081 copy_file(&source, &destination)?;
1082 restrict(&destination, 0o600, notes);
1083 }
1084 }
1085 for name in LEVELDB_DIRS {
1086 let source = paths.data_dir.join(name);
1087 let destination = staged.path().join(name);
1088 if source.is_dir() {
1089 copy_dir(&source, &destination)?;
1090 }
1091 }
1092
1093 let staged = staged.keep();
1094 if state_dir.exists() {
1095 std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
1096 }
1097 if let Err(error) = std::fs::rename(&staged, &state_dir) {
1098 let restore = if previous.exists() {
1099 std::fs::rename(&previous, &state_dir)
1100 } else {
1101 Ok(())
1102 };
1103 let _ = remove_if_present(&staged);
1104 return match restore {
1105 Ok(()) => Err(AppError::io_at(&staged, error)),
1106 Err(rollback) => Err(AppError::Other(format!(
1107 "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
1108 ))),
1109 };
1110 }
1111 if let Err(error) = remove_if_present(&previous) {
1112 notes.push(format!(
1113 "could not remove the previous Desktop-state snapshot: {error}"
1114 ));
1115 }
1116 Ok(())
1117}
1118
1119fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
1120 let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
1121 let bytes = merge::swap_config_tokens(
1122 &existing,
1123 &tokens.token_cache,
1124 &tokens.token_cache_v2,
1125 account_uuid,
1126 )?;
1127 crate::cache::atomic_write(config_json, &bytes)
1130}
1131
1132fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
1133 let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
1134 for name in COOKIE_FILES {
1135 let source = state_dir.join(name);
1136 let destination = paths.data_dir.join(name);
1137 if source.is_file() {
1138 copy_file(&source, &destination)?;
1139 } else {
1140 remove_if_present(&destination)?;
1141 }
1142 }
1143 for name in LEVELDB_DIRS {
1144 let source = state_dir.join(name);
1145 let destination = paths.data_dir.join(name);
1146 if source.is_dir() {
1147 replace_dir(&source, &destination)?;
1148 } else {
1149 remove_if_present(&destination)?;
1150 }
1151 }
1152 Ok(())
1153}
1154
1155fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
1158 let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
1159 let live = paths.data_dir.join(DEVICE_REGISTRY);
1160 let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
1161 return;
1162 };
1163 match merge::merge_device_registry(¤t, &saved) {
1164 Ok(bytes) if bytes != current => {
1165 if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
1166 notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
1167 }
1168 }
1169 Ok(_) => {}
1170 Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
1171 }
1172}
1173
1174fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
1180 let mut members = Vec::new();
1181 for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
1182 if paths.data_dir.join(name).exists() {
1183 members.push(name.to_string());
1184 }
1185 }
1186 for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
1187 if paths.data_dir.join(name).exists() {
1188 members.push(name.to_string());
1189 }
1190 }
1191 if opts.backup_sessions {
1192 if paths.sessions_root().is_dir() {
1193 members.push(SESSIONS_DIR.to_string());
1194 }
1195 return members;
1196 }
1197 let sessions_root = paths.sessions_root();
1198 let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
1199 return members;
1200 };
1201 let mut registries = Vec::new();
1202 for account in accounts.flatten() {
1203 let Ok(orgs) = std::fs::read_dir(account.path()) else {
1204 continue;
1205 };
1206 for org in orgs.flatten() {
1207 let path = org.path().join("scheduled-tasks.json");
1208 if path.is_file()
1209 && let Ok(relative) = path.strip_prefix(&paths.data_dir)
1210 {
1211 registries.push(relative.display().to_string());
1212 }
1213 }
1214 }
1215 registries.sort();
1216 members.extend(registries);
1217 members
1218}
1219
1220fn identity_members() -> Vec<&'static str> {
1221 [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
1222 .into_iter()
1223 .chain(COOKIE_FILES)
1224 .chain(LEVELDB_DIRS)
1225 .collect()
1226}
1227
1228fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
1229 let Ok(entries) = std::fs::read_dir(backups_dir) else {
1230 return;
1231 };
1232 let mut archives: Vec<PathBuf> = entries
1235 .flatten()
1236 .map(|entry| entry.path())
1237 .filter(|path| {
1238 path.file_name()
1239 .and_then(|name| name.to_str())
1240 .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
1241 })
1242 .collect();
1243 if archives.len() <= keep {
1244 return;
1245 }
1246 archives.sort();
1247 let doomed = archives.len() - keep;
1248 for path in archives.into_iter().take(doomed) {
1249 if let Err(error) = std::fs::remove_file(&path) {
1250 notes.push(format!(
1251 "could not prune {}: {error}",
1252 sanitize_untrusted_path(&path)
1253 ));
1254 }
1255 }
1256}
1257
1258fn copy_file(source: &Path, destination: &Path) -> Result<()> {
1259 if let Some(parent) = destination.parent() {
1260 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
1261 }
1262 std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
1263 Ok(())
1264}
1265
1266fn remove_if_present(path: &Path) -> Result<()> {
1267 match std::fs::symlink_metadata(path) {
1268 Ok(metadata) if metadata.is_dir() => {
1269 std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
1270 }
1271 Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
1272 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1273 Err(error) => Err(AppError::io_at(path, error)),
1274 }
1275}
1276
1277fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
1278 match std::fs::read(path) {
1279 Ok(bytes) => Ok(Some(bytes)),
1280 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1281 Err(error) => Err(AppError::io_at(path, error)),
1282 }
1283}
1284
1285fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
1286 match original {
1287 Some(bytes) => crate::cache::atomic_write(path, bytes),
1288 None => remove_if_present(path),
1289 }
1290}
1291
1292fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
1296 if destination.exists() {
1297 std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
1298 }
1299 copy_dir(source, destination)
1300}
1301
1302fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
1303 std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
1304 let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
1305 for entry in entries.flatten() {
1306 let child = entry.path();
1307 let target = destination.join(entry.file_name());
1308 if child.is_dir() {
1309 copy_dir(&child, &target)?;
1310 } else {
1311 std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
1312 }
1313 }
1314 Ok(())
1315}
1316
1317#[cfg(unix)]
1318fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
1319 use std::os::unix::fs::PermissionsExt;
1320
1321 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
1322 notes.push(format!(
1323 "could not restrict {}: {error}",
1324 sanitize_untrusted_path(path)
1325 ));
1326 }
1327}
1328
1329#[cfg(not(unix))]
1330fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
1331
1332fn timestamp() -> String {
1333 chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338 use super::*;
1339 use app::Recorder;
1340 use std::cell::RefCell;
1341
1342 struct Fixture {
1343 _root: tempfile::TempDir,
1344 paths: Paths,
1345 }
1346
1347 #[derive(Default)]
1348 struct QuitFailure {
1349 steps: RefCell<Vec<&'static str>>,
1350 }
1351
1352 impl AppControl for QuitFailure {
1353 fn quit(&self) -> Result<()> {
1354 self.steps.borrow_mut().push("quit");
1355 Err(AppError::Other("liveness probe failed".into()))
1356 }
1357
1358 fn relaunch(&self) -> Result<()> {
1359 self.steps.borrow_mut().push("relaunch");
1360 Ok(())
1361 }
1362
1363 fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1364 panic!("archive must not run after an unconfirmed quit")
1365 }
1366
1367 fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1368 panic!("restore must not run before a switch starts")
1369 }
1370 }
1371
1372 fn write(path: &Path, contents: &str) {
1373 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1374 std::fs::write(path, contents).unwrap();
1375 }
1376
1377 fn fixture() -> Fixture {
1379 let root = tempfile::TempDir::new().unwrap();
1380 let data = root.path().join("data");
1381 let profiles = root.path().join("profiles");
1382 let backups = root.path().join("backups");
1383
1384 write(
1385 &data.join(CONFIG_JSON),
1386 r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
1387 "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1388 );
1389 write(
1390 &data.join(DEVICE_REGISTRY),
1391 r#"{"uuid-here":{"deviceId":"d1"}}"#,
1392 );
1393 write(
1394 &data.join(BRIDGE_FILE),
1395 r#"{"remoteSessionId":"cse_stale"}"#,
1396 );
1397 write(&data.join("Cookies"), "live-cookies");
1398 write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1399 write(
1400 &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1401 r#"{"lastActivityAt":500}"#,
1402 );
1403 write(
1404 &data
1405 .join(SESSIONS_DIR)
1406 .join("uuid-here/org-1/scheduled-tasks.json"),
1407 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1408 );
1409
1410 write(
1411 &profiles.join("here/meta.json"),
1412 r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1413 );
1414 write(
1415 &profiles.join("there/meta.json"),
1416 r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1417 );
1418 write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1419 write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1420 write(
1421 &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1422 "there-cookies",
1423 );
1424 write(
1425 &profiles
1426 .join("there")
1427 .join(DESKTOP_STATE)
1428 .join("Local Storage/leveldb/CURRENT"),
1429 "there-ldb",
1430 );
1431
1432 Fixture {
1433 paths: Paths::at(data, profiles, backups),
1434 _root: root,
1435 }
1436 }
1437
1438 #[test]
1439 fn switchable_lists_saved_accounts_and_the_signed_in_one() {
1440 let f = fixture();
1441 assert_eq!(
1442 switchable(&f.paths),
1443 Some((vec!["here".into(), "there".into()], Some("here".into())))
1444 );
1445 }
1446
1447 #[test]
1448 fn switchable_is_none_without_the_app_or_a_saved_account() {
1449 let f = fixture();
1450 let no_app = Paths::at(
1451 f.paths.data_dir.join("missing"),
1452 f.paths.profiles_dir.clone(),
1453 f.paths.backups_dir.clone(),
1454 );
1455 assert_eq!(switchable(&no_app), None);
1456 let no_profiles = Paths::at(
1457 f.paths.data_dir.clone(),
1458 f.paths.profiles_dir.join("missing"),
1459 f.paths.backups_dir.clone(),
1460 );
1461 assert_eq!(switchable(&no_profiles), None);
1462 }
1463
1464 fn manifest(root: &Path) -> Vec<(String, u64)> {
1467 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1468 let Ok(entries) = std::fs::read_dir(dir) else {
1469 return;
1470 };
1471 for entry in entries.flatten() {
1472 let path = entry.path();
1473 if path.is_dir() {
1474 walk(&path, root, out);
1475 } else if let Ok(meta) = entry.metadata() {
1476 let relative = path.strip_prefix(root).unwrap().display().to_string();
1477 out.push((relative, meta.len()));
1478 }
1479 }
1480 }
1481 let mut out = Vec::new();
1482 walk(root, root, &mut out);
1483 out.sort();
1484 out
1485 }
1486
1487 #[test]
1488 fn profiles_load_sorted_with_their_capture_state() {
1489 let fixture = fixture();
1490 let profiles = load_profiles(&fixture.paths.profiles_dir);
1491
1492 assert_eq!(profiles.len(), 2);
1493 assert_eq!(profiles[0].label, "here");
1494 assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1495 assert!(!profiles[0].has_credentials);
1496 assert_eq!(profiles[1].label, "there");
1497 assert!(profiles[1].has_credentials);
1498 assert!(profiles[1].has_desktop_state);
1499 }
1500
1501 #[test]
1502 fn a_malformed_profile_is_skipped_not_fatal() {
1503 let fixture = fixture();
1504 write(
1505 &fixture.paths.profiles_dir.join("broken/meta.json"),
1506 "{ not json",
1507 );
1508 write(
1509 &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1510 r#"{"label":"x"}"#,
1511 );
1512
1513 let profiles = load_profiles(&fixture.paths.profiles_dir);
1514 let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1515 assert_eq!(labels, ["here", "there"]);
1516 }
1517
1518 #[test]
1519 fn the_active_account_resolves_to_its_label() {
1520 let fixture = fixture();
1521 let profiles = load_profiles(&fixture.paths.profiles_dir);
1522 let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1523
1524 assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1525 assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1526 }
1527
1528 #[test]
1529 fn session_counts_come_from_the_accounts_own_folder() {
1530 let fixture = fixture();
1531 let profiles = load_profiles(&fixture.paths.profiles_dir);
1532 let sessions_root = fixture.paths.sessions_root();
1533
1534 assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1535 assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1536 }
1537
1538 #[test]
1539 fn planning_a_switch_changes_nothing_on_disk() {
1540 let fixture = fixture();
1541 let before = manifest(&fixture.paths.data_dir);
1542
1543 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1544
1545 assert_eq!(plan.outgoing.as_deref(), Some("here"));
1546 assert_eq!(plan.tokens.token_cache, "saved-a");
1547 assert!(plan.restores_desktop_state);
1548 assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1549 assert_eq!(manifest(&fixture.paths.data_dir), before);
1550 assert!(!fixture.paths.backups_dir.exists());
1551 }
1552
1553 #[test]
1554 fn planning_an_unknown_label_lists_the_known_ones() {
1555 let fixture = fixture();
1556 let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1557 let message = error.to_string();
1558 assert!(message.contains("here"), "{message}");
1559 assert!(message.contains("claude-acc"), "{message}");
1560 }
1561
1562 #[test]
1563 fn the_archive_skips_the_session_tree_by_default() {
1564 let fixture = fixture();
1565
1566 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1567 assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1568 assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1569 assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1570 assert!(plan.archive_members.contains(&"Cookies".to_string()));
1571 assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1572 assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1573 assert!(
1574 plan.archive_members
1575 .iter()
1576 .any(|member| member.ends_with("scheduled-tasks.json"))
1577 );
1578
1579 let full = plan_switch(
1580 &fixture.paths,
1581 "there",
1582 SwitchOpts {
1583 backup_sessions: true,
1584 ..SwitchOpts::default()
1585 },
1586 )
1587 .unwrap();
1588 assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1589 }
1590
1591 #[test]
1592 fn applying_a_switch_quits_then_archives_then_relaunches() {
1593 let fixture = fixture();
1594 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1595 let recorder = Recorder::default();
1596
1597 apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1598
1599 let steps = recorder.steps();
1600 assert_eq!(steps[0], "quit");
1601 assert!(steps[1].starts_with("archive "), "{steps:?}");
1602 assert_eq!(steps[2], "relaunch");
1603 }
1604
1605 #[test]
1606 fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1607 let fixture = fixture();
1608 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1609 let before = std::fs::read(fixture.paths.config_json()).unwrap();
1610 let app = QuitFailure::default();
1611
1612 let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1613
1614 assert!(error.to_string().contains("liveness probe failed"));
1615 assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1616 assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1617 }
1618
1619 #[test]
1624 fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1625 let fixture = fixture();
1626 let sessions = fixture.paths.sessions_root();
1627 write(
1629 &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1630 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1631 );
1632 write(
1633 &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1634 r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1635 );
1636
1637 let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1638 plan.confirmed_deletions = [merge::DeletionKey {
1639 kind: merge::ConflictKind::Routine,
1640 id: "t1".to_string(),
1641 deleted_by: "uuid-here".to_string(),
1642 still_in: vec!["uuid-there".to_string()],
1643 }]
1644 .into_iter()
1645 .collect();
1646 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1647
1648 for registry in [
1649 sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1650 sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1651 ] {
1652 let value: serde_json::Value =
1653 serde_json::from_slice(&std::fs::read(®istry).unwrap()).unwrap();
1654 let ids: Vec<&str> = value["scheduledTasks"]
1655 .as_array()
1656 .unwrap()
1657 .iter()
1658 .filter_map(|task| task["id"].as_str())
1659 .collect();
1660 assert!(
1661 !ids.contains(&"t1"),
1662 "{registry:?} still holds the deleted task"
1663 );
1664 }
1665 let there: serde_json::Value = serde_json::from_slice(
1668 &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1669 )
1670 .unwrap();
1671 assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1672
1673 let recorded = load_synced(&fixture.paths.synced_path());
1676 assert!(!recorded.is_empty(), "the sync record was never written");
1677 assert!(
1678 recorded
1679 .values()
1680 .all(|state| !state.routines.contains("t1")),
1681 "{recorded:?} still lists the deleted task"
1682 );
1683 }
1684
1685 #[test]
1690 fn keeping_every_conflict_leaves_other_accounts_untouched() {
1691 let fixture = fixture();
1692 let sessions = fixture.paths.sessions_root();
1693 let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1694 write(
1695 &bystander,
1696 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1697 );
1698 let before = std::fs::read(&bystander).unwrap();
1699
1700 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1701 assert!(plan.confirmed_deletions.is_empty());
1702 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1703
1704 assert_eq!(std::fs::read(&bystander).unwrap(), before);
1705 }
1706
1707 #[test]
1711 fn a_switch_converges_routine_names_across_accounts() {
1712 let fixture = fixture();
1713 let sessions = fixture.paths.sessions_root();
1714 let here = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1715 let there = sessions.join("uuid-there/org-2/scheduled-tasks.json");
1716 write(
1718 &here,
1719 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"Home name"}]}"#,
1720 );
1721 write(
1722 &there,
1723 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"There name"}]}"#,
1724 );
1725
1726 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1727 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1728
1729 let name = |path: &std::path::Path| -> String {
1730 let v: serde_json::Value =
1731 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1732 v["scheduledTasks"][0]["displayName"]
1733 .as_str()
1734 .unwrap_or_default()
1735 .to_string()
1736 };
1737 assert_eq!(name(&here), name(&there), "names did not converge");
1739 assert!(!name(&here).is_empty());
1740 }
1741
1742 #[test]
1743 fn applying_a_switch_swaps_the_credential_and_carries_history() {
1744 let fixture = fixture();
1745 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1746
1747 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1748
1749 let config: serde_json::Value =
1750 serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1751 assert_eq!(config["oauth:tokenCache"], "saved-a");
1752 assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1753 assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1754 assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1755
1756 assert!(
1758 fixture
1759 .paths
1760 .sessions_root()
1761 .join("uuid-there/org-2/local_x.json")
1762 .is_file()
1763 );
1764 assert_eq!(
1765 std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1766 "there-cookies"
1767 );
1768 assert_eq!(
1769 std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1770 .unwrap(),
1771 "there-ldb"
1772 );
1773 assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1775 }
1776
1777 #[test]
1778 fn the_outgoing_account_is_snapshotted_before_the_swap() {
1779 let fixture = fixture();
1780 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1781
1782 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1783
1784 let here = fixture.paths.profile_dir("here");
1788 assert_eq!(
1789 std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1790 "live-a"
1791 );
1792 assert_eq!(
1793 std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1794 "live-b"
1795 );
1796 assert_eq!(
1797 std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1798 "live-cookies"
1799 );
1800 let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1802 assert!(meta.contains("here@example.com"), "{meta}");
1803 }
1804
1805 #[test]
1806 fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1807 let fixture = fixture();
1808 std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1809 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1810 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1811 assert!(error.to_string().contains("credential"), "{error}");
1812 }
1813
1814 #[test]
1815 fn planning_refuses_credentials_without_saved_browser_state() {
1816 let fixture = fixture();
1817 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1818
1819 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1820
1821 assert!(error.to_string().contains("browser state"), "{error}");
1822 }
1823
1824 #[test]
1825 fn a_failed_switch_requests_rollback_and_still_relaunches() {
1826 let fixture = fixture();
1827 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1828 write(&fixture.paths.config_json(), "{ not json");
1829 let recorder = Recorder::default();
1830
1831 let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1832
1833 assert!(error.to_string().contains("json"), "{error}");
1834 let steps = recorder.steps();
1835 assert_eq!(steps.first().map(String::as_str), Some("quit"));
1836 assert!(
1837 steps.iter().any(|step| step.starts_with("restore ")),
1838 "{steps:?}"
1839 );
1840 assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1841 }
1842
1843 #[test]
1844 fn restoring_browser_state_removes_files_the_target_does_not_have() {
1845 let fixture = fixture();
1846 write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1847 write(
1848 &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1849 "outgoing",
1850 );
1851 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1852
1853 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1854
1855 assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1856 assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1857 }
1858
1859 #[test]
1860 fn keeping_the_bridge_leaves_it_in_place() {
1861 let fixture = fixture();
1862 let plan = plan_switch(
1863 &fixture.paths,
1864 "there",
1865 SwitchOpts {
1866 keep_bridge: true,
1867 ..SwitchOpts::default()
1868 },
1869 )
1870 .unwrap();
1871
1872 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1873 assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1874 }
1875
1876 #[test]
1877 fn archives_are_pruned_oldest_first() {
1878 let dir = tempfile::TempDir::new().unwrap();
1879 for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1880 std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1881 }
1882 std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1883 let mut notes = Vec::new();
1884
1885 prune_archives(dir.path(), 2, &mut notes);
1886
1887 assert!(notes.is_empty(), "{notes:?}");
1888 assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1889 assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1890 assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1891 assert!(dir.path().join("unrelated.txt").exists());
1892 }
1893
1894 #[test]
1903 fn no_note_interpolates_an_unsanitized_path() {
1904 let mut sites = Vec::new();
1905 for file in crate::guard::rs_files_in("src") {
1906 let source = std::fs::read_to_string(&file).expect("readable module");
1907 let body = crate::guard::production_code(&source);
1908 sites.extend(
1909 crate::guard::calls(&body, "notes.push(")
1910 .into_iter()
1911 .filter(|call| call.contains(".display()"))
1912 .map(|call| format!("{}: {call}", file.display())),
1913 );
1914 }
1915 assert!(
1916 sites.is_empty(),
1917 "a note reaches the terminal verbatim; render its path with \
1918 `sanitize_untrusted_path`. Found: {sites:#?}"
1919 );
1920 }
1921
1922 #[cfg(unix)]
1929 #[test]
1930 fn a_note_does_not_carry_a_terminal_escape_out_of_a_path() {
1931 let dir = tempfile::TempDir::new().unwrap();
1932 std::fs::create_dir(dir.path().join("switch-20260101-000000-\x1b[2Kx.tar.gz")).unwrap();
1933 std::fs::write(dir.path().join("switch-20260102-000000-y.tar.gz"), "z").unwrap();
1934 let mut notes = Vec::new();
1935
1936 prune_archives(dir.path(), 1, &mut notes);
1937
1938 assert_eq!(notes.len(), 1, "{notes:?}");
1939 assert!(!notes[0].contains('\u{1b}'), "{:?}", notes[0]);
1940 assert!(!notes[0].contains('\n'), "{:?}", notes[0]);
1941 assert!(notes[0].contains("could not prune"), "{}", notes[0]);
1942 }
1943
1944 #[test]
1945 fn pruning_never_discards_the_only_rollback_archive() {
1946 let dir = tempfile::TempDir::new().unwrap();
1947 std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1948 let mut notes = Vec::new();
1949
1950 prune_archives(dir.path(), 1, &mut notes);
1951
1952 assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1953 }
1954}