pub mod app;
pub mod capture;
pub mod merge;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::AnthropicConfig;
use crate::error::{AppError, Result};
use app::AppControl;
use merge::{ScheduledMerge, SessionMerge};
const CONFIG_JSON: &str = "config.json";
const SESSIONS_DIR: &str = "claude-code-sessions";
const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
const BRIDGE_FILE: &str = "bridge-state.json";
const DEVICE_REGISTRY: &str = "ant-device-registry.json";
const TOKEN_CACHE: &str = "config-tokenCache";
const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
const DESKTOP_STATE: &str = "desktop-state";
const META_JSON: &str = "meta.json";
#[derive(Debug, Clone)]
pub struct Paths {
pub data_dir: PathBuf,
pub profiles_dir: PathBuf,
pub backups_dir: PathBuf,
}
impl Paths {
pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
Self {
data_dir,
profiles_dir,
backups_dir,
}
}
pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
let home = crate::cache::home_dir()?;
let profiles_dir = anthropic
.desktop_profiles_dir
.clone()
.unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
let backups_dir = profiles_dir
.parent()
.map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
.join("backups");
Ok(Self {
data_dir: home.join("Library/Application Support/Claude"),
profiles_dir,
backups_dir,
})
}
pub fn available(&self) -> bool {
self.data_dir.is_dir()
}
pub fn config_json(&self) -> PathBuf {
self.data_dir.join(CONFIG_JSON)
}
pub fn sessions_root(&self) -> PathBuf {
self.data_dir.join(SESSIONS_DIR)
}
pub fn profile_dir(&self, label: &str) -> PathBuf {
self.profiles_dir.join(label)
}
pub fn prelogin_dir(&self) -> PathBuf {
self.backups_dir.with_file_name("prelogin-backup")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileMeta {
pub label: String,
pub email: Option<String>,
pub account_uuid: String,
pub org_uuid: Option<String>,
pub has_credentials: bool,
pub has_desktop_state: bool,
}
#[derive(Debug, Deserialize)]
struct RawMeta {
email: Option<String>,
#[serde(rename = "accountUuid")]
account_uuid: Option<String>,
#[serde(rename = "orgUuid")]
org_uuid: Option<String>,
}
pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
let Ok(entries) = std::fs::read_dir(profiles_dir) else {
return Vec::new();
};
let mut profiles: Vec<ProfileMeta> = entries
.flatten()
.filter_map(|entry| {
let dir = entry.path();
let label = dir.file_name()?.to_str()?.to_string();
let raw: RawMeta =
serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
Some(ProfileMeta {
label,
email: raw.email.filter(|email| !email.is_empty()),
account_uuid,
org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
has_credentials: dir.join(TOKEN_CACHE).is_file()
&& dir.join(TOKEN_CACHE_V2).is_file(),
has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
})
})
.collect();
profiles.sort_by(|a, b| a.label.cmp(&b.label));
profiles
}
pub fn active_account_uuid(config_json: &Path) -> Option<String> {
let bytes = std::fs::read(config_json).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("lastKnownAccountUuid")?
.as_str()
.filter(|uuid| !uuid.is_empty())
.map(str::to_string)
}
pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
profiles
.iter()
.find(|profile| profile.account_uuid == account_uuid)
.map(|profile| profile.label.as_str())
}
pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
let Some(org) = &profile.org_uuid else {
return 0;
};
let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|extension| extension == "json")
})
.count()
}
#[derive(Debug, Clone)]
pub struct SwitchOpts {
pub keep_bridge: bool,
pub backup_sessions: bool,
pub keep_backups: usize,
}
impl Default for SwitchOpts {
fn default() -> Self {
Self {
keep_bridge: false,
backup_sessions: false,
keep_backups: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavedTokens {
pub token_cache: String,
pub token_cache_v2: String,
}
#[derive(Debug)]
pub struct SwitchPlan {
pub target: ProfileMeta,
pub outgoing: Option<String>,
pub sessions: SessionMerge,
pub scheduled: Option<ScheduledMerge>,
pub tokens: SavedTokens,
pub archive: PathBuf,
pub archive_members: Vec<String>,
pub restores_desktop_state: bool,
pub opts: SwitchOpts,
}
pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
let profiles = load_profiles(&paths.profiles_dir);
let target = profiles
.iter()
.find(|profile| profile.label == label)
.cloned()
.ok_or_else(|| {
let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
AppError::Credentials(format!(
"no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
Capture one with `claude-acc add {label}` \
(https://github.com/ohmaseclaro/claude-acc)",
paths.profiles_dir.display()
))
})?;
let sessions_root = paths.sessions_root();
let (sessions, scheduled) = match &target.org_uuid {
Some(org) => (
merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
Some(merge::plan_scheduled_merge(
&sessions_root,
&target.account_uuid,
org,
)?),
),
None => (SessionMerge::default(), None),
};
let outgoing = active_account_uuid(&paths.config_json())
.and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
let profile_dir = paths.profile_dir(label);
let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
AppError::Credentials(format!(
"no complete saved Desktop credential for {label:?}; capture or sign into that \
account before switching"
))
})?;
let token_cache_v2 =
std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
AppError::Credentials(format!(
"no complete saved Desktop credential for {label:?}; capture or sign into that \
account before switching"
))
})?;
if token_cache.is_empty() || token_cache_v2.is_empty() {
return Err(AppError::Credentials(format!(
"the saved Desktop credential for {label:?} is empty; capture it again before switching"
)));
}
let tokens = SavedTokens {
token_cache,
token_cache_v2,
};
if !profile_dir.join(DESKTOP_STATE).is_dir() {
return Err(AppError::Credentials(format!(
"no saved Desktop browser state for {label:?}; capture that account again before switching"
)));
}
let stamp = crate::claude_desktop::timestamp();
Ok(SwitchPlan {
archive: paths
.backups_dir
.join(format!("switch-{stamp}-{label}.tar.gz")),
archive_members: archive_members(paths, &opts),
restores_desktop_state: true,
target,
outgoing,
sessions,
scheduled,
tokens,
opts,
})
}
pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
let mut notes = Vec::new();
let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
app.quit()?;
let mut archived = false;
let mut result = (|| {
if !members.is_empty() {
app.archive(&plan.archive, &paths.data_dir, &members)?;
archived = true;
prune_archives(
&paths.backups_dir,
plan.opts.keep_backups.max(1),
&mut notes,
);
}
apply_switch_while_stopped(paths, plan, &mut notes)
})();
if result.is_err()
&& archived
&& let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
{
let original = result.unwrap_err();
result = Err(AppError::Other(format!(
"{original}; automatic Desktop rollback was incomplete: {rollback}"
)));
}
let relaunch = app.relaunch();
match (result, relaunch) {
(Ok(()), Ok(())) => Ok(notes),
(Err(error), Ok(())) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Err(error), Err(relaunch)) => Err(AppError::Other(format!(
"{error}; Claude Desktop also could not be relaunched: {relaunch}"
))),
}
}
fn apply_switch_while_stopped(
paths: &Paths,
plan: &SwitchPlan,
notes: &mut Vec<String>,
) -> Result<()> {
for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
copy_file(source, destination)?;
}
if let Some(scheduled) = &plan.scheduled {
crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
}
let live_config = paths.config_json();
let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
});
if let Some(label) = &outgoing {
snapshot_profile(paths, label, notes)?;
}
swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
restore_desktop_state(paths, &plan.target.label)?;
if !plan.opts.keep_bridge {
let bridge = paths.data_dir.join(BRIDGE_FILE);
if bridge.is_file()
&& let Err(error) = std::fs::remove_file(&bridge)
{
notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
}
}
restore_device_registry(paths, &plan.target.label, notes);
Ok(())
}
fn merge_history_into(
paths: &Paths,
account_uuid: &str,
org_uuid: &str,
notes: &mut Vec<String>,
) -> (usize, usize) {
let sessions_root = paths.sessions_root();
let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
let mut copied = 0;
for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
match copy_file(source, destination) {
Ok(()) => copied += 1,
Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
}
}
let routines = match merge::plan_scheduled_merge(&sessions_root, account_uuid, org_uuid) {
Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
Ok(()) => scheduled.added,
Err(error) => {
notes.push(format!("schedule seed skipped: {error}"));
0
}
},
Err(error) => {
notes.push(format!("schedule seed skipped: {error}"));
0
}
};
(copied, routines)
}
fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
let profile_dir = paths.profile_dir(label);
std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
restrict(&profile_dir, 0o700, notes);
let bytes =
std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
let live_tokens = SavedTokens {
token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
};
write_saved_tokens(&profile_dir, &live_tokens, notes)?;
let registry = paths.data_dir.join(DEVICE_REGISTRY);
if registry.is_file() {
let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
}
snapshot_desktop_state(paths, &profile_dir, notes)?;
Ok(())
}
fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.filter(|blob| !blob.is_empty())
.ok_or_else(|| {
AppError::Credentials(format!(
"the live Desktop login is missing {key}; refusing to overwrite its saved profile"
))
})
}
fn write_saved_tokens(
profile_dir: &Path,
tokens: &SavedTokens,
notes: &mut Vec<String>,
) -> Result<()> {
let token_path = profile_dir.join(TOKEN_CACHE);
let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
let original = read_optional_file(&token_path)?;
let original_v2 = read_optional_file(&token_v2_path)?;
let write_result = (|| {
crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
Ok(())
})();
if let Err(error) = write_result {
let mut rollback = Vec::new();
if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
rollback.push(failure.to_string());
}
if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
rollback.push(failure.to_string());
}
return if rollback.is_empty() {
Err(error)
} else {
Err(AppError::Other(format!(
"{error}; saved-token rollback was incomplete: {}",
rollback.join("; ")
)))
};
}
restrict(&token_path, 0o600, notes);
restrict(&token_v2_path, 0o600, notes);
Ok(())
}
fn snapshot_desktop_state(
paths: &Paths,
profile_dir: &Path,
notes: &mut Vec<String>,
) -> Result<()> {
let state_dir = profile_dir.join(DESKTOP_STATE);
let previous = profile_dir.join(".desktop-state.previous");
if previous.exists() && !state_dir.exists() {
std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
} else {
remove_if_present(&previous)?;
}
let staged = tempfile::Builder::new()
.prefix(".desktop-state.pending-")
.tempdir_in(profile_dir)
.map_err(|e| AppError::io_at(profile_dir, e))?;
restrict(staged.path(), 0o700, notes);
for name in COOKIE_FILES {
let source = paths.data_dir.join(name);
let destination = staged.path().join(name);
if source.is_file() {
copy_file(&source, &destination)?;
restrict(&destination, 0o600, notes);
}
}
for name in LEVELDB_DIRS {
let source = paths.data_dir.join(name);
let destination = staged.path().join(name);
if source.is_dir() {
copy_dir(&source, &destination)?;
}
}
let staged = staged.keep();
if state_dir.exists() {
std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
}
if let Err(error) = std::fs::rename(&staged, &state_dir) {
let restore = if previous.exists() {
std::fs::rename(&previous, &state_dir)
} else {
Ok(())
};
let _ = remove_if_present(&staged);
return match restore {
Ok(()) => Err(AppError::io_at(&staged, error)),
Err(rollback) => Err(AppError::Other(format!(
"could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
))),
};
}
if let Err(error) = remove_if_present(&previous) {
notes.push(format!(
"could not remove the previous Desktop-state snapshot: {error}"
));
}
Ok(())
}
fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
let bytes = merge::swap_config_tokens(
&existing,
&tokens.token_cache,
&tokens.token_cache_v2,
account_uuid,
)?;
crate::cache::atomic_write(config_json, &bytes)
}
fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
for name in COOKIE_FILES {
let source = state_dir.join(name);
let destination = paths.data_dir.join(name);
if source.is_file() {
copy_file(&source, &destination)?;
} else {
remove_if_present(&destination)?;
}
}
for name in LEVELDB_DIRS {
let source = state_dir.join(name);
let destination = paths.data_dir.join(name);
if source.is_dir() {
replace_dir(&source, &destination)?;
} else {
remove_if_present(&destination)?;
}
}
Ok(())
}
fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
let live = paths.data_dir.join(DEVICE_REGISTRY);
let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
return;
};
match merge::merge_device_registry(¤t, &saved) {
Ok(bytes) if bytes != current => {
if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
}
}
Ok(_) => {}
Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
}
}
fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
let mut members = Vec::new();
for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
if paths.data_dir.join(name).exists() {
members.push(name.to_string());
}
}
for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
if paths.data_dir.join(name).exists() {
members.push(name.to_string());
}
}
if opts.backup_sessions {
if paths.sessions_root().is_dir() {
members.push(SESSIONS_DIR.to_string());
}
return members;
}
let sessions_root = paths.sessions_root();
let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
return members;
};
let mut registries = Vec::new();
for account in accounts.flatten() {
let Ok(orgs) = std::fs::read_dir(account.path()) else {
continue;
};
for org in orgs.flatten() {
let path = org.path().join("scheduled-tasks.json");
if path.is_file()
&& let Ok(relative) = path.strip_prefix(&paths.data_dir)
{
registries.push(relative.display().to_string());
}
}
}
registries.sort();
members.extend(registries);
members
}
fn identity_members() -> Vec<&'static str> {
[CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
.into_iter()
.chain(COOKIE_FILES)
.chain(LEVELDB_DIRS)
.collect()
}
fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(backups_dir) else {
return;
};
let mut archives: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
})
.collect();
if archives.len() <= keep {
return;
}
archives.sort();
let doomed = archives.len() - keep;
for path in archives.into_iter().take(doomed) {
if let Err(error) = std::fs::remove_file(&path) {
notes.push(format!("could not prune {}: {error}", path.display()));
}
}
}
fn copy_file(source: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
}
std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
Ok(())
}
fn remove_if_present(path: &Path) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_dir() => {
std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
}
Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AppError::io_at(path, error)),
}
}
fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(AppError::io_at(path, error)),
}
}
fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
match original {
Some(bytes) => crate::cache::atomic_write(path, bytes),
None => remove_if_present(path),
}
}
fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
if destination.exists() {
std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
}
copy_dir(source, destination)
}
fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
for entry in entries.flatten() {
let child = entry.path();
let target = destination.join(entry.file_name());
if child.is_dir() {
copy_dir(&child, &target)?;
} else {
std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
}
}
Ok(())
}
#[cfg(unix)]
fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
use std::os::unix::fs::PermissionsExt;
if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
notes.push(format!("could not restrict {}: {error}", path.display()));
}
}
#[cfg(not(unix))]
fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
fn timestamp() -> String {
chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use app::Recorder;
struct Fixture {
_root: tempfile::TempDir,
paths: Paths,
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn fixture() -> Fixture {
let root = tempfile::TempDir::new().unwrap();
let data = root.path().join("data");
let profiles = root.path().join("profiles");
let backups = root.path().join("backups");
write(
&data.join(CONFIG_JSON),
r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
"oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
);
write(
&data.join(DEVICE_REGISTRY),
r#"{"uuid-here":{"deviceId":"d1"}}"#,
);
write(
&data.join(BRIDGE_FILE),
r#"{"remoteSessionId":"cse_stale"}"#,
);
write(&data.join("Cookies"), "live-cookies");
write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
write(
&data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
r#"{"lastActivityAt":500}"#,
);
write(
&data
.join(SESSIONS_DIR)
.join("uuid-here/org-1/scheduled-tasks.json"),
r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
);
write(
&profiles.join("here/meta.json"),
r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
);
write(
&profiles.join("there/meta.json"),
r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
);
write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
write(
&profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
"there-cookies",
);
write(
&profiles
.join("there")
.join(DESKTOP_STATE)
.join("Local Storage/leveldb/CURRENT"),
"there-ldb",
);
Fixture {
paths: Paths::at(data, profiles, backups),
_root: root,
}
}
fn manifest(root: &Path) -> Vec<(String, u64)> {
fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, root, out);
} else if let Ok(meta) = entry.metadata() {
let relative = path.strip_prefix(root).unwrap().display().to_string();
out.push((relative, meta.len()));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
#[test]
fn profiles_load_sorted_with_their_capture_state() {
let fixture = fixture();
let profiles = load_profiles(&fixture.paths.profiles_dir);
assert_eq!(profiles.len(), 2);
assert_eq!(profiles[0].label, "here");
assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
assert!(!profiles[0].has_credentials);
assert_eq!(profiles[1].label, "there");
assert!(profiles[1].has_credentials);
assert!(profiles[1].has_desktop_state);
}
#[test]
fn a_malformed_profile_is_skipped_not_fatal() {
let fixture = fixture();
write(
&fixture.paths.profiles_dir.join("broken/meta.json"),
"{ not json",
);
write(
&fixture.paths.profiles_dir.join("no-uuid/meta.json"),
r#"{"label":"x"}"#,
);
let profiles = load_profiles(&fixture.paths.profiles_dir);
let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
assert_eq!(labels, ["here", "there"]);
}
#[test]
fn the_active_account_resolves_to_its_label() {
let fixture = fixture();
let profiles = load_profiles(&fixture.paths.profiles_dir);
let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
}
#[test]
fn session_counts_come_from_the_accounts_own_folder() {
let fixture = fixture();
let profiles = load_profiles(&fixture.paths.profiles_dir);
let sessions_root = fixture.paths.sessions_root();
assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
}
#[test]
fn planning_a_switch_changes_nothing_on_disk() {
let fixture = fixture();
let before = manifest(&fixture.paths.data_dir);
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
assert_eq!(plan.outgoing.as_deref(), Some("here"));
assert_eq!(plan.tokens.token_cache, "saved-a");
assert!(plan.restores_desktop_state);
assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
assert_eq!(manifest(&fixture.paths.data_dir), before);
assert!(!fixture.paths.backups_dir.exists());
}
#[test]
fn planning_an_unknown_label_lists_the_known_ones() {
let fixture = fixture();
let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
let message = error.to_string();
assert!(message.contains("here"), "{message}");
assert!(message.contains("claude-acc"), "{message}");
}
#[test]
fn the_archive_skips_the_session_tree_by_default() {
let fixture = fixture();
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
assert!(plan.archive_members.contains(&"Cookies".to_string()));
assert!(plan.archive_members.contains(&"Local Storage".to_string()));
assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
assert!(
plan.archive_members
.iter()
.any(|member| member.ends_with("scheduled-tasks.json"))
);
let full = plan_switch(
&fixture.paths,
"there",
SwitchOpts {
backup_sessions: true,
..SwitchOpts::default()
},
)
.unwrap();
assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
}
#[test]
fn applying_a_switch_quits_then_archives_then_relaunches() {
let fixture = fixture();
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
let recorder = Recorder::default();
apply_switch(&fixture.paths, &plan, &recorder).unwrap();
let steps = recorder.steps();
assert_eq!(steps[0], "quit");
assert!(steps[1].starts_with("archive "), "{steps:?}");
assert_eq!(steps[2], "relaunch");
}
#[test]
fn applying_a_switch_swaps_the_credential_and_carries_history() {
let fixture = fixture();
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
let config: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
assert_eq!(config["oauth:tokenCache"], "saved-a");
assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
assert!(
fixture
.paths
.sessions_root()
.join("uuid-there/org-2/local_x.json")
.is_file()
);
assert_eq!(
std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
"there-cookies"
);
assert_eq!(
std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
.unwrap(),
"there-ldb"
);
assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
}
#[test]
fn the_outgoing_account_is_snapshotted_before_the_swap() {
let fixture = fixture();
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
let here = fixture.paths.profile_dir("here");
assert_eq!(
std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
"live-a"
);
assert_eq!(
std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
"live-b"
);
assert_eq!(
std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
"live-cookies"
);
let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
assert!(meta.contains("here@example.com"), "{meta}");
}
#[test]
fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
let fixture = fixture();
std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
assert!(error.to_string().contains("credential"), "{error}");
}
#[test]
fn planning_refuses_credentials_without_saved_browser_state() {
let fixture = fixture();
std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
assert!(error.to_string().contains("browser state"), "{error}");
}
#[test]
fn a_failed_switch_requests_rollback_and_still_relaunches() {
let fixture = fixture();
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
write(&fixture.paths.config_json(), "{ not json");
let recorder = Recorder::default();
let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
assert!(error.to_string().contains("json"), "{error}");
let steps = recorder.steps();
assert_eq!(steps.first().map(String::as_str), Some("quit"));
assert!(
steps.iter().any(|step| step.starts_with("restore ")),
"{steps:?}"
);
assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
}
#[test]
fn restoring_browser_state_removes_files_the_target_does_not_have() {
let fixture = fixture();
write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
write(
&fixture.paths.data_dir.join("Session Storage/CURRENT"),
"outgoing",
);
let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
assert!(!fixture.paths.data_dir.join("Session Storage").exists());
}
#[test]
fn keeping_the_bridge_leaves_it_in_place() {
let fixture = fixture();
let plan = plan_switch(
&fixture.paths,
"there",
SwitchOpts {
keep_bridge: true,
..SwitchOpts::default()
},
)
.unwrap();
apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
}
#[test]
fn archives_are_pruned_oldest_first() {
let dir = tempfile::TempDir::new().unwrap();
for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
}
std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
let mut notes = Vec::new();
prune_archives(dir.path(), 2, &mut notes);
assert!(notes.is_empty(), "{notes:?}");
assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
assert!(dir.path().join("unrelated.txt").exists());
}
#[test]
fn pruning_never_discards_the_only_rollback_archive() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
let mut notes = Vec::new();
prune_archives(dir.path(), 1, &mut notes);
assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
}
}