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