Skip to main content

ai_usagebar/claude_desktop/
mod.rs

1//! Switching which Claude account the **Claude Desktop app** is signed in as,
2//! carrying local history along so the account you land on shows the union of
3//! everything rather than only its own conversations.
4//!
5//! The Claude Desktop internals this relies on — the data-directory layout, the
6//! `oauth:tokenCache` / `oauth:tokenCacheV2` / `lastKnownAccountUuid` fields in
7//! `config.json`, which cookie and LevelDB stores carry the renderer's "who am
8//! I", the newest-wins rule for session indexes, and the reason
9//! `bridge-state.json` must be deleted rather than restored — were
10//! reverse-engineered by **claude-acc** (<https://github.com/ohmaseclaro/claude-acc>,
11//! MIT). [`plan_switch`]/[`apply_switch`] are a port of its `cmd_switch`, and
12//! they read and write its profile store so the two tools stay interchangeable.
13//! claude-acc still owns capturing (`add`), forgetting (`remove`), and chat
14//! filtering (`only`/`reset`); this module deliberately covers read + switch.
15//!
16//! Nothing here is compiled out on Linux. Every path is injected through
17//! [`Paths`], the platform commands live behind [`app::AppControl`], and the
18//! whole thing simply finds no data directory on a machine with no Claude
19//! Desktop app — which keeps the logic under CI's clippy and its tests running
20//! everywhere.
21
22pub 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
37/// Claude Desktop's own state file: OAuth token caches plus the account pointer.
38const CONFIG_JSON: &str = "config.json";
39/// Root of the per-account session-index tree, `<root>/<account>/<org>/`.
40const SESSIONS_DIR: &str = "claude-code-sessions";
41/// Chromium-style cookie jar — part of the renderer's identity, not just
42/// `config.json`'s oauth fields.
43const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
44/// LevelDB stores that carry the rest of that identity.
45const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
46/// Remote-control / cloud-session bridge. Holds a volatile `cse_…` session id
47/// that goes stale fast, so it is deleted on every switch and *never* saved
48/// into a profile: restoring a dead id makes `/remote-control` fail to
49/// disconnect.
50const BRIDGE_FILE: &str = "bridge-state.json";
51/// Account-keyed map of browser-extension device registrations. Additive only —
52/// see [`merge::merge_device_registry`].
53const DEVICE_REGISTRY: &str = "ant-device-registry.json";
54
55/// Profile-store filenames, owned by claude-acc's layout.
56const TOKEN_CACHE: &str = "config-tokenCache";
57const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
58const DESKTOP_STATE: &str = "desktop-state";
59const META_JSON: &str = "meta.json";
60/// One credential mutation can include a remote OAuth refresh. Account
61/// switching waits on the same lock rather than racing or failing after the
62/// old two-second window.
63pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
64
65/// Where the Claude Desktop app and the saved profiles live.
66///
67/// Constructed with [`Paths::at`] in tests so nothing reads a real `$HOME`.
68#[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    /// Test seam: every root explicit.
77    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    /// Production paths. `desktop_profiles_dir` overrides the claude-acc
86    /// default; rollback archives land beside the profile store.
87    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    /// Whether there is a Claude Desktop app installation to act on at all.
105    /// False on Linux, and on a Mac where the app has never run.
106    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    /// Shared by Desktop account switching and inactive-profile OAuth refresh.
123    /// Both operations can rotate or install the same saved credential.
124    pub fn account_switch_lock(&self) -> PathBuf {
125        self.backups_dir.join(".account-switch.lock")
126    }
127
128    /// Where [`capture`] parks the live login before clearing it, so a
129    /// cancelled capture can put the account back. Sits beside the archives.
130    pub fn prelogin_dir(&self) -> PathBuf {
131        self.backups_dir.with_file_name("prelogin-backup")
132    }
133
134    /// What each account's schedule registry held after the last merge, keyed
135    /// by account UUID. Kept beside the profile store rather than inside a
136    /// profile so both this tool and claude-acc read and write one record, and
137    /// so accounts without a captured profile are still tracked.
138    pub fn synced_path(&self) -> PathBuf {
139        self.backups_dir.with_file_name("synced.json")
140    }
141}
142
143/// One saved account in the profile store.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ProfileMeta {
146    /// The profile directory's name — authoritative, so a hand-edited
147    /// `meta.json` can never make a profile answer to the wrong label.
148    pub label: String,
149    pub email: Option<String>,
150    pub account_uuid: String,
151    /// Absent until the app has created a session folder for the account;
152    /// without it the history merges are skipped but the credential swap
153    /// still works.
154    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
168/// Every saved profile, sorted by label. Best-effort by design: one unreadable
169/// or hand-mangled `meta.json` is skipped rather than failing `account status`
170/// for every other account.
171pub 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
198/// Read the schedule sync record. Absent or malformed reads as empty, which
199/// makes every task look new — so a first run, or a corrupted record, reports
200/// no deletions rather than inventing them.
201pub 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
207/// Record what every account holds now, so the next switch can tell a deletion
208/// from a task that account never had.
209pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
210    crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
211}
212
213/// Which account the Desktop app currently believes it is. This is the app's
214/// own pointer, not a guess from file timestamps.
215pub 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
232/// How many conversations the account's history folder holds.
233pub 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/// Knobs that change what a switch does rather than which account it targets.
252#[derive(Debug, Clone)]
253pub struct SwitchOpts {
254    /// Keep `bridge-state.json` instead of deleting it. Off by default, so the
255    /// shipped behaviour matches claude-acc; on, it turns "does the remote
256    /// bridge cause the browser disconnect?" into a one-command experiment.
257    pub keep_bridge: bool,
258    /// Also archive the whole session tree. Off by default: the session merge
259    /// is additive (the older copy always survives in its source folder), so a
260    /// full-tree archive costs tens of megabytes per switch to protect against
261    /// nothing. On, it matches claude-acc byte for byte.
262    pub backup_sessions: bool,
263    /// Rollback archives to retain.
264    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/// The saved OAuth token caches for the account being switched to. Opaque
278/// blobs; never logged or printed.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct SavedTokens {
281    pub token_cache: String,
282    pub token_cache_v2: String,
283}
284
285/// Everything a switch would do, decided before anything is touched.
286#[derive(Debug)]
287pub struct SwitchPlan {
288    pub target: ProfileMeta,
289    /// The account being switched away from, when it is one we manage.
290    pub outgoing: Option<String>,
291    pub sessions: SessionMerge,
292    /// Absent when the target has no known org yet, so there is no history
293    /// folder to merge into.
294    pub scheduled: Option<ScheduledMerge>,
295    /// A switch is only valid with both saved Desktop credential blobs. Mixing
296    /// a target account's browser state with the current account's credential
297    /// would create an incoherent, destructive half-switch.
298    pub tokens: SavedTokens,
299    pub archive: PathBuf,
300    pub archive_members: Vec<String>,
301    pub restores_desktop_state: bool,
302    pub opts: SwitchOpts,
303    /// Schedules an account deleted that others still hold, so this merge would
304    /// resurrect them. The caller decides — a terminal prompt or the menu bar —
305    /// and records the verdict in `confirmed_deletions`.
306    pub deletions: Vec<merge::DeletionCandidate>,
307    /// Type-scoped items the user confirmed should go everywhere. Empty means
308    /// keep them all, which is also what a non-interactive switch must do.
309    pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
310    /// Baseline used to plan this switch. Apply combines it with the actual
311    /// post-write state so unresolved definitions remain unresolved safely.
312    pub prior_synced: merge::Synced,
313}
314
315/// Decide the whole switch without performing any of it.
316///
317/// This is what makes `--dry-run` free, and a test asserts it leaves the data
318/// directory byte-identical.
319pub 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
402/// Perform a planned switch.
403///
404/// Claude is always relaunched after it has been stopped. If an operation fails
405/// after the rollback archive is written, the live Desktop identity is restored
406/// from that archive before relaunching.
407pub 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    // Stop before archiving so SQLite/LevelDB and config.json are quiescent.
412    if let Err(error) = app.quit() {
413        // A fail-closed liveness probe can report an error after the graceful
414        // quit request already stopped Claude. Relaunch is harmless if it was
415        // still running and preserves the invariant that an attempted switch
416        // never leaves the app closed solely because verification failed.
417        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            // Never prune away the archive needed for this switch's rollback,
430            // even if the caller supplied --keep-backups 0.
431            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    // Derive convergence input from the immutable plan before touching any
467    // registries. Later writes must not be able to change the selected title.
468    let display_names = plan
469        .scheduled
470        .as_ref()
471        .map(ScheduledMerge::display_names)
472        .transpose()?;
473
474    // History merges abort too — a partial merge is recoverable, but doing it
475    // after the credential swap would strand the user on an account whose
476    // history never arrived.
477    for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
478        copy_file(source, destination)?;
479    }
480    if let Some(scheduled) = &plan.scheduled {
481        crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
482    }
483    // A confirmed deletion has to leave every registry, or the copy still
484    // sitting in another account hands it back on the next switch and the same
485    // prompt returns forever. Runs after the merge so it also strips anything
486    // the merge just re-added.
487    match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
488        Ok(sweep) => {
489            for (path, bytes) in sweep.rewrites {
490                if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
491                    notes.push(format!(
492                        "could not apply deletion to {}: {error}",
493                        path.display()
494                    ));
495                }
496            }
497            // Only the chat's *index* goes. Its transcript lives in the
498            // account-agnostic `~/.claude/projects/` and is never touched, so a
499            // confirmed chat stops following you between accounts without the
500            // conversation itself being destroyed.
501            for path in sweep.removals {
502                if let Err(error) = std::fs::remove_file(&path) {
503                    notes.push(format!("could not remove {}: {error}", path.display()));
504                }
505            }
506        }
507        Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
508    }
509    // Make every account agree on each routine's name. The merge selects the
510    // winning definitions while the switch is planned; carry those names into
511    // every registry so writes above cannot change the decision.
512    // Runs after the merge and the deletion sweep so it neither renames a
513    // just-deleted routine nor is undone by a re-added copy.
514    if let Some(display_names) = &display_names {
515        match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
516            Ok(convergence) => {
517                let mut fully_applied = true;
518                for (path, bytes) in convergence.rewrites {
519                    if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
520                        fully_applied = false;
521                        notes.push(format!(
522                            "could not converge routine names in {}: {error}",
523                            path.display()
524                        ));
525                    }
526                }
527                if fully_applied && convergence.converged > 0 {
528                    notes.push(format!(
529                        "converged {} routine name(s) across accounts",
530                        convergence.converged
531                    ));
532                }
533            }
534            Err(error) => notes.push(format!("name convergence skipped: {error}")),
535        }
536    }
537    // Record what every account holds now, so the next switch can tell an
538    // intentional deletion from a task that account simply never received.
539    let mut synced = merge::current_state(&paths.sessions_root());
540    let mut canonical = plan
541        .scheduled
542        .as_ref()
543        .map(|scheduled| scheduled.canonical_routines.clone())
544        .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
545    for key in &plan.confirmed_deletions {
546        if key.kind == merge::ConflictKind::Routine {
547            canonical.remove(&key.id);
548        }
549    }
550    let present: BTreeSet<String> = synced
551        .values()
552        .flat_map(|account| account.routines.iter().cloned())
553        .collect();
554    canonical.retain(|id, _| present.contains(id));
555    merge::set_canonical_routines(&mut synced, &canonical);
556    if let Err(error) = save_synced(&paths.synced_path(), &synced) {
557        notes.push(format!("could not record the schedule sync: {error}"));
558    }
559
560    // Identify the outgoing account from the *live* config, after the quit:
561    // the app rewrites this file on shutdown. Snapshotting it must also happen
562    // strictly before the swap below, or the outgoing tokens get filed under
563    // the incoming label and an account is destroyed.
564    let live_config = paths.config_json();
565    let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
566        label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
567    });
568    if let Some(label) = &outgoing {
569        snapshot_profile(paths, label, notes)?;
570    }
571
572    swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
573
574    restore_desktop_state(paths, &plan.target.label)?;
575
576    if !plan.opts.keep_bridge {
577        let bridge = paths.data_dir.join(BRIDGE_FILE);
578        if bridge.is_file()
579            && let Err(error) = std::fs::remove_file(&bridge)
580        {
581            notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
582        }
583    }
584    restore_device_registry(paths, &plan.target.label, notes);
585
586    Ok(())
587}
588
589/// Copy every other account's history into this one's folder, so it shows the
590/// union of everything. Used when capturing a brand-new account, whose first
591/// login would otherwise open on an empty sidebar; a switch uses the
592/// pre-computed plan instead so `--dry-run` can report it.
593///
594/// Returns `(session indexes, routines)` brought in.
595fn merge_history_into(
596    paths: &Paths,
597    account_uuid: &str,
598    org_uuid: &str,
599    notes: &mut Vec<String>,
600) -> (usize, usize) {
601    let sessions_root = paths.sessions_root();
602    let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
603    let mut copied = 0;
604    for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
605        match copy_file(source, destination) {
606            Ok(()) => copied += 1,
607            Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
608        }
609    }
610    let routines = match merge::plan_scheduled_merge(
611        &sessions_root,
612        account_uuid,
613        org_uuid,
614        &load_synced(&paths.synced_path()),
615    ) {
616        Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
617            Ok(()) => scheduled.added + scheduled.updated,
618            Err(error) => {
619                notes.push(format!("schedule seed skipped: {error}"));
620                0
621            }
622        },
623        Err(error) => {
624            notes.push(format!("schedule seed skipped: {error}"));
625            0
626        }
627    };
628    (copied, routines)
629}
630
631/// Save the outgoing account's live credential and browser state back into its
632/// profile, so switching to it later restores what it looked like just now.
633/// Only safe with the app fully quit — copying a live SQLite/LevelDB store
634/// risks grabbing it mid-write.
635///
636/// Deliberately never writes `meta.json`: that file belongs to `claude-acc add`,
637/// and two tools writing it is how the stores drift apart.
638fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
639    let profile_dir = paths.profile_dir(label);
640    std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
641    restrict(&profile_dir, 0o700, notes);
642
643    let bytes =
644        std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
645    let value: serde_json::Value = serde_json::from_slice(&bytes)?;
646    let live_tokens = SavedTokens {
647        token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
648        token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
649    };
650    write_saved_tokens(&profile_dir, &live_tokens, notes)?;
651
652    // The registry is account-keyed and shared, so it is snapshotted (never
653    // moved) purely so a lost key can be folded back in later.
654    let registry = paths.data_dir.join(DEVICE_REGISTRY);
655    if registry.is_file() {
656        let bytes = std::fs::read(&registry).map_err(|e| AppError::io_at(&registry, e))?;
657        crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
658    }
659
660    snapshot_desktop_state(paths, &profile_dir, notes)?;
661    // bridge-state.json is deliberately not snapshotted — see BRIDGE_FILE.
662    Ok(())
663}
664
665fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
666    value
667        .get(key)
668        .and_then(serde_json::Value::as_str)
669        .filter(|blob| !blob.is_empty())
670        .ok_or_else(|| {
671            AppError::Credentials(format!(
672                "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
673            ))
674        })
675}
676
677fn write_saved_tokens(
678    profile_dir: &Path,
679    tokens: &SavedTokens,
680    notes: &mut Vec<String>,
681) -> Result<()> {
682    let token_path = profile_dir.join(TOKEN_CACHE);
683    let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
684    let original = read_optional_file(&token_path)?;
685    let original_v2 = read_optional_file(&token_v2_path)?;
686    let write_result = (|| {
687        crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
688        crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
689        Ok(())
690    })();
691    if let Err(error) = write_result {
692        let mut rollback = Vec::new();
693        if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
694            rollback.push(failure.to_string());
695        }
696        if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
697            rollback.push(failure.to_string());
698        }
699        return if rollback.is_empty() {
700            Err(error)
701        } else {
702            Err(AppError::Other(format!(
703                "{error}; saved-token rollback was incomplete: {}",
704                rollback.join("; ")
705            )))
706        };
707    }
708    restrict(&token_path, 0o600, notes);
709    restrict(&token_v2_path, 0o600, notes);
710    Ok(())
711}
712
713fn snapshot_desktop_state(
714    paths: &Paths,
715    profile_dir: &Path,
716    notes: &mut Vec<String>,
717) -> Result<()> {
718    let state_dir = profile_dir.join(DESKTOP_STATE);
719    let previous = profile_dir.join(".desktop-state.previous");
720    if previous.exists() && !state_dir.exists() {
721        std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
722    } else {
723        remove_if_present(&previous)?;
724    }
725
726    let staged = tempfile::Builder::new()
727        .prefix(".desktop-state.pending-")
728        .tempdir_in(profile_dir)
729        .map_err(|e| AppError::io_at(profile_dir, e))?;
730    restrict(staged.path(), 0o700, notes);
731    for name in COOKIE_FILES {
732        let source = paths.data_dir.join(name);
733        let destination = staged.path().join(name);
734        if source.is_file() {
735            copy_file(&source, &destination)?;
736            restrict(&destination, 0o600, notes);
737        }
738    }
739    for name in LEVELDB_DIRS {
740        let source = paths.data_dir.join(name);
741        let destination = staged.path().join(name);
742        if source.is_dir() {
743            copy_dir(&source, &destination)?;
744        }
745    }
746
747    let staged = staged.keep();
748    if state_dir.exists() {
749        std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
750    }
751    if let Err(error) = std::fs::rename(&staged, &state_dir) {
752        let restore = if previous.exists() {
753            std::fs::rename(&previous, &state_dir)
754        } else {
755            Ok(())
756        };
757        let _ = remove_if_present(&staged);
758        return match restore {
759            Ok(()) => Err(AppError::io_at(&staged, error)),
760            Err(rollback) => Err(AppError::Other(format!(
761                "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
762            ))),
763        };
764    }
765    if let Err(error) = remove_if_present(&previous) {
766        notes.push(format!(
767            "could not remove the previous Desktop-state snapshot: {error}"
768        ));
769    }
770    Ok(())
771}
772
773fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
774    let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
775    let bytes = merge::swap_config_tokens(
776        &existing,
777        &tokens.token_cache,
778        &tokens.token_cache_v2,
779        account_uuid,
780    )?;
781    // Atomic, unlike claude-acc's truncating write: a crash mid-write here
782    // would take every account's tokens with it.
783    crate::cache::atomic_write(config_json, &bytes)
784}
785
786fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
787    let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
788    for name in COOKIE_FILES {
789        let source = state_dir.join(name);
790        let destination = paths.data_dir.join(name);
791        if source.is_file() {
792            copy_file(&source, &destination)?;
793        } else {
794            remove_if_present(&destination)?;
795        }
796    }
797    for name in LEVELDB_DIRS {
798        let source = state_dir.join(name);
799        let destination = paths.data_dir.join(name);
800        if source.is_dir() {
801            replace_dir(&source, &destination)?;
802        } else {
803            remove_if_present(&destination)?;
804        }
805    }
806    Ok(())
807}
808
809/// Fold a snapshotted device registry back into the live one. Purely additive
810/// (live wins every conflict), so this can only ever restore a lost entry.
811fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
812    let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
813    let live = paths.data_dir.join(DEVICE_REGISTRY);
814    let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
815        return;
816    };
817    match merge::merge_device_registry(&current, &saved) {
818        Ok(bytes) if bytes != current => {
819            if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
820                notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
821            }
822        }
823        Ok(_) => {}
824        Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
825    }
826}
827
828/// What goes into the rollback archive, relative to the data directory.
829///
830/// Only what a switch can actually destroy: the credential pointer, browser
831/// identity, bridge, schedule registries, and device registry. Missing entries
832/// are dropped, because `tar` fails the whole archive on one of them.
833fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
834    let mut members = Vec::new();
835    for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
836        if paths.data_dir.join(name).exists() {
837            members.push(name.to_string());
838        }
839    }
840    for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
841        if paths.data_dir.join(name).exists() {
842            members.push(name.to_string());
843        }
844    }
845    if opts.backup_sessions {
846        if paths.sessions_root().is_dir() {
847            members.push(SESSIONS_DIR.to_string());
848        }
849        return members;
850    }
851    let sessions_root = paths.sessions_root();
852    let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
853        return members;
854    };
855    let mut registries = Vec::new();
856    for account in accounts.flatten() {
857        let Ok(orgs) = std::fs::read_dir(account.path()) else {
858            continue;
859        };
860        for org in orgs.flatten() {
861            let path = org.path().join("scheduled-tasks.json");
862            if path.is_file()
863                && let Ok(relative) = path.strip_prefix(&paths.data_dir)
864            {
865                registries.push(relative.display().to_string());
866            }
867        }
868    }
869    registries.sort();
870    members.extend(registries);
871    members
872}
873
874fn identity_members() -> Vec<&'static str> {
875    [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
876        .into_iter()
877        .chain(COOKIE_FILES)
878        .chain(LEVELDB_DIRS)
879        .collect()
880}
881
882fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
883    let Ok(entries) = std::fs::read_dir(backups_dir) else {
884        return;
885    };
886    // The name embeds a sortable timestamp right after the constant prefix, so
887    // lexicographic order is chronological order.
888    let mut archives: Vec<PathBuf> = entries
889        .flatten()
890        .map(|entry| entry.path())
891        .filter(|path| {
892            path.file_name()
893                .and_then(|name| name.to_str())
894                .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
895        })
896        .collect();
897    if archives.len() <= keep {
898        return;
899    }
900    archives.sort();
901    let doomed = archives.len() - keep;
902    for path in archives.into_iter().take(doomed) {
903        if let Err(error) = std::fs::remove_file(&path) {
904            notes.push(format!("could not prune {}: {error}", path.display()));
905        }
906    }
907}
908
909fn copy_file(source: &Path, destination: &Path) -> Result<()> {
910    if let Some(parent) = destination.parent() {
911        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
912    }
913    std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
914    Ok(())
915}
916
917fn remove_if_present(path: &Path) -> Result<()> {
918    match std::fs::symlink_metadata(path) {
919        Ok(metadata) if metadata.is_dir() => {
920            std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
921        }
922        Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
923        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
924        Err(error) => Err(AppError::io_at(path, error)),
925    }
926}
927
928fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
929    match std::fs::read(path) {
930        Ok(bytes) => Ok(Some(bytes)),
931        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
932        Err(error) => Err(AppError::io_at(path, error)),
933    }
934}
935
936fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
937    match original {
938        Some(bytes) => crate::cache::atomic_write(path, bytes),
939        None => remove_if_present(path),
940    }
941}
942
943/// Replace `destination` with a fresh copy of `source`. LevelDB stores must not
944/// be merged file-by-file — a stale manifest beside fresh log files is a
945/// corrupt store — so the old directory goes first.
946fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
947    if destination.exists() {
948        std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
949    }
950    copy_dir(source, destination)
951}
952
953fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
954    std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
955    let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
956    for entry in entries.flatten() {
957        let child = entry.path();
958        let target = destination.join(entry.file_name());
959        if child.is_dir() {
960            copy_dir(&child, &target)?;
961        } else {
962            std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
963        }
964    }
965    Ok(())
966}
967
968#[cfg(unix)]
969fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
970    use std::os::unix::fs::PermissionsExt;
971
972    if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
973        notes.push(format!("could not restrict {}: {error}", path.display()));
974    }
975}
976
977#[cfg(not(unix))]
978fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
979
980fn timestamp() -> String {
981    chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987    use app::Recorder;
988    use std::cell::RefCell;
989
990    struct Fixture {
991        _root: tempfile::TempDir,
992        paths: Paths,
993    }
994
995    #[derive(Default)]
996    struct QuitFailure {
997        steps: RefCell<Vec<&'static str>>,
998    }
999
1000    impl AppControl for QuitFailure {
1001        fn quit(&self) -> Result<()> {
1002            self.steps.borrow_mut().push("quit");
1003            Err(AppError::Other("liveness probe failed".into()))
1004        }
1005
1006        fn relaunch(&self) -> Result<()> {
1007            self.steps.borrow_mut().push("relaunch");
1008            Ok(())
1009        }
1010
1011        fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1012            panic!("archive must not run after an unconfirmed quit")
1013        }
1014
1015        fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1016            panic!("restore must not run before a switch starts")
1017        }
1018    }
1019
1020    fn write(path: &Path, contents: &str) {
1021        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1022        std::fs::write(path, contents).unwrap();
1023    }
1024
1025    /// Two accounts: `here` (active) and `there` (fully captured).
1026    fn fixture() -> Fixture {
1027        let root = tempfile::TempDir::new().unwrap();
1028        let data = root.path().join("data");
1029        let profiles = root.path().join("profiles");
1030        let backups = root.path().join("backups");
1031
1032        write(
1033            &data.join(CONFIG_JSON),
1034            r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
1035                "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1036        );
1037        write(
1038            &data.join(DEVICE_REGISTRY),
1039            r#"{"uuid-here":{"deviceId":"d1"}}"#,
1040        );
1041        write(
1042            &data.join(BRIDGE_FILE),
1043            r#"{"remoteSessionId":"cse_stale"}"#,
1044        );
1045        write(&data.join("Cookies"), "live-cookies");
1046        write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1047        write(
1048            &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1049            r#"{"lastActivityAt":500}"#,
1050        );
1051        write(
1052            &data
1053                .join(SESSIONS_DIR)
1054                .join("uuid-here/org-1/scheduled-tasks.json"),
1055            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1056        );
1057
1058        write(
1059            &profiles.join("here/meta.json"),
1060            r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1061        );
1062        write(
1063            &profiles.join("there/meta.json"),
1064            r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1065        );
1066        write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1067        write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1068        write(
1069            &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1070            "there-cookies",
1071        );
1072        write(
1073            &profiles
1074                .join("there")
1075                .join(DESKTOP_STATE)
1076                .join("Local Storage/leveldb/CURRENT"),
1077            "there-ldb",
1078        );
1079
1080        Fixture {
1081            paths: Paths::at(data, profiles, backups),
1082            _root: root,
1083        }
1084    }
1085
1086    /// `(relative path, length)` for every file under a root, so a test can
1087    /// assert nothing changed.
1088    fn manifest(root: &Path) -> Vec<(String, u64)> {
1089        fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1090            let Ok(entries) = std::fs::read_dir(dir) else {
1091                return;
1092            };
1093            for entry in entries.flatten() {
1094                let path = entry.path();
1095                if path.is_dir() {
1096                    walk(&path, root, out);
1097                } else if let Ok(meta) = entry.metadata() {
1098                    let relative = path.strip_prefix(root).unwrap().display().to_string();
1099                    out.push((relative, meta.len()));
1100                }
1101            }
1102        }
1103        let mut out = Vec::new();
1104        walk(root, root, &mut out);
1105        out.sort();
1106        out
1107    }
1108
1109    #[test]
1110    fn profiles_load_sorted_with_their_capture_state() {
1111        let fixture = fixture();
1112        let profiles = load_profiles(&fixture.paths.profiles_dir);
1113
1114        assert_eq!(profiles.len(), 2);
1115        assert_eq!(profiles[0].label, "here");
1116        assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1117        assert!(!profiles[0].has_credentials);
1118        assert_eq!(profiles[1].label, "there");
1119        assert!(profiles[1].has_credentials);
1120        assert!(profiles[1].has_desktop_state);
1121    }
1122
1123    #[test]
1124    fn a_malformed_profile_is_skipped_not_fatal() {
1125        let fixture = fixture();
1126        write(
1127            &fixture.paths.profiles_dir.join("broken/meta.json"),
1128            "{ not json",
1129        );
1130        write(
1131            &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1132            r#"{"label":"x"}"#,
1133        );
1134
1135        let profiles = load_profiles(&fixture.paths.profiles_dir);
1136        let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1137        assert_eq!(labels, ["here", "there"]);
1138    }
1139
1140    #[test]
1141    fn the_active_account_resolves_to_its_label() {
1142        let fixture = fixture();
1143        let profiles = load_profiles(&fixture.paths.profiles_dir);
1144        let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1145
1146        assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1147        assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1148    }
1149
1150    #[test]
1151    fn session_counts_come_from_the_accounts_own_folder() {
1152        let fixture = fixture();
1153        let profiles = load_profiles(&fixture.paths.profiles_dir);
1154        let sessions_root = fixture.paths.sessions_root();
1155
1156        assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1157        assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1158    }
1159
1160    #[test]
1161    fn planning_a_switch_changes_nothing_on_disk() {
1162        let fixture = fixture();
1163        let before = manifest(&fixture.paths.data_dir);
1164
1165        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1166
1167        assert_eq!(plan.outgoing.as_deref(), Some("here"));
1168        assert_eq!(plan.tokens.token_cache, "saved-a");
1169        assert!(plan.restores_desktop_state);
1170        assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1171        assert_eq!(manifest(&fixture.paths.data_dir), before);
1172        assert!(!fixture.paths.backups_dir.exists());
1173    }
1174
1175    #[test]
1176    fn planning_an_unknown_label_lists_the_known_ones() {
1177        let fixture = fixture();
1178        let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1179        let message = error.to_string();
1180        assert!(message.contains("here"), "{message}");
1181        assert!(message.contains("claude-acc"), "{message}");
1182    }
1183
1184    #[test]
1185    fn the_archive_skips_the_session_tree_by_default() {
1186        let fixture = fixture();
1187
1188        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1189        assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1190        assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1191        assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1192        assert!(plan.archive_members.contains(&"Cookies".to_string()));
1193        assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1194        assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1195        assert!(
1196            plan.archive_members
1197                .iter()
1198                .any(|member| member.ends_with("scheduled-tasks.json"))
1199        );
1200
1201        let full = plan_switch(
1202            &fixture.paths,
1203            "there",
1204            SwitchOpts {
1205                backup_sessions: true,
1206                ..SwitchOpts::default()
1207            },
1208        )
1209        .unwrap();
1210        assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1211    }
1212
1213    #[test]
1214    fn applying_a_switch_quits_then_archives_then_relaunches() {
1215        let fixture = fixture();
1216        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1217        let recorder = Recorder::default();
1218
1219        apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1220
1221        let steps = recorder.steps();
1222        assert_eq!(steps[0], "quit");
1223        assert!(steps[1].starts_with("archive "), "{steps:?}");
1224        assert_eq!(steps[2], "relaunch");
1225    }
1226
1227    #[test]
1228    fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1229        let fixture = fixture();
1230        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1231        let before = std::fs::read(fixture.paths.config_json()).unwrap();
1232        let app = QuitFailure::default();
1233
1234        let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1235
1236        assert!(error.to_string().contains("liveness probe failed"));
1237        assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1238        assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1239    }
1240
1241    /// The wiring, not the pieces: a confirmed deletion must actually reach
1242    /// every registry through `apply_switch`, and the sync record must be
1243    /// written so the next switch can tell deletions from new tasks. Testing
1244    /// `plan_deletion_sweep` alone would pass even if nothing called it.
1245    #[test]
1246    fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1247        let fixture = fixture();
1248        let sessions = fixture.paths.sessions_root();
1249        // Both accounts hold t1; only `there` holds t2.
1250        write(
1251            &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1252            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1253        );
1254        write(
1255            &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1256            r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1257        );
1258
1259        let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1260        plan.confirmed_deletions = [merge::DeletionKey {
1261            kind: merge::ConflictKind::Routine,
1262            id: "t1".to_string(),
1263            deleted_by: "uuid-here".to_string(),
1264            still_in: vec!["uuid-there".to_string()],
1265        }]
1266        .into_iter()
1267        .collect();
1268        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1269
1270        for registry in [
1271            sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1272            sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1273        ] {
1274            let value: serde_json::Value =
1275                serde_json::from_slice(&std::fs::read(&registry).unwrap()).unwrap();
1276            let ids: Vec<&str> = value["scheduledTasks"]
1277                .as_array()
1278                .unwrap()
1279                .iter()
1280                .filter_map(|task| task["id"].as_str())
1281                .collect();
1282            assert!(
1283                !ids.contains(&"t1"),
1284                "{registry:?} still holds the deleted task"
1285            );
1286        }
1287        // The unrelated task survives, so the sweep is targeted rather than a
1288        // blanket wipe of the registry.
1289        let there: serde_json::Value = serde_json::from_slice(
1290            &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1291        )
1292        .unwrap();
1293        assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1294
1295        // And the record now reflects reality, so t1 is not reported as a
1296        // deletion for ever after.
1297        let recorded = load_synced(&fixture.paths.synced_path());
1298        assert!(!recorded.is_empty(), "the sync record was never written");
1299        assert!(
1300            recorded
1301                .values()
1302                .all(|state| !state.routines.contains("t1")),
1303            "{recorded:?} still lists the deleted task"
1304        );
1305    }
1306
1307    /// Keeping everything must leave *other* accounts' registries untouched —
1308    /// the safe answer has to be genuinely inert, not "delete nothing but
1309    /// rewrite everything anyway". The switch target is excluded because the
1310    /// ordinary merge rewrites it either way.
1311    #[test]
1312    fn keeping_every_conflict_leaves_other_accounts_untouched() {
1313        let fixture = fixture();
1314        let sessions = fixture.paths.sessions_root();
1315        let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1316        write(
1317            &bystander,
1318            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1319        );
1320        let before = std::fs::read(&bystander).unwrap();
1321
1322        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1323        assert!(plan.confirmed_deletions.is_empty());
1324        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1325
1326        assert_eq!(std::fs::read(&bystander).unwrap(), before);
1327    }
1328
1329    /// The wiring: a switch must actually converge routine names across every
1330    /// account, not just build the plan. Testing `plan_name_convergence` alone
1331    /// would pass even if `apply_switch` never called it.
1332    #[test]
1333    fn a_switch_converges_routine_names_across_accounts() {
1334        let fixture = fixture();
1335        let sessions = fixture.paths.sessions_root();
1336        let here = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1337        let there = sessions.join("uuid-there/org-2/scheduled-tasks.json");
1338        // Same routine id, two different titles — the non-converging case.
1339        write(
1340            &here,
1341            r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"Home name"}]}"#,
1342        );
1343        write(
1344            &there,
1345            r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"There name"}]}"#,
1346        );
1347
1348        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1349        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1350
1351        let name = |path: &std::path::Path| -> String {
1352            let v: serde_json::Value =
1353                serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1354            v["scheduledTasks"][0]["displayName"]
1355                .as_str()
1356                .unwrap_or_default()
1357                .to_string()
1358        };
1359        // The point is that both accounts agree afterwards, not which name won.
1360        assert_eq!(name(&here), name(&there), "names did not converge");
1361        assert!(!name(&here).is_empty());
1362    }
1363
1364    #[test]
1365    fn applying_a_switch_swaps_the_credential_and_carries_history() {
1366        let fixture = fixture();
1367        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1368
1369        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1370
1371        let config: serde_json::Value =
1372            serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1373        assert_eq!(config["oauth:tokenCache"], "saved-a");
1374        assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1375        assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1376        assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1377
1378        // History followed the switch, and the browser state came back.
1379        assert!(
1380            fixture
1381                .paths
1382                .sessions_root()
1383                .join("uuid-there/org-2/local_x.json")
1384                .is_file()
1385        );
1386        assert_eq!(
1387            std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1388            "there-cookies"
1389        );
1390        assert_eq!(
1391            std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1392                .unwrap(),
1393            "there-ldb"
1394        );
1395        // The volatile remote-control bridge is cleared, never restored.
1396        assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1397    }
1398
1399    #[test]
1400    fn the_outgoing_account_is_snapshotted_before_the_swap() {
1401        let fixture = fixture();
1402        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1403
1404        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1405
1406        // `here` must have captured the credential that was live *before* the
1407        // swap. Reading it back as "saved-a" would mean we filed the incoming
1408        // account's tokens under the outgoing label.
1409        let here = fixture.paths.profile_dir("here");
1410        assert_eq!(
1411            std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1412            "live-a"
1413        );
1414        assert_eq!(
1415            std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1416            "live-b"
1417        );
1418        assert_eq!(
1419            std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1420            "live-cookies"
1421        );
1422        // meta.json belongs to claude-acc; we must never rewrite it.
1423        let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1424        assert!(meta.contains("here@example.com"), "{meta}");
1425    }
1426
1427    #[test]
1428    fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1429        let fixture = fixture();
1430        std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1431        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1432        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1433        assert!(error.to_string().contains("credential"), "{error}");
1434    }
1435
1436    #[test]
1437    fn planning_refuses_credentials_without_saved_browser_state() {
1438        let fixture = fixture();
1439        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1440
1441        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1442
1443        assert!(error.to_string().contains("browser state"), "{error}");
1444    }
1445
1446    #[test]
1447    fn a_failed_switch_requests_rollback_and_still_relaunches() {
1448        let fixture = fixture();
1449        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1450        write(&fixture.paths.config_json(), "{ not json");
1451        let recorder = Recorder::default();
1452
1453        let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1454
1455        assert!(error.to_string().contains("json"), "{error}");
1456        let steps = recorder.steps();
1457        assert_eq!(steps.first().map(String::as_str), Some("quit"));
1458        assert!(
1459            steps.iter().any(|step| step.starts_with("restore ")),
1460            "{steps:?}"
1461        );
1462        assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1463    }
1464
1465    #[test]
1466    fn restoring_browser_state_removes_files_the_target_does_not_have() {
1467        let fixture = fixture();
1468        write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1469        write(
1470            &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1471            "outgoing",
1472        );
1473        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1474
1475        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1476
1477        assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1478        assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1479    }
1480
1481    #[test]
1482    fn keeping_the_bridge_leaves_it_in_place() {
1483        let fixture = fixture();
1484        let plan = plan_switch(
1485            &fixture.paths,
1486            "there",
1487            SwitchOpts {
1488                keep_bridge: true,
1489                ..SwitchOpts::default()
1490            },
1491        )
1492        .unwrap();
1493
1494        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1495        assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1496    }
1497
1498    #[test]
1499    fn archives_are_pruned_oldest_first() {
1500        let dir = tempfile::TempDir::new().unwrap();
1501        for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1502            std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1503        }
1504        std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1505        let mut notes = Vec::new();
1506
1507        prune_archives(dir.path(), 2, &mut notes);
1508
1509        assert!(notes.is_empty(), "{notes:?}");
1510        assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1511        assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1512        assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1513        assert!(dir.path().join("unrelated.txt").exists());
1514    }
1515
1516    #[test]
1517    fn pruning_never_discards_the_only_rollback_archive() {
1518        let dir = tempfile::TempDir::new().unwrap();
1519        std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1520        let mut notes = Vec::new();
1521
1522        prune_archives(dir.path(), 1, &mut notes);
1523
1524        assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1525    }
1526}