1pub mod app;
23pub mod capture;
24pub mod merge;
25
26use std::collections::BTreeSet;
27use std::path::{Path, PathBuf};
28
29use serde::Deserialize;
30
31use crate::config::AnthropicConfig;
32use crate::error::{AppError, Result};
33
34use app::AppControl;
35use merge::{ScheduledMerge, SessionMerge};
36
37const CONFIG_JSON: &str = "config.json";
39const SESSIONS_DIR: &str = "claude-code-sessions";
41const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
44const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
46const BRIDGE_FILE: &str = "bridge-state.json";
51const DEVICE_REGISTRY: &str = "ant-device-registry.json";
54
55const TOKEN_CACHE: &str = "config-tokenCache";
57const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
58const DESKTOP_STATE: &str = "desktop-state";
59const META_JSON: &str = "meta.json";
60pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
64
65#[derive(Debug, Clone)]
69pub struct Paths {
70 pub data_dir: PathBuf,
71 pub profiles_dir: PathBuf,
72 pub backups_dir: PathBuf,
73}
74
75impl Paths {
76 pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
78 Self {
79 data_dir,
80 profiles_dir,
81 backups_dir,
82 }
83 }
84
85 pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
88 let home = crate::cache::home_dir()?;
89 let profiles_dir = anthropic
90 .desktop_profiles_dir
91 .clone()
92 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
93 let backups_dir = profiles_dir
94 .parent()
95 .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
96 .join("backups");
97 Ok(Self {
98 data_dir: home.join("Library/Application Support/Claude"),
99 profiles_dir,
100 backups_dir,
101 })
102 }
103
104 pub fn available(&self) -> bool {
107 self.data_dir.is_dir()
108 }
109
110 pub fn config_json(&self) -> PathBuf {
111 self.data_dir.join(CONFIG_JSON)
112 }
113
114 pub fn sessions_root(&self) -> PathBuf {
115 self.data_dir.join(SESSIONS_DIR)
116 }
117
118 pub fn profile_dir(&self, label: &str) -> PathBuf {
119 self.profiles_dir.join(label)
120 }
121
122 pub fn account_switch_lock(&self) -> PathBuf {
125 self.backups_dir.join(".account-switch.lock")
126 }
127
128 pub fn prelogin_dir(&self) -> PathBuf {
131 self.backups_dir.with_file_name("prelogin-backup")
132 }
133
134 pub fn synced_path(&self) -> PathBuf {
139 self.backups_dir.with_file_name("synced.json")
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ProfileMeta {
146 pub label: String,
149 pub email: Option<String>,
150 pub account_uuid: String,
151 pub org_uuid: Option<String>,
155 pub has_credentials: bool,
156 pub has_desktop_state: bool,
157}
158
159#[derive(Debug, Deserialize)]
160struct RawMeta {
161 email: Option<String>,
162 #[serde(rename = "accountUuid")]
163 account_uuid: Option<String>,
164 #[serde(rename = "orgUuid")]
165 org_uuid: Option<String>,
166}
167
168pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
172 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
173 return Vec::new();
174 };
175 let mut profiles: Vec<ProfileMeta> = entries
176 .flatten()
177 .filter_map(|entry| {
178 let dir = entry.path();
179 let label = dir.file_name()?.to_str()?.to_string();
180 let raw: RawMeta =
181 serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
182 let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
183 Some(ProfileMeta {
184 label,
185 email: raw.email.filter(|email| !email.is_empty()),
186 account_uuid,
187 org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
188 has_credentials: dir.join(TOKEN_CACHE).is_file()
189 && dir.join(TOKEN_CACHE_V2).is_file(),
190 has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
191 })
192 })
193 .collect();
194 profiles.sort_by(|a, b| a.label.cmp(&b.label));
195 profiles
196}
197
198pub fn load_synced(path: &Path) -> merge::Synced {
202 std::fs::read(path)
203 .map(|bytes| merge::parse_synced(&bytes))
204 .unwrap_or_default()
205}
206
207pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
210 crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
211}
212
213pub fn active_account_uuid(config_json: &Path) -> Option<String> {
216 let bytes = std::fs::read(config_json).ok()?;
217 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
218 value
219 .get("lastKnownAccountUuid")?
220 .as_str()
221 .filter(|uuid| !uuid.is_empty())
222 .map(str::to_string)
223}
224
225pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
226 profiles
227 .iter()
228 .find(|profile| profile.account_uuid == account_uuid)
229 .map(|profile| profile.label.as_str())
230}
231
232pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
234 let Some(org) = &profile.org_uuid else {
235 return 0;
236 };
237 let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
238 return 0;
239 };
240 entries
241 .flatten()
242 .filter(|entry| {
243 entry
244 .path()
245 .extension()
246 .is_some_and(|extension| extension == "json")
247 })
248 .count()
249}
250
251#[derive(Debug, Clone)]
253pub struct SwitchOpts {
254 pub keep_bridge: bool,
258 pub backup_sessions: bool,
263 pub keep_backups: usize,
265}
266
267impl Default for SwitchOpts {
268 fn default() -> Self {
269 Self {
270 keep_bridge: false,
271 backup_sessions: false,
272 keep_backups: 10,
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct SavedTokens {
281 pub token_cache: String,
282 pub token_cache_v2: String,
283}
284
285#[derive(Debug)]
287pub struct SwitchPlan {
288 pub target: ProfileMeta,
289 pub outgoing: Option<String>,
291 pub sessions: SessionMerge,
292 pub scheduled: Option<ScheduledMerge>,
295 pub tokens: SavedTokens,
299 pub archive: PathBuf,
300 pub archive_members: Vec<String>,
301 pub restores_desktop_state: bool,
302 pub opts: SwitchOpts,
303 pub deletions: Vec<merge::DeletionCandidate>,
307 pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
310 pub prior_synced: merge::Synced,
313}
314
315pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
320 let profiles = load_profiles(&paths.profiles_dir);
321 let target = profiles
322 .iter()
323 .find(|profile| profile.label == label)
324 .cloned()
325 .ok_or_else(|| {
326 let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
327 AppError::Credentials(format!(
328 "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
329 Capture one with `claude-acc add {label}` \
330 (https://github.com/ohmaseclaro/claude-acc)",
331 paths.profiles_dir.display()
332 ))
333 })?;
334
335 let sessions_root = paths.sessions_root();
336 let synced = load_synced(&paths.synced_path());
337 let (sessions, scheduled) = match &target.org_uuid {
338 Some(org) => (
339 merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
340 Some(merge::plan_scheduled_merge(
341 &sessions_root,
342 &target.account_uuid,
343 org,
344 &synced,
345 )?),
346 ),
347 None => (SessionMerge::default(), None),
348 };
349 let deletions = merge::deletion_candidates(&sessions_root, &synced);
350
351 let outgoing = active_account_uuid(&paths.config_json())
352 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
353
354 let profile_dir = paths.profile_dir(label);
355 let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
356 AppError::Credentials(format!(
357 "no complete saved Desktop credential for {label:?}; capture or sign into that \
358 account before switching"
359 ))
360 })?;
361 let token_cache_v2 =
362 std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
363 AppError::Credentials(format!(
364 "no complete saved Desktop credential for {label:?}; capture or sign into that \
365 account before switching"
366 ))
367 })?;
368 if token_cache.is_empty() || token_cache_v2.is_empty() {
369 return Err(AppError::Credentials(format!(
370 "the saved Desktop credential for {label:?} is empty; capture it again before switching"
371 )));
372 }
373 let tokens = SavedTokens {
374 token_cache,
375 token_cache_v2,
376 };
377 if !profile_dir.join(DESKTOP_STATE).is_dir() {
378 return Err(AppError::Credentials(format!(
379 "no saved Desktop browser state for {label:?}; capture that account again before switching"
380 )));
381 }
382
383 let stamp = crate::claude_desktop::timestamp();
384 Ok(SwitchPlan {
385 archive: paths
386 .backups_dir
387 .join(format!("switch-{stamp}-{label}.tar.gz")),
388 archive_members: archive_members(paths, &opts),
389 restores_desktop_state: true,
390 target,
391 outgoing,
392 sessions,
393 scheduled,
394 tokens,
395 opts,
396 deletions,
397 confirmed_deletions: BTreeSet::new(),
398 prior_synced: synced,
399 })
400}
401
402pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
408 let mut notes = Vec::new();
409 let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
410
411 if let Err(error) = app.quit() {
413 return match app.relaunch() {
418 Ok(()) => Err(error),
419 Err(relaunch) => Err(AppError::Other(format!(
420 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
421 ))),
422 };
423 }
424 let mut archived = false;
425 let mut result = (|| {
426 if !members.is_empty() {
427 app.archive(&plan.archive, &paths.data_dir, &members)?;
428 archived = true;
429 prune_archives(
432 &paths.backups_dir,
433 plan.opts.keep_backups.max(1),
434 &mut notes,
435 );
436 }
437 apply_switch_while_stopped(paths, plan, &mut notes)
438 })();
439
440 if result.is_err()
441 && archived
442 && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
443 {
444 let original = result.unwrap_err();
445 result = Err(AppError::Other(format!(
446 "{original}; automatic Desktop rollback was incomplete: {rollback}"
447 )));
448 }
449
450 let relaunch = app.relaunch();
451 match (result, relaunch) {
452 (Ok(()), Ok(())) => Ok(notes),
453 (Err(error), Ok(())) => Err(error),
454 (Ok(()), Err(error)) => Err(error),
455 (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
456 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
457 ))),
458 }
459}
460
461fn apply_switch_while_stopped(
462 paths: &Paths,
463 plan: &SwitchPlan,
464 notes: &mut Vec<String>,
465) -> Result<()> {
466 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
470 copy_file(source, destination)?;
471 }
472 if let Some(scheduled) = &plan.scheduled {
473 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
474 }
475 match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
480 Ok(sweep) => {
481 for (path, bytes) in sweep.rewrites {
482 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
483 notes.push(format!(
484 "could not apply deletion to {}: {error}",
485 path.display()
486 ));
487 }
488 }
489 for path in sweep.removals {
494 if let Err(error) = std::fs::remove_file(&path) {
495 notes.push(format!("could not remove {}: {error}", path.display()));
496 }
497 }
498 }
499 Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
500 }
501 let mut synced = merge::current_state(&paths.sessions_root());
504 let mut canonical = plan
505 .scheduled
506 .as_ref()
507 .map(|scheduled| scheduled.canonical_routines.clone())
508 .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
509 for key in &plan.confirmed_deletions {
510 if key.kind == merge::ConflictKind::Routine {
511 canonical.remove(&key.id);
512 }
513 }
514 let present: BTreeSet<String> = synced
515 .values()
516 .flat_map(|account| account.routines.iter().cloned())
517 .collect();
518 canonical.retain(|id, _| present.contains(id));
519 merge::set_canonical_routines(&mut synced, &canonical);
520 if let Err(error) = save_synced(&paths.synced_path(), &synced) {
521 notes.push(format!("could not record the schedule sync: {error}"));
522 }
523
524 let live_config = paths.config_json();
529 let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
530 label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
531 });
532 if let Some(label) = &outgoing {
533 snapshot_profile(paths, label, notes)?;
534 }
535
536 swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
537
538 restore_desktop_state(paths, &plan.target.label)?;
539
540 if !plan.opts.keep_bridge {
541 let bridge = paths.data_dir.join(BRIDGE_FILE);
542 if bridge.is_file()
543 && let Err(error) = std::fs::remove_file(&bridge)
544 {
545 notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
546 }
547 }
548 restore_device_registry(paths, &plan.target.label, notes);
549
550 Ok(())
551}
552
553fn merge_history_into(
560 paths: &Paths,
561 account_uuid: &str,
562 org_uuid: &str,
563 notes: &mut Vec<String>,
564) -> (usize, usize) {
565 let sessions_root = paths.sessions_root();
566 let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
567 let mut copied = 0;
568 for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
569 match copy_file(source, destination) {
570 Ok(()) => copied += 1,
571 Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
572 }
573 }
574 let routines = match merge::plan_scheduled_merge(
575 &sessions_root,
576 account_uuid,
577 org_uuid,
578 &load_synced(&paths.synced_path()),
579 ) {
580 Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
581 Ok(()) => scheduled.added + scheduled.updated,
582 Err(error) => {
583 notes.push(format!("schedule seed skipped: {error}"));
584 0
585 }
586 },
587 Err(error) => {
588 notes.push(format!("schedule seed skipped: {error}"));
589 0
590 }
591 };
592 (copied, routines)
593}
594
595fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
603 let profile_dir = paths.profile_dir(label);
604 std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
605 restrict(&profile_dir, 0o700, notes);
606
607 let bytes =
608 std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
609 let value: serde_json::Value = serde_json::from_slice(&bytes)?;
610 let live_tokens = SavedTokens {
611 token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
612 token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
613 };
614 write_saved_tokens(&profile_dir, &live_tokens, notes)?;
615
616 let registry = paths.data_dir.join(DEVICE_REGISTRY);
619 if registry.is_file() {
620 let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
621 crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
622 }
623
624 snapshot_desktop_state(paths, &profile_dir, notes)?;
625 Ok(())
627}
628
629fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
630 value
631 .get(key)
632 .and_then(serde_json::Value::as_str)
633 .filter(|blob| !blob.is_empty())
634 .ok_or_else(|| {
635 AppError::Credentials(format!(
636 "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
637 ))
638 })
639}
640
641fn write_saved_tokens(
642 profile_dir: &Path,
643 tokens: &SavedTokens,
644 notes: &mut Vec<String>,
645) -> Result<()> {
646 let token_path = profile_dir.join(TOKEN_CACHE);
647 let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
648 let original = read_optional_file(&token_path)?;
649 let original_v2 = read_optional_file(&token_v2_path)?;
650 let write_result = (|| {
651 crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
652 crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
653 Ok(())
654 })();
655 if let Err(error) = write_result {
656 let mut rollback = Vec::new();
657 if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
658 rollback.push(failure.to_string());
659 }
660 if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
661 rollback.push(failure.to_string());
662 }
663 return if rollback.is_empty() {
664 Err(error)
665 } else {
666 Err(AppError::Other(format!(
667 "{error}; saved-token rollback was incomplete: {}",
668 rollback.join("; ")
669 )))
670 };
671 }
672 restrict(&token_path, 0o600, notes);
673 restrict(&token_v2_path, 0o600, notes);
674 Ok(())
675}
676
677fn snapshot_desktop_state(
678 paths: &Paths,
679 profile_dir: &Path,
680 notes: &mut Vec<String>,
681) -> Result<()> {
682 let state_dir = profile_dir.join(DESKTOP_STATE);
683 let previous = profile_dir.join(".desktop-state.previous");
684 if previous.exists() && !state_dir.exists() {
685 std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
686 } else {
687 remove_if_present(&previous)?;
688 }
689
690 let staged = tempfile::Builder::new()
691 .prefix(".desktop-state.pending-")
692 .tempdir_in(profile_dir)
693 .map_err(|e| AppError::io_at(profile_dir, e))?;
694 restrict(staged.path(), 0o700, notes);
695 for name in COOKIE_FILES {
696 let source = paths.data_dir.join(name);
697 let destination = staged.path().join(name);
698 if source.is_file() {
699 copy_file(&source, &destination)?;
700 restrict(&destination, 0o600, notes);
701 }
702 }
703 for name in LEVELDB_DIRS {
704 let source = paths.data_dir.join(name);
705 let destination = staged.path().join(name);
706 if source.is_dir() {
707 copy_dir(&source, &destination)?;
708 }
709 }
710
711 let staged = staged.keep();
712 if state_dir.exists() {
713 std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
714 }
715 if let Err(error) = std::fs::rename(&staged, &state_dir) {
716 let restore = if previous.exists() {
717 std::fs::rename(&previous, &state_dir)
718 } else {
719 Ok(())
720 };
721 let _ = remove_if_present(&staged);
722 return match restore {
723 Ok(()) => Err(AppError::io_at(&staged, error)),
724 Err(rollback) => Err(AppError::Other(format!(
725 "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
726 ))),
727 };
728 }
729 if let Err(error) = remove_if_present(&previous) {
730 notes.push(format!(
731 "could not remove the previous Desktop-state snapshot: {error}"
732 ));
733 }
734 Ok(())
735}
736
737fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
738 let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
739 let bytes = merge::swap_config_tokens(
740 &existing,
741 &tokens.token_cache,
742 &tokens.token_cache_v2,
743 account_uuid,
744 )?;
745 crate::cache::atomic_write(config_json, &bytes)
748}
749
750fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
751 let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
752 for name in COOKIE_FILES {
753 let source = state_dir.join(name);
754 let destination = paths.data_dir.join(name);
755 if source.is_file() {
756 copy_file(&source, &destination)?;
757 } else {
758 remove_if_present(&destination)?;
759 }
760 }
761 for name in LEVELDB_DIRS {
762 let source = state_dir.join(name);
763 let destination = paths.data_dir.join(name);
764 if source.is_dir() {
765 replace_dir(&source, &destination)?;
766 } else {
767 remove_if_present(&destination)?;
768 }
769 }
770 Ok(())
771}
772
773fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
776 let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
777 let live = paths.data_dir.join(DEVICE_REGISTRY);
778 let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
779 return;
780 };
781 match merge::merge_device_registry(¤t, &saved) {
782 Ok(bytes) if bytes != current => {
783 if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
784 notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
785 }
786 }
787 Ok(_) => {}
788 Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
789 }
790}
791
792fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
798 let mut members = Vec::new();
799 for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
800 if paths.data_dir.join(name).exists() {
801 members.push(name.to_string());
802 }
803 }
804 for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
805 if paths.data_dir.join(name).exists() {
806 members.push(name.to_string());
807 }
808 }
809 if opts.backup_sessions {
810 if paths.sessions_root().is_dir() {
811 members.push(SESSIONS_DIR.to_string());
812 }
813 return members;
814 }
815 let sessions_root = paths.sessions_root();
816 let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
817 return members;
818 };
819 let mut registries = Vec::new();
820 for account in accounts.flatten() {
821 let Ok(orgs) = std::fs::read_dir(account.path()) else {
822 continue;
823 };
824 for org in orgs.flatten() {
825 let path = org.path().join("scheduled-tasks.json");
826 if path.is_file()
827 && let Ok(relative) = path.strip_prefix(&paths.data_dir)
828 {
829 registries.push(relative.display().to_string());
830 }
831 }
832 }
833 registries.sort();
834 members.extend(registries);
835 members
836}
837
838fn identity_members() -> Vec<&'static str> {
839 [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
840 .into_iter()
841 .chain(COOKIE_FILES)
842 .chain(LEVELDB_DIRS)
843 .collect()
844}
845
846fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
847 let Ok(entries) = std::fs::read_dir(backups_dir) else {
848 return;
849 };
850 let mut archives: Vec<PathBuf> = entries
853 .flatten()
854 .map(|entry| entry.path())
855 .filter(|path| {
856 path.file_name()
857 .and_then(|name| name.to_str())
858 .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
859 })
860 .collect();
861 if archives.len() <= keep {
862 return;
863 }
864 archives.sort();
865 let doomed = archives.len() - keep;
866 for path in archives.into_iter().take(doomed) {
867 if let Err(error) = std::fs::remove_file(&path) {
868 notes.push(format!("could not prune {}: {error}", path.display()));
869 }
870 }
871}
872
873fn copy_file(source: &Path, destination: &Path) -> Result<()> {
874 if let Some(parent) = destination.parent() {
875 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
876 }
877 std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
878 Ok(())
879}
880
881fn remove_if_present(path: &Path) -> Result<()> {
882 match std::fs::symlink_metadata(path) {
883 Ok(metadata) if metadata.is_dir() => {
884 std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
885 }
886 Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
887 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
888 Err(error) => Err(AppError::io_at(path, error)),
889 }
890}
891
892fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
893 match std::fs::read(path) {
894 Ok(bytes) => Ok(Some(bytes)),
895 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
896 Err(error) => Err(AppError::io_at(path, error)),
897 }
898}
899
900fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
901 match original {
902 Some(bytes) => crate::cache::atomic_write(path, bytes),
903 None => remove_if_present(path),
904 }
905}
906
907fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
911 if destination.exists() {
912 std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
913 }
914 copy_dir(source, destination)
915}
916
917fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
918 std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
919 let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
920 for entry in entries.flatten() {
921 let child = entry.path();
922 let target = destination.join(entry.file_name());
923 if child.is_dir() {
924 copy_dir(&child, &target)?;
925 } else {
926 std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
927 }
928 }
929 Ok(())
930}
931
932#[cfg(unix)]
933fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
934 use std::os::unix::fs::PermissionsExt;
935
936 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
937 notes.push(format!("could not restrict {}: {error}", path.display()));
938 }
939}
940
941#[cfg(not(unix))]
942fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
943
944fn timestamp() -> String {
945 chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use app::Recorder;
952 use std::cell::RefCell;
953
954 struct Fixture {
955 _root: tempfile::TempDir,
956 paths: Paths,
957 }
958
959 #[derive(Default)]
960 struct QuitFailure {
961 steps: RefCell<Vec<&'static str>>,
962 }
963
964 impl AppControl for QuitFailure {
965 fn quit(&self) -> Result<()> {
966 self.steps.borrow_mut().push("quit");
967 Err(AppError::Other("liveness probe failed".into()))
968 }
969
970 fn relaunch(&self) -> Result<()> {
971 self.steps.borrow_mut().push("relaunch");
972 Ok(())
973 }
974
975 fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
976 panic!("archive must not run after an unconfirmed quit")
977 }
978
979 fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
980 panic!("restore must not run before a switch starts")
981 }
982 }
983
984 fn write(path: &Path, contents: &str) {
985 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
986 std::fs::write(path, contents).unwrap();
987 }
988
989 fn fixture() -> Fixture {
991 let root = tempfile::TempDir::new().unwrap();
992 let data = root.path().join("data");
993 let profiles = root.path().join("profiles");
994 let backups = root.path().join("backups");
995
996 write(
997 &data.join(CONFIG_JSON),
998 r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
999 "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1000 );
1001 write(
1002 &data.join(DEVICE_REGISTRY),
1003 r#"{"uuid-here":{"deviceId":"d1"}}"#,
1004 );
1005 write(
1006 &data.join(BRIDGE_FILE),
1007 r#"{"remoteSessionId":"cse_stale"}"#,
1008 );
1009 write(&data.join("Cookies"), "live-cookies");
1010 write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1011 write(
1012 &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1013 r#"{"lastActivityAt":500}"#,
1014 );
1015 write(
1016 &data
1017 .join(SESSIONS_DIR)
1018 .join("uuid-here/org-1/scheduled-tasks.json"),
1019 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1020 );
1021
1022 write(
1023 &profiles.join("here/meta.json"),
1024 r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1025 );
1026 write(
1027 &profiles.join("there/meta.json"),
1028 r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1029 );
1030 write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1031 write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1032 write(
1033 &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1034 "there-cookies",
1035 );
1036 write(
1037 &profiles
1038 .join("there")
1039 .join(DESKTOP_STATE)
1040 .join("Local Storage/leveldb/CURRENT"),
1041 "there-ldb",
1042 );
1043
1044 Fixture {
1045 paths: Paths::at(data, profiles, backups),
1046 _root: root,
1047 }
1048 }
1049
1050 fn manifest(root: &Path) -> Vec<(String, u64)> {
1053 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1054 let Ok(entries) = std::fs::read_dir(dir) else {
1055 return;
1056 };
1057 for entry in entries.flatten() {
1058 let path = entry.path();
1059 if path.is_dir() {
1060 walk(&path, root, out);
1061 } else if let Ok(meta) = entry.metadata() {
1062 let relative = path.strip_prefix(root).unwrap().display().to_string();
1063 out.push((relative, meta.len()));
1064 }
1065 }
1066 }
1067 let mut out = Vec::new();
1068 walk(root, root, &mut out);
1069 out.sort();
1070 out
1071 }
1072
1073 #[test]
1074 fn profiles_load_sorted_with_their_capture_state() {
1075 let fixture = fixture();
1076 let profiles = load_profiles(&fixture.paths.profiles_dir);
1077
1078 assert_eq!(profiles.len(), 2);
1079 assert_eq!(profiles[0].label, "here");
1080 assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1081 assert!(!profiles[0].has_credentials);
1082 assert_eq!(profiles[1].label, "there");
1083 assert!(profiles[1].has_credentials);
1084 assert!(profiles[1].has_desktop_state);
1085 }
1086
1087 #[test]
1088 fn a_malformed_profile_is_skipped_not_fatal() {
1089 let fixture = fixture();
1090 write(
1091 &fixture.paths.profiles_dir.join("broken/meta.json"),
1092 "{ not json",
1093 );
1094 write(
1095 &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1096 r#"{"label":"x"}"#,
1097 );
1098
1099 let profiles = load_profiles(&fixture.paths.profiles_dir);
1100 let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1101 assert_eq!(labels, ["here", "there"]);
1102 }
1103
1104 #[test]
1105 fn the_active_account_resolves_to_its_label() {
1106 let fixture = fixture();
1107 let profiles = load_profiles(&fixture.paths.profiles_dir);
1108 let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1109
1110 assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1111 assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1112 }
1113
1114 #[test]
1115 fn session_counts_come_from_the_accounts_own_folder() {
1116 let fixture = fixture();
1117 let profiles = load_profiles(&fixture.paths.profiles_dir);
1118 let sessions_root = fixture.paths.sessions_root();
1119
1120 assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1121 assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1122 }
1123
1124 #[test]
1125 fn planning_a_switch_changes_nothing_on_disk() {
1126 let fixture = fixture();
1127 let before = manifest(&fixture.paths.data_dir);
1128
1129 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1130
1131 assert_eq!(plan.outgoing.as_deref(), Some("here"));
1132 assert_eq!(plan.tokens.token_cache, "saved-a");
1133 assert!(plan.restores_desktop_state);
1134 assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1135 assert_eq!(manifest(&fixture.paths.data_dir), before);
1136 assert!(!fixture.paths.backups_dir.exists());
1137 }
1138
1139 #[test]
1140 fn planning_an_unknown_label_lists_the_known_ones() {
1141 let fixture = fixture();
1142 let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1143 let message = error.to_string();
1144 assert!(message.contains("here"), "{message}");
1145 assert!(message.contains("claude-acc"), "{message}");
1146 }
1147
1148 #[test]
1149 fn the_archive_skips_the_session_tree_by_default() {
1150 let fixture = fixture();
1151
1152 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1153 assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1154 assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1155 assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1156 assert!(plan.archive_members.contains(&"Cookies".to_string()));
1157 assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1158 assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1159 assert!(
1160 plan.archive_members
1161 .iter()
1162 .any(|member| member.ends_with("scheduled-tasks.json"))
1163 );
1164
1165 let full = plan_switch(
1166 &fixture.paths,
1167 "there",
1168 SwitchOpts {
1169 backup_sessions: true,
1170 ..SwitchOpts::default()
1171 },
1172 )
1173 .unwrap();
1174 assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1175 }
1176
1177 #[test]
1178 fn applying_a_switch_quits_then_archives_then_relaunches() {
1179 let fixture = fixture();
1180 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1181 let recorder = Recorder::default();
1182
1183 apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1184
1185 let steps = recorder.steps();
1186 assert_eq!(steps[0], "quit");
1187 assert!(steps[1].starts_with("archive "), "{steps:?}");
1188 assert_eq!(steps[2], "relaunch");
1189 }
1190
1191 #[test]
1192 fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1193 let fixture = fixture();
1194 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1195 let before = std::fs::read(fixture.paths.config_json()).unwrap();
1196 let app = QuitFailure::default();
1197
1198 let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1199
1200 assert!(error.to_string().contains("liveness probe failed"));
1201 assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1202 assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1203 }
1204
1205 #[test]
1210 fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1211 let fixture = fixture();
1212 let sessions = fixture.paths.sessions_root();
1213 write(
1215 &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1216 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1217 );
1218 write(
1219 &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1220 r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1221 );
1222
1223 let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1224 plan.confirmed_deletions = [merge::DeletionKey {
1225 kind: merge::ConflictKind::Routine,
1226 id: "t1".to_string(),
1227 deleted_by: "uuid-here".to_string(),
1228 still_in: vec!["uuid-there".to_string()],
1229 }]
1230 .into_iter()
1231 .collect();
1232 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1233
1234 for registry in [
1235 sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1236 sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1237 ] {
1238 let value: serde_json::Value =
1239 serde_json::from_slice(&std::fs::read(®istry).unwrap()).unwrap();
1240 let ids: Vec<&str> = value["scheduledTasks"]
1241 .as_array()
1242 .unwrap()
1243 .iter()
1244 .filter_map(|task| task["id"].as_str())
1245 .collect();
1246 assert!(
1247 !ids.contains(&"t1"),
1248 "{registry:?} still holds the deleted task"
1249 );
1250 }
1251 let there: serde_json::Value = serde_json::from_slice(
1254 &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1255 )
1256 .unwrap();
1257 assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1258
1259 let recorded = load_synced(&fixture.paths.synced_path());
1262 assert!(!recorded.is_empty(), "the sync record was never written");
1263 assert!(
1264 recorded
1265 .values()
1266 .all(|state| !state.routines.contains("t1")),
1267 "{recorded:?} still lists the deleted task"
1268 );
1269 }
1270
1271 #[test]
1276 fn keeping_every_conflict_leaves_other_accounts_untouched() {
1277 let fixture = fixture();
1278 let sessions = fixture.paths.sessions_root();
1279 let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1280 write(
1281 &bystander,
1282 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1283 );
1284 let before = std::fs::read(&bystander).unwrap();
1285
1286 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1287 assert!(plan.confirmed_deletions.is_empty());
1288 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1289
1290 assert_eq!(std::fs::read(&bystander).unwrap(), before);
1291 }
1292
1293 #[test]
1294 fn applying_a_switch_swaps_the_credential_and_carries_history() {
1295 let fixture = fixture();
1296 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1297
1298 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1299
1300 let config: serde_json::Value =
1301 serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1302 assert_eq!(config["oauth:tokenCache"], "saved-a");
1303 assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1304 assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1305 assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1306
1307 assert!(
1309 fixture
1310 .paths
1311 .sessions_root()
1312 .join("uuid-there/org-2/local_x.json")
1313 .is_file()
1314 );
1315 assert_eq!(
1316 std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1317 "there-cookies"
1318 );
1319 assert_eq!(
1320 std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1321 .unwrap(),
1322 "there-ldb"
1323 );
1324 assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1326 }
1327
1328 #[test]
1329 fn the_outgoing_account_is_snapshotted_before_the_swap() {
1330 let fixture = fixture();
1331 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1332
1333 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1334
1335 let here = fixture.paths.profile_dir("here");
1339 assert_eq!(
1340 std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1341 "live-a"
1342 );
1343 assert_eq!(
1344 std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1345 "live-b"
1346 );
1347 assert_eq!(
1348 std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1349 "live-cookies"
1350 );
1351 let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1353 assert!(meta.contains("here@example.com"), "{meta}");
1354 }
1355
1356 #[test]
1357 fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1358 let fixture = fixture();
1359 std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1360 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1361 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1362 assert!(error.to_string().contains("credential"), "{error}");
1363 }
1364
1365 #[test]
1366 fn planning_refuses_credentials_without_saved_browser_state() {
1367 let fixture = fixture();
1368 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1369
1370 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1371
1372 assert!(error.to_string().contains("browser state"), "{error}");
1373 }
1374
1375 #[test]
1376 fn a_failed_switch_requests_rollback_and_still_relaunches() {
1377 let fixture = fixture();
1378 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1379 write(&fixture.paths.config_json(), "{ not json");
1380 let recorder = Recorder::default();
1381
1382 let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1383
1384 assert!(error.to_string().contains("json"), "{error}");
1385 let steps = recorder.steps();
1386 assert_eq!(steps.first().map(String::as_str), Some("quit"));
1387 assert!(
1388 steps.iter().any(|step| step.starts_with("restore ")),
1389 "{steps:?}"
1390 );
1391 assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1392 }
1393
1394 #[test]
1395 fn restoring_browser_state_removes_files_the_target_does_not_have() {
1396 let fixture = fixture();
1397 write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1398 write(
1399 &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1400 "outgoing",
1401 );
1402 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1403
1404 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1405
1406 assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1407 assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1408 }
1409
1410 #[test]
1411 fn keeping_the_bridge_leaves_it_in_place() {
1412 let fixture = fixture();
1413 let plan = plan_switch(
1414 &fixture.paths,
1415 "there",
1416 SwitchOpts {
1417 keep_bridge: true,
1418 ..SwitchOpts::default()
1419 },
1420 )
1421 .unwrap();
1422
1423 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1424 assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1425 }
1426
1427 #[test]
1428 fn archives_are_pruned_oldest_first() {
1429 let dir = tempfile::TempDir::new().unwrap();
1430 for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1431 std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1432 }
1433 std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1434 let mut notes = Vec::new();
1435
1436 prune_archives(dir.path(), 2, &mut notes);
1437
1438 assert!(notes.is_empty(), "{notes:?}");
1439 assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1440 assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1441 assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1442 assert!(dir.path().join("unrelated.txt").exists());
1443 }
1444
1445 #[test]
1446 fn pruning_never_discards_the_only_rollback_archive() {
1447 let dir = tempfile::TempDir::new().unwrap();
1448 std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1449 let mut notes = Vec::new();
1450
1451 prune_archives(dir.path(), 1, &mut notes);
1452
1453 assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1454 }
1455}