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::path::{Path, PathBuf};
27
28use serde::Deserialize;
29
30use crate::config::AnthropicConfig;
31use crate::error::{AppError, Result};
32
33use app::AppControl;
34use merge::{ScheduledMerge, SessionMerge};
35
36/// Claude Desktop's own state file: OAuth token caches plus the account pointer.
37const CONFIG_JSON: &str = "config.json";
38/// Root of the per-account session-index tree, `<root>/<account>/<org>/`.
39const SESSIONS_DIR: &str = "claude-code-sessions";
40/// Chromium-style cookie jar — part of the renderer's identity, not just
41/// `config.json`'s oauth fields.
42const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
43/// LevelDB stores that carry the rest of that identity.
44const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
45/// Remote-control / cloud-session bridge. Holds a volatile `cse_…` session id
46/// that goes stale fast, so it is deleted on every switch and *never* saved
47/// into a profile: restoring a dead id makes `/remote-control` fail to
48/// disconnect.
49const BRIDGE_FILE: &str = "bridge-state.json";
50/// Account-keyed map of browser-extension device registrations. Additive only —
51/// see [`merge::merge_device_registry`].
52const DEVICE_REGISTRY: &str = "ant-device-registry.json";
53
54/// Profile-store filenames, owned by claude-acc's layout.
55const TOKEN_CACHE: &str = "config-tokenCache";
56const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
57const DESKTOP_STATE: &str = "desktop-state";
58const META_JSON: &str = "meta.json";
59
60/// Where the Claude Desktop app and the saved profiles live.
61///
62/// Constructed with [`Paths::at`] in tests so nothing reads a real `$HOME`.
63#[derive(Debug, Clone)]
64pub struct Paths {
65    pub data_dir: PathBuf,
66    pub profiles_dir: PathBuf,
67    pub backups_dir: PathBuf,
68}
69
70impl Paths {
71    /// Test seam: every root explicit.
72    pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
73        Self {
74            data_dir,
75            profiles_dir,
76            backups_dir,
77        }
78    }
79
80    /// Production paths. `desktop_profiles_dir` overrides the claude-acc
81    /// default; rollback archives land beside the profile store.
82    pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
83        let home = crate::cache::home_dir()?;
84        let profiles_dir = anthropic
85            .desktop_profiles_dir
86            .clone()
87            .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
88        let backups_dir = profiles_dir
89            .parent()
90            .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
91            .join("backups");
92        Ok(Self {
93            data_dir: home.join("Library/Application Support/Claude"),
94            profiles_dir,
95            backups_dir,
96        })
97    }
98
99    /// Whether there is a Claude Desktop app installation to act on at all.
100    /// False on Linux, and on a Mac where the app has never run.
101    pub fn available(&self) -> bool {
102        self.data_dir.is_dir()
103    }
104
105    pub fn config_json(&self) -> PathBuf {
106        self.data_dir.join(CONFIG_JSON)
107    }
108
109    pub fn sessions_root(&self) -> PathBuf {
110        self.data_dir.join(SESSIONS_DIR)
111    }
112
113    pub fn profile_dir(&self, label: &str) -> PathBuf {
114        self.profiles_dir.join(label)
115    }
116
117    /// Where [`capture`] parks the live login before clearing it, so a
118    /// cancelled capture can put the account back. Sits beside the archives.
119    pub fn prelogin_dir(&self) -> PathBuf {
120        self.backups_dir.with_file_name("prelogin-backup")
121    }
122}
123
124/// One saved account in the profile store.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ProfileMeta {
127    /// The profile directory's name — authoritative, so a hand-edited
128    /// `meta.json` can never make a profile answer to the wrong label.
129    pub label: String,
130    pub email: Option<String>,
131    pub account_uuid: String,
132    /// Absent until the app has created a session folder for the account;
133    /// without it the history merges are skipped but the credential swap
134    /// still works.
135    pub org_uuid: Option<String>,
136    pub has_credentials: bool,
137    pub has_desktop_state: bool,
138}
139
140#[derive(Debug, Deserialize)]
141struct RawMeta {
142    email: Option<String>,
143    #[serde(rename = "accountUuid")]
144    account_uuid: Option<String>,
145    #[serde(rename = "orgUuid")]
146    org_uuid: Option<String>,
147}
148
149/// Every saved profile, sorted by label. Best-effort by design: one unreadable
150/// or hand-mangled `meta.json` is skipped rather than failing `account status`
151/// for every other account.
152pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
153    let Ok(entries) = std::fs::read_dir(profiles_dir) else {
154        return Vec::new();
155    };
156    let mut profiles: Vec<ProfileMeta> = entries
157        .flatten()
158        .filter_map(|entry| {
159            let dir = entry.path();
160            let label = dir.file_name()?.to_str()?.to_string();
161            let raw: RawMeta =
162                serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
163            let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
164            Some(ProfileMeta {
165                label,
166                email: raw.email.filter(|email| !email.is_empty()),
167                account_uuid,
168                org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
169                has_credentials: dir.join(TOKEN_CACHE).is_file()
170                    && dir.join(TOKEN_CACHE_V2).is_file(),
171                has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
172            })
173        })
174        .collect();
175    profiles.sort_by(|a, b| a.label.cmp(&b.label));
176    profiles
177}
178
179/// Which account the Desktop app currently believes it is. This is the app's
180/// own pointer, not a guess from file timestamps.
181pub fn active_account_uuid(config_json: &Path) -> Option<String> {
182    let bytes = std::fs::read(config_json).ok()?;
183    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
184    value
185        .get("lastKnownAccountUuid")?
186        .as_str()
187        .filter(|uuid| !uuid.is_empty())
188        .map(str::to_string)
189}
190
191pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
192    profiles
193        .iter()
194        .find(|profile| profile.account_uuid == account_uuid)
195        .map(|profile| profile.label.as_str())
196}
197
198/// How many conversations the account's history folder holds.
199pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
200    let Some(org) = &profile.org_uuid else {
201        return 0;
202    };
203    let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
204        return 0;
205    };
206    entries
207        .flatten()
208        .filter(|entry| {
209            entry
210                .path()
211                .extension()
212                .is_some_and(|extension| extension == "json")
213        })
214        .count()
215}
216
217/// Knobs that change what a switch does rather than which account it targets.
218#[derive(Debug, Clone)]
219pub struct SwitchOpts {
220    /// Keep `bridge-state.json` instead of deleting it. Off by default, so the
221    /// shipped behaviour matches claude-acc; on, it turns "does the remote
222    /// bridge cause the browser disconnect?" into a one-command experiment.
223    pub keep_bridge: bool,
224    /// Also archive the whole session tree. Off by default: the session merge
225    /// is additive (the older copy always survives in its source folder), so a
226    /// full-tree archive costs tens of megabytes per switch to protect against
227    /// nothing. On, it matches claude-acc byte for byte.
228    pub backup_sessions: bool,
229    /// Rollback archives to retain.
230    pub keep_backups: usize,
231}
232
233impl Default for SwitchOpts {
234    fn default() -> Self {
235        Self {
236            keep_bridge: false,
237            backup_sessions: false,
238            keep_backups: 10,
239        }
240    }
241}
242
243/// The saved OAuth token caches for the account being switched to. Opaque
244/// blobs; never logged or printed.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SavedTokens {
247    pub token_cache: String,
248    pub token_cache_v2: String,
249}
250
251/// Everything a switch would do, decided before anything is touched.
252#[derive(Debug)]
253pub struct SwitchPlan {
254    pub target: ProfileMeta,
255    /// The account being switched away from, when it is one we manage.
256    pub outgoing: Option<String>,
257    pub sessions: SessionMerge,
258    /// Absent when the target has no known org yet, so there is no history
259    /// folder to merge into.
260    pub scheduled: Option<ScheduledMerge>,
261    /// A switch is only valid with both saved Desktop credential blobs. Mixing
262    /// a target account's browser state with the current account's credential
263    /// would create an incoherent, destructive half-switch.
264    pub tokens: SavedTokens,
265    pub archive: PathBuf,
266    pub archive_members: Vec<String>,
267    pub restores_desktop_state: bool,
268    pub opts: SwitchOpts,
269}
270
271/// Decide the whole switch without performing any of it.
272///
273/// This is what makes `--dry-run` free, and a test asserts it leaves the data
274/// directory byte-identical.
275pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
276    let profiles = load_profiles(&paths.profiles_dir);
277    let target = profiles
278        .iter()
279        .find(|profile| profile.label == label)
280        .cloned()
281        .ok_or_else(|| {
282            let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
283            AppError::Credentials(format!(
284                "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
285                 Capture one with `claude-acc add {label}` \
286                 (https://github.com/ohmaseclaro/claude-acc)",
287                paths.profiles_dir.display()
288            ))
289        })?;
290
291    let sessions_root = paths.sessions_root();
292    let (sessions, scheduled) = match &target.org_uuid {
293        Some(org) => (
294            merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
295            Some(merge::plan_scheduled_merge(
296                &sessions_root,
297                &target.account_uuid,
298                org,
299            )?),
300        ),
301        None => (SessionMerge::default(), None),
302    };
303
304    let outgoing = active_account_uuid(&paths.config_json())
305        .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
306
307    let profile_dir = paths.profile_dir(label);
308    let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
309        AppError::Credentials(format!(
310            "no complete saved Desktop credential for {label:?}; capture or sign into that \
311             account before switching"
312        ))
313    })?;
314    let token_cache_v2 =
315        std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
316            AppError::Credentials(format!(
317                "no complete saved Desktop credential for {label:?}; capture or sign into that \
318                 account before switching"
319            ))
320        })?;
321    if token_cache.is_empty() || token_cache_v2.is_empty() {
322        return Err(AppError::Credentials(format!(
323            "the saved Desktop credential for {label:?} is empty; capture it again before switching"
324        )));
325    }
326    let tokens = SavedTokens {
327        token_cache,
328        token_cache_v2,
329    };
330    if !profile_dir.join(DESKTOP_STATE).is_dir() {
331        return Err(AppError::Credentials(format!(
332            "no saved Desktop browser state for {label:?}; capture that account again before switching"
333        )));
334    }
335
336    let stamp = crate::claude_desktop::timestamp();
337    Ok(SwitchPlan {
338        archive: paths
339            .backups_dir
340            .join(format!("switch-{stamp}-{label}.tar.gz")),
341        archive_members: archive_members(paths, &opts),
342        restores_desktop_state: true,
343        target,
344        outgoing,
345        sessions,
346        scheduled,
347        tokens,
348        opts,
349    })
350}
351
352/// Perform a planned switch.
353///
354/// Claude is always relaunched after it has been stopped. If an operation fails
355/// after the rollback archive is written, the live Desktop identity is restored
356/// from that archive before relaunching.
357pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
358    let mut notes = Vec::new();
359    let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
360
361    // Stop before archiving so SQLite/LevelDB and config.json are quiescent.
362    app.quit()?;
363    let mut archived = false;
364    let mut result = (|| {
365        if !members.is_empty() {
366            app.archive(&plan.archive, &paths.data_dir, &members)?;
367            archived = true;
368            // Never prune away the archive needed for this switch's rollback,
369            // even if the caller supplied --keep-backups 0.
370            prune_archives(
371                &paths.backups_dir,
372                plan.opts.keep_backups.max(1),
373                &mut notes,
374            );
375        }
376        apply_switch_while_stopped(paths, plan, &mut notes)
377    })();
378
379    if result.is_err()
380        && archived
381        && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
382    {
383        let original = result.unwrap_err();
384        result = Err(AppError::Other(format!(
385            "{original}; automatic Desktop rollback was incomplete: {rollback}"
386        )));
387    }
388
389    let relaunch = app.relaunch();
390    match (result, relaunch) {
391        (Ok(()), Ok(())) => Ok(notes),
392        (Err(error), Ok(())) => Err(error),
393        (Ok(()), Err(error)) => Err(error),
394        (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
395            "{error}; Claude Desktop also could not be relaunched: {relaunch}"
396        ))),
397    }
398}
399
400fn apply_switch_while_stopped(
401    paths: &Paths,
402    plan: &SwitchPlan,
403    notes: &mut Vec<String>,
404) -> Result<()> {
405    // History merges abort too — a partial merge is recoverable, but doing it
406    // after the credential swap would strand the user on an account whose
407    // history never arrived.
408    for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
409        copy_file(source, destination)?;
410    }
411    if let Some(scheduled) = &plan.scheduled {
412        crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
413    }
414
415    // Identify the outgoing account from the *live* config, after the quit:
416    // the app rewrites this file on shutdown. Snapshotting it must also happen
417    // strictly before the swap below, or the outgoing tokens get filed under
418    // the incoming label and an account is destroyed.
419    let live_config = paths.config_json();
420    let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
421        label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
422    });
423    if let Some(label) = &outgoing {
424        snapshot_profile(paths, label, notes)?;
425    }
426
427    swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
428
429    restore_desktop_state(paths, &plan.target.label)?;
430
431    if !plan.opts.keep_bridge {
432        let bridge = paths.data_dir.join(BRIDGE_FILE);
433        if bridge.is_file()
434            && let Err(error) = std::fs::remove_file(&bridge)
435        {
436            notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
437        }
438    }
439    restore_device_registry(paths, &plan.target.label, notes);
440
441    Ok(())
442}
443
444/// Copy every other account's history into this one's folder, so it shows the
445/// union of everything. Used when capturing a brand-new account, whose first
446/// login would otherwise open on an empty sidebar; a switch uses the
447/// pre-computed plan instead so `--dry-run` can report it.
448///
449/// Returns `(session indexes, routines)` brought in.
450fn merge_history_into(
451    paths: &Paths,
452    account_uuid: &str,
453    org_uuid: &str,
454    notes: &mut Vec<String>,
455) -> (usize, usize) {
456    let sessions_root = paths.sessions_root();
457    let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
458    let mut copied = 0;
459    for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
460        match copy_file(source, destination) {
461            Ok(()) => copied += 1,
462            Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
463        }
464    }
465    let routines = match merge::plan_scheduled_merge(&sessions_root, account_uuid, org_uuid) {
466        Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
467            Ok(()) => scheduled.added,
468            Err(error) => {
469                notes.push(format!("schedule seed skipped: {error}"));
470                0
471            }
472        },
473        Err(error) => {
474            notes.push(format!("schedule seed skipped: {error}"));
475            0
476        }
477    };
478    (copied, routines)
479}
480
481/// Save the outgoing account's live credential and browser state back into its
482/// profile, so switching to it later restores what it looked like just now.
483/// Only safe with the app fully quit — copying a live SQLite/LevelDB store
484/// risks grabbing it mid-write.
485///
486/// Deliberately never writes `meta.json`: that file belongs to `claude-acc add`,
487/// and two tools writing it is how the stores drift apart.
488fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
489    let profile_dir = paths.profile_dir(label);
490    std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
491    restrict(&profile_dir, 0o700, notes);
492
493    let bytes =
494        std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
495    let value: serde_json::Value = serde_json::from_slice(&bytes)?;
496    let live_tokens = SavedTokens {
497        token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
498        token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
499    };
500    write_saved_tokens(&profile_dir, &live_tokens, notes)?;
501
502    // The registry is account-keyed and shared, so it is snapshotted (never
503    // moved) purely so a lost key can be folded back in later.
504    let registry = paths.data_dir.join(DEVICE_REGISTRY);
505    if registry.is_file() {
506        let bytes = std::fs::read(&registry).map_err(|e| AppError::io_at(&registry, e))?;
507        crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
508    }
509
510    snapshot_desktop_state(paths, &profile_dir, notes)?;
511    // bridge-state.json is deliberately not snapshotted — see BRIDGE_FILE.
512    Ok(())
513}
514
515fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
516    value
517        .get(key)
518        .and_then(serde_json::Value::as_str)
519        .filter(|blob| !blob.is_empty())
520        .ok_or_else(|| {
521            AppError::Credentials(format!(
522                "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
523            ))
524        })
525}
526
527fn write_saved_tokens(
528    profile_dir: &Path,
529    tokens: &SavedTokens,
530    notes: &mut Vec<String>,
531) -> Result<()> {
532    let token_path = profile_dir.join(TOKEN_CACHE);
533    let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
534    let original = read_optional_file(&token_path)?;
535    let original_v2 = read_optional_file(&token_v2_path)?;
536    let write_result = (|| {
537        crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
538        crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
539        Ok(())
540    })();
541    if let Err(error) = write_result {
542        let mut rollback = Vec::new();
543        if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
544            rollback.push(failure.to_string());
545        }
546        if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
547            rollback.push(failure.to_string());
548        }
549        return if rollback.is_empty() {
550            Err(error)
551        } else {
552            Err(AppError::Other(format!(
553                "{error}; saved-token rollback was incomplete: {}",
554                rollback.join("; ")
555            )))
556        };
557    }
558    restrict(&token_path, 0o600, notes);
559    restrict(&token_v2_path, 0o600, notes);
560    Ok(())
561}
562
563fn snapshot_desktop_state(
564    paths: &Paths,
565    profile_dir: &Path,
566    notes: &mut Vec<String>,
567) -> Result<()> {
568    let state_dir = profile_dir.join(DESKTOP_STATE);
569    let previous = profile_dir.join(".desktop-state.previous");
570    if previous.exists() && !state_dir.exists() {
571        std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
572    } else {
573        remove_if_present(&previous)?;
574    }
575
576    let staged = tempfile::Builder::new()
577        .prefix(".desktop-state.pending-")
578        .tempdir_in(profile_dir)
579        .map_err(|e| AppError::io_at(profile_dir, e))?;
580    restrict(staged.path(), 0o700, notes);
581    for name in COOKIE_FILES {
582        let source = paths.data_dir.join(name);
583        let destination = staged.path().join(name);
584        if source.is_file() {
585            copy_file(&source, &destination)?;
586            restrict(&destination, 0o600, notes);
587        }
588    }
589    for name in LEVELDB_DIRS {
590        let source = paths.data_dir.join(name);
591        let destination = staged.path().join(name);
592        if source.is_dir() {
593            copy_dir(&source, &destination)?;
594        }
595    }
596
597    let staged = staged.keep();
598    if state_dir.exists() {
599        std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
600    }
601    if let Err(error) = std::fs::rename(&staged, &state_dir) {
602        let restore = if previous.exists() {
603            std::fs::rename(&previous, &state_dir)
604        } else {
605            Ok(())
606        };
607        let _ = remove_if_present(&staged);
608        return match restore {
609            Ok(()) => Err(AppError::io_at(&staged, error)),
610            Err(rollback) => Err(AppError::Other(format!(
611                "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
612            ))),
613        };
614    }
615    if let Err(error) = remove_if_present(&previous) {
616        notes.push(format!(
617            "could not remove the previous Desktop-state snapshot: {error}"
618        ));
619    }
620    Ok(())
621}
622
623fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
624    let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
625    let bytes = merge::swap_config_tokens(
626        &existing,
627        &tokens.token_cache,
628        &tokens.token_cache_v2,
629        account_uuid,
630    )?;
631    // Atomic, unlike claude-acc's truncating write: a crash mid-write here
632    // would take every account's tokens with it.
633    crate::cache::atomic_write(config_json, &bytes)
634}
635
636fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
637    let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
638    for name in COOKIE_FILES {
639        let source = state_dir.join(name);
640        let destination = paths.data_dir.join(name);
641        if source.is_file() {
642            copy_file(&source, &destination)?;
643        } else {
644            remove_if_present(&destination)?;
645        }
646    }
647    for name in LEVELDB_DIRS {
648        let source = state_dir.join(name);
649        let destination = paths.data_dir.join(name);
650        if source.is_dir() {
651            replace_dir(&source, &destination)?;
652        } else {
653            remove_if_present(&destination)?;
654        }
655    }
656    Ok(())
657}
658
659/// Fold a snapshotted device registry back into the live one. Purely additive
660/// (live wins every conflict), so this can only ever restore a lost entry.
661fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
662    let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
663    let live = paths.data_dir.join(DEVICE_REGISTRY);
664    let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
665        return;
666    };
667    match merge::merge_device_registry(&current, &saved) {
668        Ok(bytes) if bytes != current => {
669            if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
670                notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
671            }
672        }
673        Ok(_) => {}
674        Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
675    }
676}
677
678/// What goes into the rollback archive, relative to the data directory.
679///
680/// Only what a switch can actually destroy: the credential pointer, browser
681/// identity, bridge, schedule registries, and device registry. Missing entries
682/// are dropped, because `tar` fails the whole archive on one of them.
683fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
684    let mut members = Vec::new();
685    for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
686        if paths.data_dir.join(name).exists() {
687            members.push(name.to_string());
688        }
689    }
690    for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
691        if paths.data_dir.join(name).exists() {
692            members.push(name.to_string());
693        }
694    }
695    if opts.backup_sessions {
696        if paths.sessions_root().is_dir() {
697            members.push(SESSIONS_DIR.to_string());
698        }
699        return members;
700    }
701    let sessions_root = paths.sessions_root();
702    let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
703        return members;
704    };
705    let mut registries = Vec::new();
706    for account in accounts.flatten() {
707        let Ok(orgs) = std::fs::read_dir(account.path()) else {
708            continue;
709        };
710        for org in orgs.flatten() {
711            let path = org.path().join("scheduled-tasks.json");
712            if path.is_file()
713                && let Ok(relative) = path.strip_prefix(&paths.data_dir)
714            {
715                registries.push(relative.display().to_string());
716            }
717        }
718    }
719    registries.sort();
720    members.extend(registries);
721    members
722}
723
724fn identity_members() -> Vec<&'static str> {
725    [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
726        .into_iter()
727        .chain(COOKIE_FILES)
728        .chain(LEVELDB_DIRS)
729        .collect()
730}
731
732fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
733    let Ok(entries) = std::fs::read_dir(backups_dir) else {
734        return;
735    };
736    // The name embeds a sortable timestamp right after the constant prefix, so
737    // lexicographic order is chronological order.
738    let mut archives: Vec<PathBuf> = entries
739        .flatten()
740        .map(|entry| entry.path())
741        .filter(|path| {
742            path.file_name()
743                .and_then(|name| name.to_str())
744                .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
745        })
746        .collect();
747    if archives.len() <= keep {
748        return;
749    }
750    archives.sort();
751    let doomed = archives.len() - keep;
752    for path in archives.into_iter().take(doomed) {
753        if let Err(error) = std::fs::remove_file(&path) {
754            notes.push(format!("could not prune {}: {error}", path.display()));
755        }
756    }
757}
758
759fn copy_file(source: &Path, destination: &Path) -> Result<()> {
760    if let Some(parent) = destination.parent() {
761        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
762    }
763    std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
764    Ok(())
765}
766
767fn remove_if_present(path: &Path) -> Result<()> {
768    match std::fs::symlink_metadata(path) {
769        Ok(metadata) if metadata.is_dir() => {
770            std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
771        }
772        Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
773        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
774        Err(error) => Err(AppError::io_at(path, error)),
775    }
776}
777
778fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
779    match std::fs::read(path) {
780        Ok(bytes) => Ok(Some(bytes)),
781        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
782        Err(error) => Err(AppError::io_at(path, error)),
783    }
784}
785
786fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
787    match original {
788        Some(bytes) => crate::cache::atomic_write(path, bytes),
789        None => remove_if_present(path),
790    }
791}
792
793/// Replace `destination` with a fresh copy of `source`. LevelDB stores must not
794/// be merged file-by-file — a stale manifest beside fresh log files is a
795/// corrupt store — so the old directory goes first.
796fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
797    if destination.exists() {
798        std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
799    }
800    copy_dir(source, destination)
801}
802
803fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
804    std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
805    let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
806    for entry in entries.flatten() {
807        let child = entry.path();
808        let target = destination.join(entry.file_name());
809        if child.is_dir() {
810            copy_dir(&child, &target)?;
811        } else {
812            std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
813        }
814    }
815    Ok(())
816}
817
818#[cfg(unix)]
819fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
820    use std::os::unix::fs::PermissionsExt;
821
822    if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
823        notes.push(format!("could not restrict {}: {error}", path.display()));
824    }
825}
826
827#[cfg(not(unix))]
828fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
829
830fn timestamp() -> String {
831    chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use app::Recorder;
838
839    struct Fixture {
840        _root: tempfile::TempDir,
841        paths: Paths,
842    }
843
844    fn write(path: &Path, contents: &str) {
845        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
846        std::fs::write(path, contents).unwrap();
847    }
848
849    /// Two accounts: `here` (active) and `there` (fully captured).
850    fn fixture() -> Fixture {
851        let root = tempfile::TempDir::new().unwrap();
852        let data = root.path().join("data");
853        let profiles = root.path().join("profiles");
854        let backups = root.path().join("backups");
855
856        write(
857            &data.join(CONFIG_JSON),
858            r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
859                "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
860        );
861        write(
862            &data.join(DEVICE_REGISTRY),
863            r#"{"uuid-here":{"deviceId":"d1"}}"#,
864        );
865        write(
866            &data.join(BRIDGE_FILE),
867            r#"{"remoteSessionId":"cse_stale"}"#,
868        );
869        write(&data.join("Cookies"), "live-cookies");
870        write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
871        write(
872            &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
873            r#"{"lastActivityAt":500}"#,
874        );
875        write(
876            &data
877                .join(SESSIONS_DIR)
878                .join("uuid-here/org-1/scheduled-tasks.json"),
879            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
880        );
881
882        write(
883            &profiles.join("here/meta.json"),
884            r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
885        );
886        write(
887            &profiles.join("there/meta.json"),
888            r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
889        );
890        write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
891        write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
892        write(
893            &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
894            "there-cookies",
895        );
896        write(
897            &profiles
898                .join("there")
899                .join(DESKTOP_STATE)
900                .join("Local Storage/leveldb/CURRENT"),
901            "there-ldb",
902        );
903
904        Fixture {
905            paths: Paths::at(data, profiles, backups),
906            _root: root,
907        }
908    }
909
910    /// `(relative path, length)` for every file under a root, so a test can
911    /// assert nothing changed.
912    fn manifest(root: &Path) -> Vec<(String, u64)> {
913        fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
914            let Ok(entries) = std::fs::read_dir(dir) else {
915                return;
916            };
917            for entry in entries.flatten() {
918                let path = entry.path();
919                if path.is_dir() {
920                    walk(&path, root, out);
921                } else if let Ok(meta) = entry.metadata() {
922                    let relative = path.strip_prefix(root).unwrap().display().to_string();
923                    out.push((relative, meta.len()));
924                }
925            }
926        }
927        let mut out = Vec::new();
928        walk(root, root, &mut out);
929        out.sort();
930        out
931    }
932
933    #[test]
934    fn profiles_load_sorted_with_their_capture_state() {
935        let fixture = fixture();
936        let profiles = load_profiles(&fixture.paths.profiles_dir);
937
938        assert_eq!(profiles.len(), 2);
939        assert_eq!(profiles[0].label, "here");
940        assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
941        assert!(!profiles[0].has_credentials);
942        assert_eq!(profiles[1].label, "there");
943        assert!(profiles[1].has_credentials);
944        assert!(profiles[1].has_desktop_state);
945    }
946
947    #[test]
948    fn a_malformed_profile_is_skipped_not_fatal() {
949        let fixture = fixture();
950        write(
951            &fixture.paths.profiles_dir.join("broken/meta.json"),
952            "{ not json",
953        );
954        write(
955            &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
956            r#"{"label":"x"}"#,
957        );
958
959        let profiles = load_profiles(&fixture.paths.profiles_dir);
960        let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
961        assert_eq!(labels, ["here", "there"]);
962    }
963
964    #[test]
965    fn the_active_account_resolves_to_its_label() {
966        let fixture = fixture();
967        let profiles = load_profiles(&fixture.paths.profiles_dir);
968        let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
969
970        assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
971        assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
972    }
973
974    #[test]
975    fn session_counts_come_from_the_accounts_own_folder() {
976        let fixture = fixture();
977        let profiles = load_profiles(&fixture.paths.profiles_dir);
978        let sessions_root = fixture.paths.sessions_root();
979
980        assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
981        assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
982    }
983
984    #[test]
985    fn planning_a_switch_changes_nothing_on_disk() {
986        let fixture = fixture();
987        let before = manifest(&fixture.paths.data_dir);
988
989        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
990
991        assert_eq!(plan.outgoing.as_deref(), Some("here"));
992        assert_eq!(plan.tokens.token_cache, "saved-a");
993        assert!(plan.restores_desktop_state);
994        assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
995        assert_eq!(manifest(&fixture.paths.data_dir), before);
996        assert!(!fixture.paths.backups_dir.exists());
997    }
998
999    #[test]
1000    fn planning_an_unknown_label_lists_the_known_ones() {
1001        let fixture = fixture();
1002        let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1003        let message = error.to_string();
1004        assert!(message.contains("here"), "{message}");
1005        assert!(message.contains("claude-acc"), "{message}");
1006    }
1007
1008    #[test]
1009    fn the_archive_skips_the_session_tree_by_default() {
1010        let fixture = fixture();
1011
1012        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1013        assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1014        assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1015        assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1016        assert!(plan.archive_members.contains(&"Cookies".to_string()));
1017        assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1018        assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1019        assert!(
1020            plan.archive_members
1021                .iter()
1022                .any(|member| member.ends_with("scheduled-tasks.json"))
1023        );
1024
1025        let full = plan_switch(
1026            &fixture.paths,
1027            "there",
1028            SwitchOpts {
1029                backup_sessions: true,
1030                ..SwitchOpts::default()
1031            },
1032        )
1033        .unwrap();
1034        assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1035    }
1036
1037    #[test]
1038    fn applying_a_switch_quits_then_archives_then_relaunches() {
1039        let fixture = fixture();
1040        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1041        let recorder = Recorder::default();
1042
1043        apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1044
1045        let steps = recorder.steps();
1046        assert_eq!(steps[0], "quit");
1047        assert!(steps[1].starts_with("archive "), "{steps:?}");
1048        assert_eq!(steps[2], "relaunch");
1049    }
1050
1051    #[test]
1052    fn applying_a_switch_swaps_the_credential_and_carries_history() {
1053        let fixture = fixture();
1054        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1055
1056        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1057
1058        let config: serde_json::Value =
1059            serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1060        assert_eq!(config["oauth:tokenCache"], "saved-a");
1061        assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1062        assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1063        assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1064
1065        // History followed the switch, and the browser state came back.
1066        assert!(
1067            fixture
1068                .paths
1069                .sessions_root()
1070                .join("uuid-there/org-2/local_x.json")
1071                .is_file()
1072        );
1073        assert_eq!(
1074            std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1075            "there-cookies"
1076        );
1077        assert_eq!(
1078            std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1079                .unwrap(),
1080            "there-ldb"
1081        );
1082        // The volatile remote-control bridge is cleared, never restored.
1083        assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1084    }
1085
1086    #[test]
1087    fn the_outgoing_account_is_snapshotted_before_the_swap() {
1088        let fixture = fixture();
1089        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1090
1091        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1092
1093        // `here` must have captured the credential that was live *before* the
1094        // swap. Reading it back as "saved-a" would mean we filed the incoming
1095        // account's tokens under the outgoing label.
1096        let here = fixture.paths.profile_dir("here");
1097        assert_eq!(
1098            std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1099            "live-a"
1100        );
1101        assert_eq!(
1102            std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1103            "live-b"
1104        );
1105        assert_eq!(
1106            std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1107            "live-cookies"
1108        );
1109        // meta.json belongs to claude-acc; we must never rewrite it.
1110        let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1111        assert!(meta.contains("here@example.com"), "{meta}");
1112    }
1113
1114    #[test]
1115    fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1116        let fixture = fixture();
1117        std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1118        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1119        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1120        assert!(error.to_string().contains("credential"), "{error}");
1121    }
1122
1123    #[test]
1124    fn planning_refuses_credentials_without_saved_browser_state() {
1125        let fixture = fixture();
1126        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1127
1128        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1129
1130        assert!(error.to_string().contains("browser state"), "{error}");
1131    }
1132
1133    #[test]
1134    fn a_failed_switch_requests_rollback_and_still_relaunches() {
1135        let fixture = fixture();
1136        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1137        write(&fixture.paths.config_json(), "{ not json");
1138        let recorder = Recorder::default();
1139
1140        let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1141
1142        assert!(error.to_string().contains("json"), "{error}");
1143        let steps = recorder.steps();
1144        assert_eq!(steps.first().map(String::as_str), Some("quit"));
1145        assert!(
1146            steps.iter().any(|step| step.starts_with("restore ")),
1147            "{steps:?}"
1148        );
1149        assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1150    }
1151
1152    #[test]
1153    fn restoring_browser_state_removes_files_the_target_does_not_have() {
1154        let fixture = fixture();
1155        write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1156        write(
1157            &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1158            "outgoing",
1159        );
1160        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1161
1162        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1163
1164        assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1165        assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1166    }
1167
1168    #[test]
1169    fn keeping_the_bridge_leaves_it_in_place() {
1170        let fixture = fixture();
1171        let plan = plan_switch(
1172            &fixture.paths,
1173            "there",
1174            SwitchOpts {
1175                keep_bridge: true,
1176                ..SwitchOpts::default()
1177            },
1178        )
1179        .unwrap();
1180
1181        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1182        assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1183    }
1184
1185    #[test]
1186    fn archives_are_pruned_oldest_first() {
1187        let dir = tempfile::TempDir::new().unwrap();
1188        for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1189            std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1190        }
1191        std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1192        let mut notes = Vec::new();
1193
1194        prune_archives(dir.path(), 2, &mut notes);
1195
1196        assert!(notes.is_empty(), "{notes:?}");
1197        assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1198        assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1199        assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1200        assert!(dir.path().join("unrelated.txt").exists());
1201    }
1202
1203    #[test]
1204    fn pruning_never_discards_the_only_rollback_archive() {
1205        let dir = tempfile::TempDir::new().unwrap();
1206        std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1207        let mut notes = Vec::new();
1208
1209        prune_archives(dir.path(), 1, &mut notes);
1210
1211        assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1212    }
1213}