1pub mod app;
23pub mod capture;
24pub mod merge;
25
26use std::path::{Path, PathBuf};
27
28use serde::Deserialize;
29
30use crate::config::AnthropicConfig;
31use crate::error::{AppError, Result};
32
33use app::AppControl;
34use merge::{ScheduledMerge, SessionMerge};
35
36const CONFIG_JSON: &str = "config.json";
38const SESSIONS_DIR: &str = "claude-code-sessions";
40const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
43const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
45const BRIDGE_FILE: &str = "bridge-state.json";
50const DEVICE_REGISTRY: &str = "ant-device-registry.json";
53
54const TOKEN_CACHE: &str = "config-tokenCache";
56const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
57const DESKTOP_STATE: &str = "desktop-state";
58const META_JSON: &str = "meta.json";
59
60#[derive(Debug, Clone)]
64pub struct Paths {
65 pub data_dir: PathBuf,
66 pub profiles_dir: PathBuf,
67 pub backups_dir: PathBuf,
68}
69
70impl Paths {
71 pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
73 Self {
74 data_dir,
75 profiles_dir,
76 backups_dir,
77 }
78 }
79
80 pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
83 let home = crate::cache::home_dir()?;
84 let profiles_dir = anthropic
85 .desktop_profiles_dir
86 .clone()
87 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
88 let backups_dir = profiles_dir
89 .parent()
90 .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
91 .join("backups");
92 Ok(Self {
93 data_dir: home.join("Library/Application Support/Claude"),
94 profiles_dir,
95 backups_dir,
96 })
97 }
98
99 pub fn available(&self) -> bool {
102 self.data_dir.is_dir()
103 }
104
105 pub fn config_json(&self) -> PathBuf {
106 self.data_dir.join(CONFIG_JSON)
107 }
108
109 pub fn sessions_root(&self) -> PathBuf {
110 self.data_dir.join(SESSIONS_DIR)
111 }
112
113 pub fn profile_dir(&self, label: &str) -> PathBuf {
114 self.profiles_dir.join(label)
115 }
116
117 pub fn prelogin_dir(&self) -> PathBuf {
120 self.backups_dir.with_file_name("prelogin-backup")
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ProfileMeta {
127 pub label: String,
130 pub email: Option<String>,
131 pub account_uuid: String,
132 pub org_uuid: Option<String>,
136 pub has_credentials: bool,
137 pub has_desktop_state: bool,
138}
139
140#[derive(Debug, Deserialize)]
141struct RawMeta {
142 email: Option<String>,
143 #[serde(rename = "accountUuid")]
144 account_uuid: Option<String>,
145 #[serde(rename = "orgUuid")]
146 org_uuid: Option<String>,
147}
148
149pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
153 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
154 return Vec::new();
155 };
156 let mut profiles: Vec<ProfileMeta> = entries
157 .flatten()
158 .filter_map(|entry| {
159 let dir = entry.path();
160 let label = dir.file_name()?.to_str()?.to_string();
161 let raw: RawMeta =
162 serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
163 let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
164 Some(ProfileMeta {
165 label,
166 email: raw.email.filter(|email| !email.is_empty()),
167 account_uuid,
168 org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
169 has_credentials: dir.join(TOKEN_CACHE).is_file()
170 && dir.join(TOKEN_CACHE_V2).is_file(),
171 has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
172 })
173 })
174 .collect();
175 profiles.sort_by(|a, b| a.label.cmp(&b.label));
176 profiles
177}
178
179pub fn active_account_uuid(config_json: &Path) -> Option<String> {
182 let bytes = std::fs::read(config_json).ok()?;
183 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
184 value
185 .get("lastKnownAccountUuid")?
186 .as_str()
187 .filter(|uuid| !uuid.is_empty())
188 .map(str::to_string)
189}
190
191pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
192 profiles
193 .iter()
194 .find(|profile| profile.account_uuid == account_uuid)
195 .map(|profile| profile.label.as_str())
196}
197
198pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
200 let Some(org) = &profile.org_uuid else {
201 return 0;
202 };
203 let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
204 return 0;
205 };
206 entries
207 .flatten()
208 .filter(|entry| {
209 entry
210 .path()
211 .extension()
212 .is_some_and(|extension| extension == "json")
213 })
214 .count()
215}
216
217#[derive(Debug, Clone)]
219pub struct SwitchOpts {
220 pub keep_bridge: bool,
224 pub backup_sessions: bool,
229 pub keep_backups: usize,
231}
232
233impl Default for SwitchOpts {
234 fn default() -> Self {
235 Self {
236 keep_bridge: false,
237 backup_sessions: false,
238 keep_backups: 10,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SavedTokens {
247 pub token_cache: String,
248 pub token_cache_v2: String,
249}
250
251#[derive(Debug)]
253pub struct SwitchPlan {
254 pub target: ProfileMeta,
255 pub outgoing: Option<String>,
257 pub sessions: SessionMerge,
258 pub scheduled: Option<ScheduledMerge>,
261 pub tokens: SavedTokens,
265 pub archive: PathBuf,
266 pub archive_members: Vec<String>,
267 pub restores_desktop_state: bool,
268 pub opts: SwitchOpts,
269}
270
271pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
276 let profiles = load_profiles(&paths.profiles_dir);
277 let target = profiles
278 .iter()
279 .find(|profile| profile.label == label)
280 .cloned()
281 .ok_or_else(|| {
282 let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
283 AppError::Credentials(format!(
284 "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
285 Capture one with `claude-acc add {label}` \
286 (https://github.com/ohmaseclaro/claude-acc)",
287 paths.profiles_dir.display()
288 ))
289 })?;
290
291 let sessions_root = paths.sessions_root();
292 let (sessions, scheduled) = match &target.org_uuid {
293 Some(org) => (
294 merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
295 Some(merge::plan_scheduled_merge(
296 &sessions_root,
297 &target.account_uuid,
298 org,
299 )?),
300 ),
301 None => (SessionMerge::default(), None),
302 };
303
304 let outgoing = active_account_uuid(&paths.config_json())
305 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
306
307 let profile_dir = paths.profile_dir(label);
308 let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
309 AppError::Credentials(format!(
310 "no complete saved Desktop credential for {label:?}; capture or sign into that \
311 account before switching"
312 ))
313 })?;
314 let token_cache_v2 =
315 std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
316 AppError::Credentials(format!(
317 "no complete saved Desktop credential for {label:?}; capture or sign into that \
318 account before switching"
319 ))
320 })?;
321 if token_cache.is_empty() || token_cache_v2.is_empty() {
322 return Err(AppError::Credentials(format!(
323 "the saved Desktop credential for {label:?} is empty; capture it again before switching"
324 )));
325 }
326 let tokens = SavedTokens {
327 token_cache,
328 token_cache_v2,
329 };
330 if !profile_dir.join(DESKTOP_STATE).is_dir() {
331 return Err(AppError::Credentials(format!(
332 "no saved Desktop browser state for {label:?}; capture that account again before switching"
333 )));
334 }
335
336 let stamp = crate::claude_desktop::timestamp();
337 Ok(SwitchPlan {
338 archive: paths
339 .backups_dir
340 .join(format!("switch-{stamp}-{label}.tar.gz")),
341 archive_members: archive_members(paths, &opts),
342 restores_desktop_state: true,
343 target,
344 outgoing,
345 sessions,
346 scheduled,
347 tokens,
348 opts,
349 })
350}
351
352pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
358 let mut notes = Vec::new();
359 let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
360
361 app.quit()?;
363 let mut archived = false;
364 let mut result = (|| {
365 if !members.is_empty() {
366 app.archive(&plan.archive, &paths.data_dir, &members)?;
367 archived = true;
368 prune_archives(
371 &paths.backups_dir,
372 plan.opts.keep_backups.max(1),
373 &mut notes,
374 );
375 }
376 apply_switch_while_stopped(paths, plan, &mut notes)
377 })();
378
379 if result.is_err()
380 && archived
381 && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
382 {
383 let original = result.unwrap_err();
384 result = Err(AppError::Other(format!(
385 "{original}; automatic Desktop rollback was incomplete: {rollback}"
386 )));
387 }
388
389 let relaunch = app.relaunch();
390 match (result, relaunch) {
391 (Ok(()), Ok(())) => Ok(notes),
392 (Err(error), Ok(())) => Err(error),
393 (Ok(()), Err(error)) => Err(error),
394 (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
395 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
396 ))),
397 }
398}
399
400fn apply_switch_while_stopped(
401 paths: &Paths,
402 plan: &SwitchPlan,
403 notes: &mut Vec<String>,
404) -> Result<()> {
405 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
409 copy_file(source, destination)?;
410 }
411 if let Some(scheduled) = &plan.scheduled {
412 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
413 }
414
415 let live_config = paths.config_json();
420 let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
421 label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
422 });
423 if let Some(label) = &outgoing {
424 snapshot_profile(paths, label, notes)?;
425 }
426
427 swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
428
429 restore_desktop_state(paths, &plan.target.label)?;
430
431 if !plan.opts.keep_bridge {
432 let bridge = paths.data_dir.join(BRIDGE_FILE);
433 if bridge.is_file()
434 && let Err(error) = std::fs::remove_file(&bridge)
435 {
436 notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
437 }
438 }
439 restore_device_registry(paths, &plan.target.label, notes);
440
441 Ok(())
442}
443
444fn merge_history_into(
451 paths: &Paths,
452 account_uuid: &str,
453 org_uuid: &str,
454 notes: &mut Vec<String>,
455) -> (usize, usize) {
456 let sessions_root = paths.sessions_root();
457 let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
458 let mut copied = 0;
459 for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
460 match copy_file(source, destination) {
461 Ok(()) => copied += 1,
462 Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
463 }
464 }
465 let routines = match merge::plan_scheduled_merge(&sessions_root, account_uuid, org_uuid) {
466 Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
467 Ok(()) => scheduled.added,
468 Err(error) => {
469 notes.push(format!("schedule seed skipped: {error}"));
470 0
471 }
472 },
473 Err(error) => {
474 notes.push(format!("schedule seed skipped: {error}"));
475 0
476 }
477 };
478 (copied, routines)
479}
480
481fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
489 let profile_dir = paths.profile_dir(label);
490 std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
491 restrict(&profile_dir, 0o700, notes);
492
493 let bytes =
494 std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
495 let value: serde_json::Value = serde_json::from_slice(&bytes)?;
496 let live_tokens = SavedTokens {
497 token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
498 token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
499 };
500 write_saved_tokens(&profile_dir, &live_tokens, notes)?;
501
502 let registry = paths.data_dir.join(DEVICE_REGISTRY);
505 if registry.is_file() {
506 let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
507 crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
508 }
509
510 snapshot_desktop_state(paths, &profile_dir, notes)?;
511 Ok(())
513}
514
515fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
516 value
517 .get(key)
518 .and_then(serde_json::Value::as_str)
519 .filter(|blob| !blob.is_empty())
520 .ok_or_else(|| {
521 AppError::Credentials(format!(
522 "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
523 ))
524 })
525}
526
527fn write_saved_tokens(
528 profile_dir: &Path,
529 tokens: &SavedTokens,
530 notes: &mut Vec<String>,
531) -> Result<()> {
532 let token_path = profile_dir.join(TOKEN_CACHE);
533 let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
534 let original = read_optional_file(&token_path)?;
535 let original_v2 = read_optional_file(&token_v2_path)?;
536 let write_result = (|| {
537 crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
538 crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
539 Ok(())
540 })();
541 if let Err(error) = write_result {
542 let mut rollback = Vec::new();
543 if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
544 rollback.push(failure.to_string());
545 }
546 if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
547 rollback.push(failure.to_string());
548 }
549 return if rollback.is_empty() {
550 Err(error)
551 } else {
552 Err(AppError::Other(format!(
553 "{error}; saved-token rollback was incomplete: {}",
554 rollback.join("; ")
555 )))
556 };
557 }
558 restrict(&token_path, 0o600, notes);
559 restrict(&token_v2_path, 0o600, notes);
560 Ok(())
561}
562
563fn snapshot_desktop_state(
564 paths: &Paths,
565 profile_dir: &Path,
566 notes: &mut Vec<String>,
567) -> Result<()> {
568 let state_dir = profile_dir.join(DESKTOP_STATE);
569 let previous = profile_dir.join(".desktop-state.previous");
570 if previous.exists() && !state_dir.exists() {
571 std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
572 } else {
573 remove_if_present(&previous)?;
574 }
575
576 let staged = tempfile::Builder::new()
577 .prefix(".desktop-state.pending-")
578 .tempdir_in(profile_dir)
579 .map_err(|e| AppError::io_at(profile_dir, e))?;
580 restrict(staged.path(), 0o700, notes);
581 for name in COOKIE_FILES {
582 let source = paths.data_dir.join(name);
583 let destination = staged.path().join(name);
584 if source.is_file() {
585 copy_file(&source, &destination)?;
586 restrict(&destination, 0o600, notes);
587 }
588 }
589 for name in LEVELDB_DIRS {
590 let source = paths.data_dir.join(name);
591 let destination = staged.path().join(name);
592 if source.is_dir() {
593 copy_dir(&source, &destination)?;
594 }
595 }
596
597 let staged = staged.keep();
598 if state_dir.exists() {
599 std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
600 }
601 if let Err(error) = std::fs::rename(&staged, &state_dir) {
602 let restore = if previous.exists() {
603 std::fs::rename(&previous, &state_dir)
604 } else {
605 Ok(())
606 };
607 let _ = remove_if_present(&staged);
608 return match restore {
609 Ok(()) => Err(AppError::io_at(&staged, error)),
610 Err(rollback) => Err(AppError::Other(format!(
611 "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
612 ))),
613 };
614 }
615 if let Err(error) = remove_if_present(&previous) {
616 notes.push(format!(
617 "could not remove the previous Desktop-state snapshot: {error}"
618 ));
619 }
620 Ok(())
621}
622
623fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
624 let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
625 let bytes = merge::swap_config_tokens(
626 &existing,
627 &tokens.token_cache,
628 &tokens.token_cache_v2,
629 account_uuid,
630 )?;
631 crate::cache::atomic_write(config_json, &bytes)
634}
635
636fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
637 let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
638 for name in COOKIE_FILES {
639 let source = state_dir.join(name);
640 let destination = paths.data_dir.join(name);
641 if source.is_file() {
642 copy_file(&source, &destination)?;
643 } else {
644 remove_if_present(&destination)?;
645 }
646 }
647 for name in LEVELDB_DIRS {
648 let source = state_dir.join(name);
649 let destination = paths.data_dir.join(name);
650 if source.is_dir() {
651 replace_dir(&source, &destination)?;
652 } else {
653 remove_if_present(&destination)?;
654 }
655 }
656 Ok(())
657}
658
659fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
662 let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
663 let live = paths.data_dir.join(DEVICE_REGISTRY);
664 let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
665 return;
666 };
667 match merge::merge_device_registry(¤t, &saved) {
668 Ok(bytes) if bytes != current => {
669 if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
670 notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
671 }
672 }
673 Ok(_) => {}
674 Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
675 }
676}
677
678fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
684 let mut members = Vec::new();
685 for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
686 if paths.data_dir.join(name).exists() {
687 members.push(name.to_string());
688 }
689 }
690 for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
691 if paths.data_dir.join(name).exists() {
692 members.push(name.to_string());
693 }
694 }
695 if opts.backup_sessions {
696 if paths.sessions_root().is_dir() {
697 members.push(SESSIONS_DIR.to_string());
698 }
699 return members;
700 }
701 let sessions_root = paths.sessions_root();
702 let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
703 return members;
704 };
705 let mut registries = Vec::new();
706 for account in accounts.flatten() {
707 let Ok(orgs) = std::fs::read_dir(account.path()) else {
708 continue;
709 };
710 for org in orgs.flatten() {
711 let path = org.path().join("scheduled-tasks.json");
712 if path.is_file()
713 && let Ok(relative) = path.strip_prefix(&paths.data_dir)
714 {
715 registries.push(relative.display().to_string());
716 }
717 }
718 }
719 registries.sort();
720 members.extend(registries);
721 members
722}
723
724fn identity_members() -> Vec<&'static str> {
725 [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
726 .into_iter()
727 .chain(COOKIE_FILES)
728 .chain(LEVELDB_DIRS)
729 .collect()
730}
731
732fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
733 let Ok(entries) = std::fs::read_dir(backups_dir) else {
734 return;
735 };
736 let mut archives: Vec<PathBuf> = entries
739 .flatten()
740 .map(|entry| entry.path())
741 .filter(|path| {
742 path.file_name()
743 .and_then(|name| name.to_str())
744 .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
745 })
746 .collect();
747 if archives.len() <= keep {
748 return;
749 }
750 archives.sort();
751 let doomed = archives.len() - keep;
752 for path in archives.into_iter().take(doomed) {
753 if let Err(error) = std::fs::remove_file(&path) {
754 notes.push(format!("could not prune {}: {error}", path.display()));
755 }
756 }
757}
758
759fn copy_file(source: &Path, destination: &Path) -> Result<()> {
760 if let Some(parent) = destination.parent() {
761 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
762 }
763 std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
764 Ok(())
765}
766
767fn remove_if_present(path: &Path) -> Result<()> {
768 match std::fs::symlink_metadata(path) {
769 Ok(metadata) if metadata.is_dir() => {
770 std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
771 }
772 Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
774 Err(error) => Err(AppError::io_at(path, error)),
775 }
776}
777
778fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
779 match std::fs::read(path) {
780 Ok(bytes) => Ok(Some(bytes)),
781 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
782 Err(error) => Err(AppError::io_at(path, error)),
783 }
784}
785
786fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
787 match original {
788 Some(bytes) => crate::cache::atomic_write(path, bytes),
789 None => remove_if_present(path),
790 }
791}
792
793fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
797 if destination.exists() {
798 std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
799 }
800 copy_dir(source, destination)
801}
802
803fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
804 std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
805 let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
806 for entry in entries.flatten() {
807 let child = entry.path();
808 let target = destination.join(entry.file_name());
809 if child.is_dir() {
810 copy_dir(&child, &target)?;
811 } else {
812 std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
813 }
814 }
815 Ok(())
816}
817
818#[cfg(unix)]
819fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
820 use std::os::unix::fs::PermissionsExt;
821
822 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
823 notes.push(format!("could not restrict {}: {error}", path.display()));
824 }
825}
826
827#[cfg(not(unix))]
828fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
829
830fn timestamp() -> String {
831 chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use app::Recorder;
838
839 struct Fixture {
840 _root: tempfile::TempDir,
841 paths: Paths,
842 }
843
844 fn write(path: &Path, contents: &str) {
845 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
846 std::fs::write(path, contents).unwrap();
847 }
848
849 fn fixture() -> Fixture {
851 let root = tempfile::TempDir::new().unwrap();
852 let data = root.path().join("data");
853 let profiles = root.path().join("profiles");
854 let backups = root.path().join("backups");
855
856 write(
857 &data.join(CONFIG_JSON),
858 r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
859 "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
860 );
861 write(
862 &data.join(DEVICE_REGISTRY),
863 r#"{"uuid-here":{"deviceId":"d1"}}"#,
864 );
865 write(
866 &data.join(BRIDGE_FILE),
867 r#"{"remoteSessionId":"cse_stale"}"#,
868 );
869 write(&data.join("Cookies"), "live-cookies");
870 write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
871 write(
872 &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
873 r#"{"lastActivityAt":500}"#,
874 );
875 write(
876 &data
877 .join(SESSIONS_DIR)
878 .join("uuid-here/org-1/scheduled-tasks.json"),
879 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
880 );
881
882 write(
883 &profiles.join("here/meta.json"),
884 r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
885 );
886 write(
887 &profiles.join("there/meta.json"),
888 r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
889 );
890 write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
891 write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
892 write(
893 &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
894 "there-cookies",
895 );
896 write(
897 &profiles
898 .join("there")
899 .join(DESKTOP_STATE)
900 .join("Local Storage/leveldb/CURRENT"),
901 "there-ldb",
902 );
903
904 Fixture {
905 paths: Paths::at(data, profiles, backups),
906 _root: root,
907 }
908 }
909
910 fn manifest(root: &Path) -> Vec<(String, u64)> {
913 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
914 let Ok(entries) = std::fs::read_dir(dir) else {
915 return;
916 };
917 for entry in entries.flatten() {
918 let path = entry.path();
919 if path.is_dir() {
920 walk(&path, root, out);
921 } else if let Ok(meta) = entry.metadata() {
922 let relative = path.strip_prefix(root).unwrap().display().to_string();
923 out.push((relative, meta.len()));
924 }
925 }
926 }
927 let mut out = Vec::new();
928 walk(root, root, &mut out);
929 out.sort();
930 out
931 }
932
933 #[test]
934 fn profiles_load_sorted_with_their_capture_state() {
935 let fixture = fixture();
936 let profiles = load_profiles(&fixture.paths.profiles_dir);
937
938 assert_eq!(profiles.len(), 2);
939 assert_eq!(profiles[0].label, "here");
940 assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
941 assert!(!profiles[0].has_credentials);
942 assert_eq!(profiles[1].label, "there");
943 assert!(profiles[1].has_credentials);
944 assert!(profiles[1].has_desktop_state);
945 }
946
947 #[test]
948 fn a_malformed_profile_is_skipped_not_fatal() {
949 let fixture = fixture();
950 write(
951 &fixture.paths.profiles_dir.join("broken/meta.json"),
952 "{ not json",
953 );
954 write(
955 &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
956 r#"{"label":"x"}"#,
957 );
958
959 let profiles = load_profiles(&fixture.paths.profiles_dir);
960 let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
961 assert_eq!(labels, ["here", "there"]);
962 }
963
964 #[test]
965 fn the_active_account_resolves_to_its_label() {
966 let fixture = fixture();
967 let profiles = load_profiles(&fixture.paths.profiles_dir);
968 let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
969
970 assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
971 assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
972 }
973
974 #[test]
975 fn session_counts_come_from_the_accounts_own_folder() {
976 let fixture = fixture();
977 let profiles = load_profiles(&fixture.paths.profiles_dir);
978 let sessions_root = fixture.paths.sessions_root();
979
980 assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
981 assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
982 }
983
984 #[test]
985 fn planning_a_switch_changes_nothing_on_disk() {
986 let fixture = fixture();
987 let before = manifest(&fixture.paths.data_dir);
988
989 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
990
991 assert_eq!(plan.outgoing.as_deref(), Some("here"));
992 assert_eq!(plan.tokens.token_cache, "saved-a");
993 assert!(plan.restores_desktop_state);
994 assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
995 assert_eq!(manifest(&fixture.paths.data_dir), before);
996 assert!(!fixture.paths.backups_dir.exists());
997 }
998
999 #[test]
1000 fn planning_an_unknown_label_lists_the_known_ones() {
1001 let fixture = fixture();
1002 let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1003 let message = error.to_string();
1004 assert!(message.contains("here"), "{message}");
1005 assert!(message.contains("claude-acc"), "{message}");
1006 }
1007
1008 #[test]
1009 fn the_archive_skips_the_session_tree_by_default() {
1010 let fixture = fixture();
1011
1012 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1013 assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1014 assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1015 assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1016 assert!(plan.archive_members.contains(&"Cookies".to_string()));
1017 assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1018 assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1019 assert!(
1020 plan.archive_members
1021 .iter()
1022 .any(|member| member.ends_with("scheduled-tasks.json"))
1023 );
1024
1025 let full = plan_switch(
1026 &fixture.paths,
1027 "there",
1028 SwitchOpts {
1029 backup_sessions: true,
1030 ..SwitchOpts::default()
1031 },
1032 )
1033 .unwrap();
1034 assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1035 }
1036
1037 #[test]
1038 fn applying_a_switch_quits_then_archives_then_relaunches() {
1039 let fixture = fixture();
1040 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1041 let recorder = Recorder::default();
1042
1043 apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1044
1045 let steps = recorder.steps();
1046 assert_eq!(steps[0], "quit");
1047 assert!(steps[1].starts_with("archive "), "{steps:?}");
1048 assert_eq!(steps[2], "relaunch");
1049 }
1050
1051 #[test]
1052 fn applying_a_switch_swaps_the_credential_and_carries_history() {
1053 let fixture = fixture();
1054 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1055
1056 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1057
1058 let config: serde_json::Value =
1059 serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1060 assert_eq!(config["oauth:tokenCache"], "saved-a");
1061 assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1062 assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1063 assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1064
1065 assert!(
1067 fixture
1068 .paths
1069 .sessions_root()
1070 .join("uuid-there/org-2/local_x.json")
1071 .is_file()
1072 );
1073 assert_eq!(
1074 std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1075 "there-cookies"
1076 );
1077 assert_eq!(
1078 std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1079 .unwrap(),
1080 "there-ldb"
1081 );
1082 assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1084 }
1085
1086 #[test]
1087 fn the_outgoing_account_is_snapshotted_before_the_swap() {
1088 let fixture = fixture();
1089 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1090
1091 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1092
1093 let here = fixture.paths.profile_dir("here");
1097 assert_eq!(
1098 std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1099 "live-a"
1100 );
1101 assert_eq!(
1102 std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1103 "live-b"
1104 );
1105 assert_eq!(
1106 std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1107 "live-cookies"
1108 );
1109 let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1111 assert!(meta.contains("here@example.com"), "{meta}");
1112 }
1113
1114 #[test]
1115 fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1116 let fixture = fixture();
1117 std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1118 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1119 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1120 assert!(error.to_string().contains("credential"), "{error}");
1121 }
1122
1123 #[test]
1124 fn planning_refuses_credentials_without_saved_browser_state() {
1125 let fixture = fixture();
1126 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1127
1128 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1129
1130 assert!(error.to_string().contains("browser state"), "{error}");
1131 }
1132
1133 #[test]
1134 fn a_failed_switch_requests_rollback_and_still_relaunches() {
1135 let fixture = fixture();
1136 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1137 write(&fixture.paths.config_json(), "{ not json");
1138 let recorder = Recorder::default();
1139
1140 let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1141
1142 assert!(error.to_string().contains("json"), "{error}");
1143 let steps = recorder.steps();
1144 assert_eq!(steps.first().map(String::as_str), Some("quit"));
1145 assert!(
1146 steps.iter().any(|step| step.starts_with("restore ")),
1147 "{steps:?}"
1148 );
1149 assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1150 }
1151
1152 #[test]
1153 fn restoring_browser_state_removes_files_the_target_does_not_have() {
1154 let fixture = fixture();
1155 write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1156 write(
1157 &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1158 "outgoing",
1159 );
1160 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1161
1162 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1163
1164 assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1165 assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1166 }
1167
1168 #[test]
1169 fn keeping_the_bridge_leaves_it_in_place() {
1170 let fixture = fixture();
1171 let plan = plan_switch(
1172 &fixture.paths,
1173 "there",
1174 SwitchOpts {
1175 keep_bridge: true,
1176 ..SwitchOpts::default()
1177 },
1178 )
1179 .unwrap();
1180
1181 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1182 assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1183 }
1184
1185 #[test]
1186 fn archives_are_pruned_oldest_first() {
1187 let dir = tempfile::TempDir::new().unwrap();
1188 for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1189 std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1190 }
1191 std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1192 let mut notes = Vec::new();
1193
1194 prune_archives(dir.path(), 2, &mut notes);
1195
1196 assert!(notes.is_empty(), "{notes:?}");
1197 assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1198 assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1199 assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1200 assert!(dir.path().join("unrelated.txt").exists());
1201 }
1202
1203 #[test]
1204 fn pruning_never_discards_the_only_rollback_archive() {
1205 let dir = tempfile::TempDir::new().unwrap();
1206 std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1207 let mut notes = Vec::new();
1208
1209 prune_archives(dir.path(), 1, &mut notes);
1210
1211 assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1212 }
1213}