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    // History merges abort too — a partial merge is recoverable, but doing it
467    // after the credential swap would strand the user on an account whose
468    // history never arrived.
469    for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
470        copy_file(source, destination)?;
471    }
472    if let Some(scheduled) = &plan.scheduled {
473        crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
474    }
475    // A confirmed deletion has to leave every registry, or the copy still
476    // sitting in another account hands it back on the next switch and the same
477    // prompt returns forever. Runs after the merge so it also strips anything
478    // the merge just re-added.
479    match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
480        Ok(sweep) => {
481            for (path, bytes) in sweep.rewrites {
482                if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
483                    notes.push(format!(
484                        "could not apply deletion to {}: {error}",
485                        path.display()
486                    ));
487                }
488            }
489            // Only the chat's *index* goes. Its transcript lives in the
490            // account-agnostic `~/.claude/projects/` and is never touched, so a
491            // confirmed chat stops following you between accounts without the
492            // conversation itself being destroyed.
493            for path in sweep.removals {
494                if let Err(error) = std::fs::remove_file(&path) {
495                    notes.push(format!("could not remove {}: {error}", path.display()));
496                }
497            }
498        }
499        Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
500    }
501    // Record what every account holds now, so the next switch can tell an
502    // intentional deletion from a task that account simply never received.
503    let mut synced = merge::current_state(&paths.sessions_root());
504    let mut canonical = plan
505        .scheduled
506        .as_ref()
507        .map(|scheduled| scheduled.canonical_routines.clone())
508        .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
509    for key in &plan.confirmed_deletions {
510        if key.kind == merge::ConflictKind::Routine {
511            canonical.remove(&key.id);
512        }
513    }
514    let present: BTreeSet<String> = synced
515        .values()
516        .flat_map(|account| account.routines.iter().cloned())
517        .collect();
518    canonical.retain(|id, _| present.contains(id));
519    merge::set_canonical_routines(&mut synced, &canonical);
520    if let Err(error) = save_synced(&paths.synced_path(), &synced) {
521        notes.push(format!("could not record the schedule sync: {error}"));
522    }
523
524    // Identify the outgoing account from the *live* config, after the quit:
525    // the app rewrites this file on shutdown. Snapshotting it must also happen
526    // strictly before the swap below, or the outgoing tokens get filed under
527    // the incoming label and an account is destroyed.
528    let live_config = paths.config_json();
529    let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
530        label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
531    });
532    if let Some(label) = &outgoing {
533        snapshot_profile(paths, label, notes)?;
534    }
535
536    swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
537
538    restore_desktop_state(paths, &plan.target.label)?;
539
540    if !plan.opts.keep_bridge {
541        let bridge = paths.data_dir.join(BRIDGE_FILE);
542        if bridge.is_file()
543            && let Err(error) = std::fs::remove_file(&bridge)
544        {
545            notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
546        }
547    }
548    restore_device_registry(paths, &plan.target.label, notes);
549
550    Ok(())
551}
552
553/// Copy every other account's history into this one's folder, so it shows the
554/// union of everything. Used when capturing a brand-new account, whose first
555/// login would otherwise open on an empty sidebar; a switch uses the
556/// pre-computed plan instead so `--dry-run` can report it.
557///
558/// Returns `(session indexes, routines)` brought in.
559fn merge_history_into(
560    paths: &Paths,
561    account_uuid: &str,
562    org_uuid: &str,
563    notes: &mut Vec<String>,
564) -> (usize, usize) {
565    let sessions_root = paths.sessions_root();
566    let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
567    let mut copied = 0;
568    for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
569        match copy_file(source, destination) {
570            Ok(()) => copied += 1,
571            Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
572        }
573    }
574    let routines = match merge::plan_scheduled_merge(
575        &sessions_root,
576        account_uuid,
577        org_uuid,
578        &load_synced(&paths.synced_path()),
579    ) {
580        Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
581            Ok(()) => scheduled.added + scheduled.updated,
582            Err(error) => {
583                notes.push(format!("schedule seed skipped: {error}"));
584                0
585            }
586        },
587        Err(error) => {
588            notes.push(format!("schedule seed skipped: {error}"));
589            0
590        }
591    };
592    (copied, routines)
593}
594
595/// Save the outgoing account's live credential and browser state back into its
596/// profile, so switching to it later restores what it looked like just now.
597/// Only safe with the app fully quit — copying a live SQLite/LevelDB store
598/// risks grabbing it mid-write.
599///
600/// Deliberately never writes `meta.json`: that file belongs to `claude-acc add`,
601/// and two tools writing it is how the stores drift apart.
602fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
603    let profile_dir = paths.profile_dir(label);
604    std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
605    restrict(&profile_dir, 0o700, notes);
606
607    let bytes =
608        std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
609    let value: serde_json::Value = serde_json::from_slice(&bytes)?;
610    let live_tokens = SavedTokens {
611        token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
612        token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
613    };
614    write_saved_tokens(&profile_dir, &live_tokens, notes)?;
615
616    // The registry is account-keyed and shared, so it is snapshotted (never
617    // moved) purely so a lost key can be folded back in later.
618    let registry = paths.data_dir.join(DEVICE_REGISTRY);
619    if registry.is_file() {
620        let bytes = std::fs::read(&registry).map_err(|e| AppError::io_at(&registry, e))?;
621        crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
622    }
623
624    snapshot_desktop_state(paths, &profile_dir, notes)?;
625    // bridge-state.json is deliberately not snapshotted — see BRIDGE_FILE.
626    Ok(())
627}
628
629fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
630    value
631        .get(key)
632        .and_then(serde_json::Value::as_str)
633        .filter(|blob| !blob.is_empty())
634        .ok_or_else(|| {
635            AppError::Credentials(format!(
636                "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
637            ))
638        })
639}
640
641fn write_saved_tokens(
642    profile_dir: &Path,
643    tokens: &SavedTokens,
644    notes: &mut Vec<String>,
645) -> Result<()> {
646    let token_path = profile_dir.join(TOKEN_CACHE);
647    let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
648    let original = read_optional_file(&token_path)?;
649    let original_v2 = read_optional_file(&token_v2_path)?;
650    let write_result = (|| {
651        crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
652        crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
653        Ok(())
654    })();
655    if let Err(error) = write_result {
656        let mut rollback = Vec::new();
657        if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
658            rollback.push(failure.to_string());
659        }
660        if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
661            rollback.push(failure.to_string());
662        }
663        return if rollback.is_empty() {
664            Err(error)
665        } else {
666            Err(AppError::Other(format!(
667                "{error}; saved-token rollback was incomplete: {}",
668                rollback.join("; ")
669            )))
670        };
671    }
672    restrict(&token_path, 0o600, notes);
673    restrict(&token_v2_path, 0o600, notes);
674    Ok(())
675}
676
677fn snapshot_desktop_state(
678    paths: &Paths,
679    profile_dir: &Path,
680    notes: &mut Vec<String>,
681) -> Result<()> {
682    let state_dir = profile_dir.join(DESKTOP_STATE);
683    let previous = profile_dir.join(".desktop-state.previous");
684    if previous.exists() && !state_dir.exists() {
685        std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
686    } else {
687        remove_if_present(&previous)?;
688    }
689
690    let staged = tempfile::Builder::new()
691        .prefix(".desktop-state.pending-")
692        .tempdir_in(profile_dir)
693        .map_err(|e| AppError::io_at(profile_dir, e))?;
694    restrict(staged.path(), 0o700, notes);
695    for name in COOKIE_FILES {
696        let source = paths.data_dir.join(name);
697        let destination = staged.path().join(name);
698        if source.is_file() {
699            copy_file(&source, &destination)?;
700            restrict(&destination, 0o600, notes);
701        }
702    }
703    for name in LEVELDB_DIRS {
704        let source = paths.data_dir.join(name);
705        let destination = staged.path().join(name);
706        if source.is_dir() {
707            copy_dir(&source, &destination)?;
708        }
709    }
710
711    let staged = staged.keep();
712    if state_dir.exists() {
713        std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
714    }
715    if let Err(error) = std::fs::rename(&staged, &state_dir) {
716        let restore = if previous.exists() {
717            std::fs::rename(&previous, &state_dir)
718        } else {
719            Ok(())
720        };
721        let _ = remove_if_present(&staged);
722        return match restore {
723            Ok(()) => Err(AppError::io_at(&staged, error)),
724            Err(rollback) => Err(AppError::Other(format!(
725                "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
726            ))),
727        };
728    }
729    if let Err(error) = remove_if_present(&previous) {
730        notes.push(format!(
731            "could not remove the previous Desktop-state snapshot: {error}"
732        ));
733    }
734    Ok(())
735}
736
737fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
738    let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
739    let bytes = merge::swap_config_tokens(
740        &existing,
741        &tokens.token_cache,
742        &tokens.token_cache_v2,
743        account_uuid,
744    )?;
745    // Atomic, unlike claude-acc's truncating write: a crash mid-write here
746    // would take every account's tokens with it.
747    crate::cache::atomic_write(config_json, &bytes)
748}
749
750fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
751    let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
752    for name in COOKIE_FILES {
753        let source = state_dir.join(name);
754        let destination = paths.data_dir.join(name);
755        if source.is_file() {
756            copy_file(&source, &destination)?;
757        } else {
758            remove_if_present(&destination)?;
759        }
760    }
761    for name in LEVELDB_DIRS {
762        let source = state_dir.join(name);
763        let destination = paths.data_dir.join(name);
764        if source.is_dir() {
765            replace_dir(&source, &destination)?;
766        } else {
767            remove_if_present(&destination)?;
768        }
769    }
770    Ok(())
771}
772
773/// Fold a snapshotted device registry back into the live one. Purely additive
774/// (live wins every conflict), so this can only ever restore a lost entry.
775fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
776    let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
777    let live = paths.data_dir.join(DEVICE_REGISTRY);
778    let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
779        return;
780    };
781    match merge::merge_device_registry(&current, &saved) {
782        Ok(bytes) if bytes != current => {
783            if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
784                notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
785            }
786        }
787        Ok(_) => {}
788        Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
789    }
790}
791
792/// What goes into the rollback archive, relative to the data directory.
793///
794/// Only what a switch can actually destroy: the credential pointer, browser
795/// identity, bridge, schedule registries, and device registry. Missing entries
796/// are dropped, because `tar` fails the whole archive on one of them.
797fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
798    let mut members = Vec::new();
799    for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
800        if paths.data_dir.join(name).exists() {
801            members.push(name.to_string());
802        }
803    }
804    for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
805        if paths.data_dir.join(name).exists() {
806            members.push(name.to_string());
807        }
808    }
809    if opts.backup_sessions {
810        if paths.sessions_root().is_dir() {
811            members.push(SESSIONS_DIR.to_string());
812        }
813        return members;
814    }
815    let sessions_root = paths.sessions_root();
816    let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
817        return members;
818    };
819    let mut registries = Vec::new();
820    for account in accounts.flatten() {
821        let Ok(orgs) = std::fs::read_dir(account.path()) else {
822            continue;
823        };
824        for org in orgs.flatten() {
825            let path = org.path().join("scheduled-tasks.json");
826            if path.is_file()
827                && let Ok(relative) = path.strip_prefix(&paths.data_dir)
828            {
829                registries.push(relative.display().to_string());
830            }
831        }
832    }
833    registries.sort();
834    members.extend(registries);
835    members
836}
837
838fn identity_members() -> Vec<&'static str> {
839    [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
840        .into_iter()
841        .chain(COOKIE_FILES)
842        .chain(LEVELDB_DIRS)
843        .collect()
844}
845
846fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
847    let Ok(entries) = std::fs::read_dir(backups_dir) else {
848        return;
849    };
850    // The name embeds a sortable timestamp right after the constant prefix, so
851    // lexicographic order is chronological order.
852    let mut archives: Vec<PathBuf> = entries
853        .flatten()
854        .map(|entry| entry.path())
855        .filter(|path| {
856            path.file_name()
857                .and_then(|name| name.to_str())
858                .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
859        })
860        .collect();
861    if archives.len() <= keep {
862        return;
863    }
864    archives.sort();
865    let doomed = archives.len() - keep;
866    for path in archives.into_iter().take(doomed) {
867        if let Err(error) = std::fs::remove_file(&path) {
868            notes.push(format!("could not prune {}: {error}", path.display()));
869        }
870    }
871}
872
873fn copy_file(source: &Path, destination: &Path) -> Result<()> {
874    if let Some(parent) = destination.parent() {
875        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
876    }
877    std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
878    Ok(())
879}
880
881fn remove_if_present(path: &Path) -> Result<()> {
882    match std::fs::symlink_metadata(path) {
883        Ok(metadata) if metadata.is_dir() => {
884            std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
885        }
886        Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
887        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
888        Err(error) => Err(AppError::io_at(path, error)),
889    }
890}
891
892fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
893    match std::fs::read(path) {
894        Ok(bytes) => Ok(Some(bytes)),
895        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
896        Err(error) => Err(AppError::io_at(path, error)),
897    }
898}
899
900fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
901    match original {
902        Some(bytes) => crate::cache::atomic_write(path, bytes),
903        None => remove_if_present(path),
904    }
905}
906
907/// Replace `destination` with a fresh copy of `source`. LevelDB stores must not
908/// be merged file-by-file — a stale manifest beside fresh log files is a
909/// corrupt store — so the old directory goes first.
910fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
911    if destination.exists() {
912        std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
913    }
914    copy_dir(source, destination)
915}
916
917fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
918    std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
919    let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
920    for entry in entries.flatten() {
921        let child = entry.path();
922        let target = destination.join(entry.file_name());
923        if child.is_dir() {
924            copy_dir(&child, &target)?;
925        } else {
926            std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
927        }
928    }
929    Ok(())
930}
931
932#[cfg(unix)]
933fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
934    use std::os::unix::fs::PermissionsExt;
935
936    if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
937        notes.push(format!("could not restrict {}: {error}", path.display()));
938    }
939}
940
941#[cfg(not(unix))]
942fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
943
944fn timestamp() -> String {
945    chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use app::Recorder;
952    use std::cell::RefCell;
953
954    struct Fixture {
955        _root: tempfile::TempDir,
956        paths: Paths,
957    }
958
959    #[derive(Default)]
960    struct QuitFailure {
961        steps: RefCell<Vec<&'static str>>,
962    }
963
964    impl AppControl for QuitFailure {
965        fn quit(&self) -> Result<()> {
966            self.steps.borrow_mut().push("quit");
967            Err(AppError::Other("liveness probe failed".into()))
968        }
969
970        fn relaunch(&self) -> Result<()> {
971            self.steps.borrow_mut().push("relaunch");
972            Ok(())
973        }
974
975        fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
976            panic!("archive must not run after an unconfirmed quit")
977        }
978
979        fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
980            panic!("restore must not run before a switch starts")
981        }
982    }
983
984    fn write(path: &Path, contents: &str) {
985        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
986        std::fs::write(path, contents).unwrap();
987    }
988
989    /// Two accounts: `here` (active) and `there` (fully captured).
990    fn fixture() -> Fixture {
991        let root = tempfile::TempDir::new().unwrap();
992        let data = root.path().join("data");
993        let profiles = root.path().join("profiles");
994        let backups = root.path().join("backups");
995
996        write(
997            &data.join(CONFIG_JSON),
998            r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
999                "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1000        );
1001        write(
1002            &data.join(DEVICE_REGISTRY),
1003            r#"{"uuid-here":{"deviceId":"d1"}}"#,
1004        );
1005        write(
1006            &data.join(BRIDGE_FILE),
1007            r#"{"remoteSessionId":"cse_stale"}"#,
1008        );
1009        write(&data.join("Cookies"), "live-cookies");
1010        write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1011        write(
1012            &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1013            r#"{"lastActivityAt":500}"#,
1014        );
1015        write(
1016            &data
1017                .join(SESSIONS_DIR)
1018                .join("uuid-here/org-1/scheduled-tasks.json"),
1019            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1020        );
1021
1022        write(
1023            &profiles.join("here/meta.json"),
1024            r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1025        );
1026        write(
1027            &profiles.join("there/meta.json"),
1028            r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1029        );
1030        write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1031        write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1032        write(
1033            &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1034            "there-cookies",
1035        );
1036        write(
1037            &profiles
1038                .join("there")
1039                .join(DESKTOP_STATE)
1040                .join("Local Storage/leveldb/CURRENT"),
1041            "there-ldb",
1042        );
1043
1044        Fixture {
1045            paths: Paths::at(data, profiles, backups),
1046            _root: root,
1047        }
1048    }
1049
1050    /// `(relative path, length)` for every file under a root, so a test can
1051    /// assert nothing changed.
1052    fn manifest(root: &Path) -> Vec<(String, u64)> {
1053        fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1054            let Ok(entries) = std::fs::read_dir(dir) else {
1055                return;
1056            };
1057            for entry in entries.flatten() {
1058                let path = entry.path();
1059                if path.is_dir() {
1060                    walk(&path, root, out);
1061                } else if let Ok(meta) = entry.metadata() {
1062                    let relative = path.strip_prefix(root).unwrap().display().to_string();
1063                    out.push((relative, meta.len()));
1064                }
1065            }
1066        }
1067        let mut out = Vec::new();
1068        walk(root, root, &mut out);
1069        out.sort();
1070        out
1071    }
1072
1073    #[test]
1074    fn profiles_load_sorted_with_their_capture_state() {
1075        let fixture = fixture();
1076        let profiles = load_profiles(&fixture.paths.profiles_dir);
1077
1078        assert_eq!(profiles.len(), 2);
1079        assert_eq!(profiles[0].label, "here");
1080        assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1081        assert!(!profiles[0].has_credentials);
1082        assert_eq!(profiles[1].label, "there");
1083        assert!(profiles[1].has_credentials);
1084        assert!(profiles[1].has_desktop_state);
1085    }
1086
1087    #[test]
1088    fn a_malformed_profile_is_skipped_not_fatal() {
1089        let fixture = fixture();
1090        write(
1091            &fixture.paths.profiles_dir.join("broken/meta.json"),
1092            "{ not json",
1093        );
1094        write(
1095            &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1096            r#"{"label":"x"}"#,
1097        );
1098
1099        let profiles = load_profiles(&fixture.paths.profiles_dir);
1100        let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1101        assert_eq!(labels, ["here", "there"]);
1102    }
1103
1104    #[test]
1105    fn the_active_account_resolves_to_its_label() {
1106        let fixture = fixture();
1107        let profiles = load_profiles(&fixture.paths.profiles_dir);
1108        let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1109
1110        assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1111        assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1112    }
1113
1114    #[test]
1115    fn session_counts_come_from_the_accounts_own_folder() {
1116        let fixture = fixture();
1117        let profiles = load_profiles(&fixture.paths.profiles_dir);
1118        let sessions_root = fixture.paths.sessions_root();
1119
1120        assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1121        assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1122    }
1123
1124    #[test]
1125    fn planning_a_switch_changes_nothing_on_disk() {
1126        let fixture = fixture();
1127        let before = manifest(&fixture.paths.data_dir);
1128
1129        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1130
1131        assert_eq!(plan.outgoing.as_deref(), Some("here"));
1132        assert_eq!(plan.tokens.token_cache, "saved-a");
1133        assert!(plan.restores_desktop_state);
1134        assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1135        assert_eq!(manifest(&fixture.paths.data_dir), before);
1136        assert!(!fixture.paths.backups_dir.exists());
1137    }
1138
1139    #[test]
1140    fn planning_an_unknown_label_lists_the_known_ones() {
1141        let fixture = fixture();
1142        let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1143        let message = error.to_string();
1144        assert!(message.contains("here"), "{message}");
1145        assert!(message.contains("claude-acc"), "{message}");
1146    }
1147
1148    #[test]
1149    fn the_archive_skips_the_session_tree_by_default() {
1150        let fixture = fixture();
1151
1152        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1153        assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1154        assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1155        assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1156        assert!(plan.archive_members.contains(&"Cookies".to_string()));
1157        assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1158        assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1159        assert!(
1160            plan.archive_members
1161                .iter()
1162                .any(|member| member.ends_with("scheduled-tasks.json"))
1163        );
1164
1165        let full = plan_switch(
1166            &fixture.paths,
1167            "there",
1168            SwitchOpts {
1169                backup_sessions: true,
1170                ..SwitchOpts::default()
1171            },
1172        )
1173        .unwrap();
1174        assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1175    }
1176
1177    #[test]
1178    fn applying_a_switch_quits_then_archives_then_relaunches() {
1179        let fixture = fixture();
1180        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1181        let recorder = Recorder::default();
1182
1183        apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1184
1185        let steps = recorder.steps();
1186        assert_eq!(steps[0], "quit");
1187        assert!(steps[1].starts_with("archive "), "{steps:?}");
1188        assert_eq!(steps[2], "relaunch");
1189    }
1190
1191    #[test]
1192    fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1193        let fixture = fixture();
1194        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1195        let before = std::fs::read(fixture.paths.config_json()).unwrap();
1196        let app = QuitFailure::default();
1197
1198        let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1199
1200        assert!(error.to_string().contains("liveness probe failed"));
1201        assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1202        assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1203    }
1204
1205    /// The wiring, not the pieces: a confirmed deletion must actually reach
1206    /// every registry through `apply_switch`, and the sync record must be
1207    /// written so the next switch can tell deletions from new tasks. Testing
1208    /// `plan_deletion_sweep` alone would pass even if nothing called it.
1209    #[test]
1210    fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1211        let fixture = fixture();
1212        let sessions = fixture.paths.sessions_root();
1213        // Both accounts hold t1; only `there` holds t2.
1214        write(
1215            &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1216            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1217        );
1218        write(
1219            &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1220            r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1221        );
1222
1223        let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1224        plan.confirmed_deletions = [merge::DeletionKey {
1225            kind: merge::ConflictKind::Routine,
1226            id: "t1".to_string(),
1227            deleted_by: "uuid-here".to_string(),
1228            still_in: vec!["uuid-there".to_string()],
1229        }]
1230        .into_iter()
1231        .collect();
1232        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1233
1234        for registry in [
1235            sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1236            sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1237        ] {
1238            let value: serde_json::Value =
1239                serde_json::from_slice(&std::fs::read(&registry).unwrap()).unwrap();
1240            let ids: Vec<&str> = value["scheduledTasks"]
1241                .as_array()
1242                .unwrap()
1243                .iter()
1244                .filter_map(|task| task["id"].as_str())
1245                .collect();
1246            assert!(
1247                !ids.contains(&"t1"),
1248                "{registry:?} still holds the deleted task"
1249            );
1250        }
1251        // The unrelated task survives, so the sweep is targeted rather than a
1252        // blanket wipe of the registry.
1253        let there: serde_json::Value = serde_json::from_slice(
1254            &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1255        )
1256        .unwrap();
1257        assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1258
1259        // And the record now reflects reality, so t1 is not reported as a
1260        // deletion for ever after.
1261        let recorded = load_synced(&fixture.paths.synced_path());
1262        assert!(!recorded.is_empty(), "the sync record was never written");
1263        assert!(
1264            recorded
1265                .values()
1266                .all(|state| !state.routines.contains("t1")),
1267            "{recorded:?} still lists the deleted task"
1268        );
1269    }
1270
1271    /// Keeping everything must leave *other* accounts' registries untouched —
1272    /// the safe answer has to be genuinely inert, not "delete nothing but
1273    /// rewrite everything anyway". The switch target is excluded because the
1274    /// ordinary merge rewrites it either way.
1275    #[test]
1276    fn keeping_every_conflict_leaves_other_accounts_untouched() {
1277        let fixture = fixture();
1278        let sessions = fixture.paths.sessions_root();
1279        let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1280        write(
1281            &bystander,
1282            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1283        );
1284        let before = std::fs::read(&bystander).unwrap();
1285
1286        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1287        assert!(plan.confirmed_deletions.is_empty());
1288        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1289
1290        assert_eq!(std::fs::read(&bystander).unwrap(), before);
1291    }
1292
1293    #[test]
1294    fn applying_a_switch_swaps_the_credential_and_carries_history() {
1295        let fixture = fixture();
1296        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1297
1298        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1299
1300        let config: serde_json::Value =
1301            serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1302        assert_eq!(config["oauth:tokenCache"], "saved-a");
1303        assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1304        assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1305        assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1306
1307        // History followed the switch, and the browser state came back.
1308        assert!(
1309            fixture
1310                .paths
1311                .sessions_root()
1312                .join("uuid-there/org-2/local_x.json")
1313                .is_file()
1314        );
1315        assert_eq!(
1316            std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1317            "there-cookies"
1318        );
1319        assert_eq!(
1320            std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1321                .unwrap(),
1322            "there-ldb"
1323        );
1324        // The volatile remote-control bridge is cleared, never restored.
1325        assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1326    }
1327
1328    #[test]
1329    fn the_outgoing_account_is_snapshotted_before_the_swap() {
1330        let fixture = fixture();
1331        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1332
1333        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1334
1335        // `here` must have captured the credential that was live *before* the
1336        // swap. Reading it back as "saved-a" would mean we filed the incoming
1337        // account's tokens under the outgoing label.
1338        let here = fixture.paths.profile_dir("here");
1339        assert_eq!(
1340            std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1341            "live-a"
1342        );
1343        assert_eq!(
1344            std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1345            "live-b"
1346        );
1347        assert_eq!(
1348            std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1349            "live-cookies"
1350        );
1351        // meta.json belongs to claude-acc; we must never rewrite it.
1352        let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1353        assert!(meta.contains("here@example.com"), "{meta}");
1354    }
1355
1356    #[test]
1357    fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1358        let fixture = fixture();
1359        std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1360        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1361        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1362        assert!(error.to_string().contains("credential"), "{error}");
1363    }
1364
1365    #[test]
1366    fn planning_refuses_credentials_without_saved_browser_state() {
1367        let fixture = fixture();
1368        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1369
1370        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1371
1372        assert!(error.to_string().contains("browser state"), "{error}");
1373    }
1374
1375    #[test]
1376    fn a_failed_switch_requests_rollback_and_still_relaunches() {
1377        let fixture = fixture();
1378        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1379        write(&fixture.paths.config_json(), "{ not json");
1380        let recorder = Recorder::default();
1381
1382        let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1383
1384        assert!(error.to_string().contains("json"), "{error}");
1385        let steps = recorder.steps();
1386        assert_eq!(steps.first().map(String::as_str), Some("quit"));
1387        assert!(
1388            steps.iter().any(|step| step.starts_with("restore ")),
1389            "{steps:?}"
1390        );
1391        assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1392    }
1393
1394    #[test]
1395    fn restoring_browser_state_removes_files_the_target_does_not_have() {
1396        let fixture = fixture();
1397        write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1398        write(
1399            &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1400            "outgoing",
1401        );
1402        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1403
1404        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1405
1406        assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1407        assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1408    }
1409
1410    #[test]
1411    fn keeping_the_bridge_leaves_it_in_place() {
1412        let fixture = fixture();
1413        let plan = plan_switch(
1414            &fixture.paths,
1415            "there",
1416            SwitchOpts {
1417                keep_bridge: true,
1418                ..SwitchOpts::default()
1419            },
1420        )
1421        .unwrap();
1422
1423        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1424        assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1425    }
1426
1427    #[test]
1428    fn archives_are_pruned_oldest_first() {
1429        let dir = tempfile::TempDir::new().unwrap();
1430        for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1431            std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1432        }
1433        std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1434        let mut notes = Vec::new();
1435
1436        prune_archives(dir.path(), 2, &mut notes);
1437
1438        assert!(notes.is_empty(), "{notes:?}");
1439        assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1440        assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1441        assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1442        assert!(dir.path().join("unrelated.txt").exists());
1443    }
1444
1445    #[test]
1446    fn pruning_never_discards_the_only_rollback_archive() {
1447        let dir = tempfile::TempDir::new().unwrap();
1448        std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1449        let mut notes = Vec::new();
1450
1451        prune_archives(dir.path(), 1, &mut notes);
1452
1453        assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1454    }
1455}