diff --git a/crates/wb-switch-core/Cargo.toml b/crates/wb-switch-core/Cargo.toml index 44f8263d..e39a0b51 100644 --- a/crates/wb-switch-core/Cargo.toml +++ b/crates/wb-switch-core/Cargo.toml @@ -7,7 +7,7 @@ description = "wb-switch 核心逻辑(账号/切换/会话/签到/更新), [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } uuid = { version = "1", features = ["v4"] } dirs = "5" diff --git a/crates/wb-switch-core/src/modules/align.rs b/crates/wb-switch-core/src/modules/align.rs new file mode 100644 index 00000000..3216337d --- /dev/null +++ b/crates/wb-switch-core/src/modules/align.rs @@ -0,0 +1,1014 @@ +//! 多账号数据对齐:把 multi_sync 的 L1/L3/L4/L5 能力内置进 switch。 +//! +//! 设计原则(对照 multi_sync.py,规则保持一致): +//! - 归属层(L3):「移动」而非「复制」,UPDATE 既有行的 owner/user_id,天然无双跑 +//! - 文件层(L4):settings.json claw.users 深合并,SECRET_KEYS 命中即跳过防串号; +//! storage/user-* 与画像缓存做「目标缺失才补」的单向对齐 +//! - 合并层(L5):my-files.json 全账号并集,禁止单向覆盖(防丢 favoriteIds 键) +//! - 备份层(L1):任何落盘前先备份 workbuddy.db + settings.json;storage 被 +//! 覆盖的文件在覆盖前另存 backups/storage///<相对路径>(唯一还原点) +//! - dry_run:只统计将要发生的变更,不落盘、不关 App、不写凭据 + +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::modules::automations; +use crate::modules::discover; +use crate::modules::config::{atomic_write, backup_dir, utc_iso}; +use crate::modules::session::{backup_workbuddy_db, open_db, workbuddy_db_path}; +use crate::modules::variant::WbVariant; + +/// 身份凭据关键词:命中即跳过,绝不跨账号复制(含渠道身份,防止串号)。 +const SECRET_KEYS: &[&str] = &[ + "bottoken", "accountid", "token", "credential", "secret", "master.key", + "appid", "appsecret", "corpid", "apikey", "accesstoken", "refreshtoken", + "password", "sessionkey", "privatekey", "signature", + "channelid", "userid", "openid", "unionid", "chatid", "groupid", "roomid", + "webhook", "robotid", "wxid", "imuid", "ownerid", +]; +const SECRET_FILENAMES: &[&str] = &["master.key", "credential", "credentials"]; +/// 这些文件必须走并集合并,禁止单向覆盖。 +const MERGE_ONLY_FILES: &[&str] = &["my-files.json"]; + +pub fn is_secret_key(key: &str) -> bool { + let k = key.to_lowercase().replace(['_', '-'], ""); + SECRET_KEYS.iter().any(|s| k.contains(&s.replace('.', ""))) +} + +fn is_secret_file(name: &str) -> bool { + let low = name.to_lowercase(); + SECRET_FILENAMES.iter().any(|s| low.contains(s)) +} + +fn is_merge_only(name: &str) -> bool { + MERGE_ONLY_FILES.iter().any(|s| name.eq_ignore_ascii_case(s)) +} + +/// 对齐选项(dry_run=true 时只统计不落盘)。 +/// +/// 命名:align_automations = 「带走定时任务」;align_files = 「同步设置与文件」; +/// slim_keep = 「清理旧会话」保留条数(0=关)。 +/// 「会话归属对齐」(原 align_sessions)已于 2026-09-17 全链删除——它正是「幽灵会话」 +/// 的成因之一(改别人账号的 sessions.user_id),归档见 +/// `.memory/archive/align-sessions-removed-2026-09-17.md`。 +/// 「同步项目列表」(原 sync_projects)已于 2026-09-17 弃用并清理,见 `projects_anchor`。 +#[derive(Debug, Clone, Default)] +pub struct AlignOptions { + pub align_automations: bool, + pub align_files: bool, + /// 会话瘦身:每项目保留最近 N 条存活会话(0 或缺省 = 关闭)。 + pub slim_keep: i64, + /// 预览模式:只统计变更,不落盘。 + pub dry_run: bool, +} + +impl AlignOptions { + /// 是否有任一对齐项开启(全关时整个对齐流程跳过)。 + fn any_enabled(&self) -> bool { + self.align_automations || self.align_files || self.slim_keep > 0 + } +} + +/// 从账号 JSON 里取 uid(空串表示账号缺 uid,调用方应跳过对齐)。 +pub fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// 备份 settings.json(P1 全量备份的一半;db 备份复用 session::backup_workbuddy_db)。 +pub fn backup_settings() -> Option { + let src = discover::settings_path(); + if !src.is_file() { + return None; + } + let root = backup_dir().join("settings").join(utc_iso()); + fs::create_dir_all(&root).ok()?; + let dst = root.join("settings.json"); + fs::copy(&src, &dst).ok()?; + Some(dst) +} + +/// L1:备份 workbuddy.db + settings.json,返回描述。 +fn backup_all() -> Value { + let db = backup_workbuddy_db(WbVariant::Cn, &backup_dir().join("db").join(utc_iso())) + .map(|p| p.to_string_lossy().to_string()); + let settings = backup_settings().map(|p| p.to_string_lossy().to_string()); + json!({ "db": db, "settings": settings }) +} + +/// 每类备份保留的最近份数(真实执行落盘后顺带轮转)。 +pub const BACKUP_KEEP: usize = 20; + +/// 目录名是否为 `utc_iso` 生成的时间戳格式(`%Y-%m-%dT%H-%M-%SZ`,20 字符)。 +fn is_backup_ts(name: &str) -> bool { + let b = name.as_bytes(); + name.len() == 20 + && name.ends_with('Z') + && b[4] == b'-' + && b[7] == b'-' + && b[10] == b'T' + && b[13] == b'-' + && b[16] == b'-' + && b.iter().enumerate().all(|(i, c)| { + matches!(i, 4 | 7 | 10 | 13 | 16 | 19) || c.is_ascii_digit() + }) +} + +/// 备份轮转:`root`(= backups/)下每个类别目录只保留最近 [`BACKUP_KEEP`] +/// 份时间戳目录,其余删除。非时间戳命名的条目一律不碰;返回删除的目录数。 +/// 时间戳字符串排序即时间序(`utc_iso` 格式天然可排序)。 +pub fn prune_backups_at(root: &Path, keep: usize) -> usize { + let mut removed = 0usize; + let Ok(categories) = fs::read_dir(root) else { + return 0; + }; + for cat in categories.flatten() { + let cat_path = cat.path(); + if !cat_path.is_dir() { + continue; + } + let Ok(entries) = fs::read_dir(&cat_path) else { + continue; + }; + let mut stamps: Vec = entries + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| is_backup_ts(n) && cat_path.join(n).is_dir()) + .collect(); + if stamps.len() <= keep { + continue; + } + stamps.sort(); + stamps.reverse(); + for stale in stamps.into_iter().skip(keep) { + if fs::remove_dir_all(cat_path.join(&stale)).is_ok() { + removed += 1; + } + } + } + removed +} + +/// 真实备份落盘后调用 [`prune_backups_at`],本次时间戳天然在保留范围内。 +fn prune_backups(keep: usize) -> usize { + prune_backups_at(&backup_dir(), keep) +} + +// --------------------------------------------------------------------------- +// 对齐源选择 +// --------------------------------------------------------------------------- + +fn latest_mtime_dir(p: &Path) -> i64 { + let mut latest = 0i64; + if let Ok(entries) = fs::read_dir(p) { + for e in entries.flatten() { + let path = e.path(); + if path.is_dir() { + latest = latest.max(latest_mtime_dir(&path)); + } else if let Ok(meta) = e.metadata() { + if let Ok(m) = meta.modified() { + if let Ok(d) = m.duration_since(std::time::UNIX_EPOCH) { + latest = latest.max(d.as_millis() as i64); + } + } + } + } + } + latest +} + +/// 缺省对齐源 = 除目标外最近活跃的账号(按 user--personal 最新 mtime)。 +pub fn pick_source(target: &str) -> Option { + let mut best: Option<(i64, String)> = None; + for uid in discover::discover_accounts() { + if uid == target { + continue; + } + let dir = discover::storage_dir().join(format!("user-{uid}-personal")); + let m = if dir.is_dir() { latest_mtime_dir(&dir) } else { 0 }; + if best.as_ref().is_none_or(|(bm, _)| m > *bm) { + best = Some((m, uid)); + } + } + best.map(|(_, uid)| uid) +} + +// --------------------------------------------------------------------------- +// L3 归属层(DB)→ 已拆到 `automations.rs`(2026-09-19,上游 PR 单独走一个) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// L4 文件层 +// --------------------------------------------------------------------------- + +/// settings.json claw.users 深合并(源补齐到目标),SECRET_KEYS 命中的键整棵跳过。 +fn align_settings_claw_users(src: &str, dst: &str, dry_run: bool) -> Value { + align_settings_claw_users_at(&discover::settings_path(), src, dst, dry_run) +} + +/// 注入版:`settings_file` = settings.json 路径(测试传临时文件)。 +fn align_settings_claw_users_at(settings_file: &Path, src: &str, dst: &str, dry_run: bool) -> Value { + let mut changed: Vec = Vec::new(); + let mut skipped = false; + 'outer: { + let Ok(text) = fs::read_to_string(settings_file) else { + skipped = true; + break 'outer; + }; + let Ok(mut data) = serde_json::from_str::(&text) else { + skipped = true; + break 'outer; + }; + let Some(users) = data + .get_mut("claw") + .and_then(|c| c.get_mut("users")) + .and_then(|u| u.as_object_mut()) + else { + skipped = true; + break 'outer; + }; + let Some(src_node) = users.get(src).cloned() else { + skipped = true; + break 'outer; + }; + let Some(dst_node) = users.get_mut(dst) else { + skipped = true; + break 'outer; + }; + if let (Value::Object(s), Value::Object(d)) = (&src_node, dst_node) { + let s = s.clone(); + merge_walk(&s, d, "", &mut changed); + } + if !changed.is_empty() && !dry_run { + if let Ok(out) = serde_json::to_string_pretty(&data) { + if let Err(e) = atomic_write(settings_file, &out) { + return json!({ "changed": 0, "skipped": true, "error": e.to_string() }); + } + } + } + } + json!({ + "changed": changed.len(), + "keys": changed.iter().take(8).collect::>(), + "skipped": skipped, + }) +} + +/// 深合并:src 的键补齐/更新到 dst(secret 键跳过),记录变更路径。 +fn merge_walk( + src: &Map, + dst: &mut Map, + path: &str, + changed: &mut Vec, +) { + for (k, v) in src { + if is_secret_key(k) { + continue; + } + let full = if path.is_empty() { k.clone() } else { format!("{path}.{k}") }; + match dst.get_mut(k) { + None => { + changed.push(format!("+{full}")); + dst.insert(k.clone(), v.clone()); + } + Some(Value::Object(d2)) if v.is_object() => { + if let Value::Object(s2) = v { + merge_walk(s2, d2, &full, changed); + } + } + Some(cur) if cur != v => { + changed.push(format!("~{full}")); + *cur = v.clone(); + } + _ => {} + } + } +} + +/// 递归目录对齐:目标缺失或内容不同才复制;跳过 .bak / 凭据文件;my-files.json 延迟到 L5。 +fn align_storage_dir( + src: &Path, + dst: &Path, + backup_root: &Path, + dry_run: bool, + copied: &mut usize, + skipped: &mut usize, + deferred: &mut usize, + samples: &mut Vec, +) { + if !src.is_dir() { + return; + } + if !dst.exists() && !dry_run { + let _ = fs::create_dir_all(dst); + } + let Ok(entries) = fs::read_dir(src) else { + return; + }; + for entry in entries.flatten() { + let sp = entry.path(); + let dp = dst.join(entry.file_name()); + if sp.is_dir() { + align_storage_dir(&sp, &dp, backup_root, dry_run, copied, skipped, deferred, samples); + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".bak") || is_secret_file(&name) { + *skipped += 1; + continue; + } + if is_merge_only(&name) { + *deferred += 1; + continue; + } + // 先比 size:长度不同必不同(免读大文件进内存);同长才读内容精确比对 + let need = match (fs::metadata(&sp).ok().map(|m| m.len()), fs::metadata(&dp).ok().map(|m| m.len())) { + (Some(a), Some(b)) if a == b => match (fs::read(&sp), fs::read(&dp)) { + (Ok(x), Ok(y)) => x != y, + (Ok(_), Err(_)) => true, + _ => false, + }, + (Some(_), None) => true, // 目标缺失/不可读 → 复制 + (Some(_), Some(_)) => true, // 长度不同 → 复制 + _ => false, + }; + if !need { + continue; + } + *copied += 1; + if samples.len() < 6 { + samples.push(name); + } + if !dry_run { + if let Some(parent) = dp.parent() { + let _ = fs::create_dir_all(parent); + } + // 覆盖前单文件备份(镜像 dst 内相对结构):/storage///。 + // L1 备份只保 db + settings.json,这里是 storage 被覆盖文件的唯一还原点。 + // 备份失败 = 无还原点 ⇒ 宁不同步也不裸覆盖(与 ui_theme「宁可不写」同哲学)。 + let mut backed_up = true; + if dp.is_file() { + let rel = dp.strip_prefix(dst).unwrap_or(dp.as_path()); + let target = backup_root + .join("storage") + .join(utc_iso()) + .join(dst.file_name().unwrap_or_default()) + .join(rel); + let bparent_ok = target + .parent() + .map(|p| fs::create_dir_all(p).is_ok()) + .unwrap_or(true); + backed_up = bparent_ok && fs::copy(&dp, &target).is_ok(); + } + if !backed_up { + *copied -= 1; + *skipped += 1; + continue; + } + if fs::copy(&sp, &dp).is_err() { + *copied -= 1; + *skipped += 1; + } + } + } +} + +/// 云端画像缓存对齐:memory/_memory.md 源 -> 目标。 +/// 注入版:`memory_root` = memory 目录、`backup_root` = backups 根(测试传临时目录)。 +fn align_memory_profile_at( + memory_root: &Path, + backup_root: &Path, + src: &str, + dst: &str, + dry_run: bool, +) -> Value { + let sa = memory_root.join(format!("{src}_memory.md")); + let sb = memory_root.join(format!("{dst}_memory.md")); + if !sa.is_file() { + return json!({ "changed": false, "skipped": true }); + } + let same = match (fs::read(&sa), fs::read(&sb)) { + (Ok(a), Ok(b)) => a == b, + (Ok(_), Err(_)) => false, + _ => true, + }; + if same { + return json!({ "changed": false }); + } + let bytes = fs::metadata(&sa).map(|m| m.len()).unwrap_or(0); + if !dry_run { + if sb.is_file() { + let root = backup_root.join("memory").join(utc_iso()); + let bparent_ok = fs::create_dir_all(&root).is_ok(); + // 备份失败 = 无还原点 ⇒ 宁不同步也不裸覆盖 + if !bparent_ok || fs::copy(&sb, root.join(format!("{dst}_memory.md"))).is_err() { + return json!({ "changed": false, "skipped": true }); + } + } + let _ = fs::copy(&sa, &sb); + } + json!({ "changed": true, "bytes": bytes }) +} + +/// L5:my-files.json 全账号并集合并(列表取并集去重,其余键首个非空值生效)。 +/// 注入版:`storage_root` = user--personal 的父目录、`backup_root` = backups 根。 +fn merge_my_files_all_at( + storage_root: &Path, + backup_root: &Path, + accounts: &[String], + dry_run: bool, +) -> Value { + let mut files: Vec = Vec::new(); + for uid in accounts { + let scoped = storage_root + .join(format!("user-{uid}-personal")) + .join("scoped"); + let Ok(entries) = fs::read_dir(&scoped) else { + continue; + }; + for scope in entries.flatten() { + let f = scope.path().join("my-files.json"); + if f.is_file() { + files.push(f); + } + } + } + if files.len() < 2 { + return json!({ "files": files.len(), "changed": 0 }); + } + + let mut merged = Map::new(); + let mut parsed: Vec<(PathBuf, Map)> = Vec::new(); + for f in &files { + let parsed_one = fs::read_to_string(f) + .map_err(|e| e.to_string()) + .and_then(|t| serde_json::from_str::(&t).map_err(|e| e.to_string())); + if let Ok(Value::Object(m)) = parsed_one { + parsed.push((f.clone(), m)); + } + } + for (_, m) in &parsed { + for (k, v) in m { + match merged.get_mut(k) { + None => { + merged.insert(k.clone(), v.clone()); + } + Some(Value::Array(dst_list)) if v.is_array() => { + if let Value::Array(src_list) = v { + let mut all: Vec = dst_list + .iter() + .chain(src_list.iter()) + .map(|x| x.to_string()) + .collect(); + all.sort(); + all.dedup(); + *dst_list = all + .iter() + .map(|s| serde_json::from_str(s).unwrap_or(Value::String(s.clone()))) + .collect(); + } + } + _ => {} + } + } + } + + let mut changed = 0usize; + if !dry_run { + for (f, m) in &parsed { + if *m != merged { + if let Ok(out) = serde_json::to_string_pretty(&Value::Object(merged.clone())) { + let root = backup_root.join("my-files").join(utc_iso()); + let _ = fs::create_dir_all(&root); + let _ = fs::copy(f, root.join("my-files.json")); + if atomic_write(f, &out).is_ok() { + changed += 1; + } + } + } + } + } else { + changed = parsed.iter().filter(|(_, m)| *m != merged).count(); + } + json!({ "files": files.len(), "changed": changed, "keys": merged.len() }) +} + +// --------------------------------------------------------------------------- +// 总入口 +// --------------------------------------------------------------------------- + +/// 多账号对齐总入口(L1 备份 + L3 归属 + L4 文件 + L5 合并)。 +/// +/// dry_run=true 时只统计计划变更;任一开关都未打开时返回 noop。 +pub fn align_data(target_uid: &str, source_uid: Option<&str>, opts: &AlignOptions) -> Value { + let mut report = json!({ + "targetUid": target_uid, + "dryRun": opts.dry_run, + "automations": { "updated": 0, "outbox": 0 }, + "settings": { + "claw": { "changed": 0, "skipped": true }, + "storage": { "copied": 0, "skipped": 0, "deferred": 0, "samples": [] }, + "memory": { "changed": false }, + "myFiles": { "files": 0, "changed": 0, "keys": 0 }, + }, + }); + let any = opts.align_automations || opts.align_files; + if !any || target_uid.is_empty() { + report["noop"] = json!(true); + return report; + } + + let db = workbuddy_db_path(WbVariant::Cn); + if !db.is_file() { + report["error"] = json!("workbuddy.db 不存在"); + return report; + } + + if !opts.dry_run { + report["backup"] = backup_all(); + // 备份轮转:每类只保留最近 BACKUP_KEEP 份(含本次),防 backups 无限堆积 + let pruned = prune_backups(BACKUP_KEEP); + if pruned > 0 { + report["backup"]["pruned"] = json!(pruned); + } + } + + if opts.align_automations { + if let Ok((a, o)) = automations::align_automations_owner_in_db(&db, target_uid, opts.dry_run) { + report["automations"] = json!({ "updated": a, "outbox": o }); + } + } + + if opts.align_files { + let storage = discover::storage_dir(); + let backups = backup_dir(); + let source = source_uid + .map(|s| s.to_string()) + .or_else(|| pick_source(target_uid)); + report["settings"]["sourceUid"] = json!(source); + if let Some(src) = source { + if src != target_uid { + report["settings"]["claw"] = + align_settings_claw_users(&src, target_uid, opts.dry_run); + + let mut copied = 0usize; + let mut skipped = 0usize; + let mut deferred = 0usize; + let mut samples: Vec = Vec::new(); + for suffix in ["", "-personal"] { + let s = storage.join(format!("user-{src}{suffix}")); + let d = storage.join(format!("user-{target_uid}{suffix}")); + align_storage_dir( + &s, &d, &backups, opts.dry_run, &mut copied, &mut skipped, &mut deferred, + &mut samples, + ); + } + report["settings"]["storage"] = json!({ + "copied": copied, "skipped": skipped, "deferred": deferred, "samples": samples, + }); + + report["settings"]["memory"] = + align_memory_profile_at(&discover::memory_dir(), &backups, &src, target_uid, opts.dry_run); + } + } + let accounts = discover::discover_accounts(); + report["settings"]["myFiles"] = + merge_my_files_all_at(&storage, &backups, &accounts, opts.dry_run); + } + + report +} + +/// 会话瘦身,追加进报告(真实执行与预览共用)。 +/// +/// (原「项目侧栏同步」已于 2026-09-17 弃用,本函数只剩瘦身。) +/// `dry_run` 只从 `opts.dry_run` 读——不再另收一个可能与之矛盾的独立参数。 +/// `protected_ids` = 本次手动勾选复制体,瘦身时跳过(不删、也不占保留名额)。 +/// `keep_sids` = 统一保留名单(autoLink 计算的 keepTargetSids);传入时瘦身按 +/// 「名单外全删」判定,`slim_keep` 的每项目 keep 逻辑退位(见 projects_anchor)。 +fn append_project_and_slim( + report: &mut Value, + target_uid: &str, + opts: &AlignOptions, + protected_ids: &[String], + keep_sids: &[String], +) { + // 会话瘦身(slim_keep)随「清理旧会话」PR 单独发,本版本该字段恒为 0。 + // 参数签名保持不动(调用点零改动),此处显式消化掉以免 unused 警告。 + let _ = (report, target_uid, opts, protected_ids, keep_sids); +} + +/// 只读取一批会话的 cwd(预览阶段估算复制体落点用,查不到就忽略)。 +fn session_cwds(ids: &[String]) -> Vec { + let mut out = Vec::new(); + let Some(conn) = open_db(&workbuddy_db_path(WbVariant::Cn), true) else { + return out; + }; + for id in ids { + if let Ok(Some(c)) = conn.query_row( + "SELECT cwd FROM sessions WHERE id = ?1", + [id.as_str()], + |r| r.get::<_, Option>(0), + ) { + if !c.is_empty() { + out.push(c); + } + } + } + out +} + +/// 预览专用:量化「本次将复制的会话」对瘦身的抵消。 +/// +/// 预览不执行复制,拿不到复制体的新 id,无法把它们放进保护名单,所以 `planned` +/// 偏大。这里按 cwd 把复制体对齐到瘦身项目上,给出「将复制几条 / 命中几个瘦身项目」, +/// 替代原来「删除数可能更少」的模糊提示。 +fn annotate_copy_impact(report: &mut Value, copy_session_ids: &[String]) { + if copy_session_ids.is_empty() { + return; + } + let cwds = session_cwds(copy_session_ids); + let slimmed: BTreeSet<&str> = report["slim"]["groups"] + .as_array() + .map(|gs| gs.iter().filter_map(|g| g["cwd"].as_str()).collect()) + .unwrap_or_default(); + let (hit_count, hit_projects) = copy_hits(&slimmed, &cwds); + report["slim"]["copyPlanned"] = json!({ + "total": copy_session_ids.len(), + "hitCount": hit_count, + "hitProjects": hit_projects, + }); +} + +/// 纯函数:复制体 cwd 与瘦身项目的交集 → (命中会话条数, 命中项目数)。 +fn copy_hits(slimmed: &BTreeSet<&str>, cwds: &[String]) -> (usize, usize) { + let mut projects: BTreeSet<&str> = BTreeSet::new(); + let mut count = 0usize; + for c in cwds { + if slimmed.contains(c.as_str()) { + count += 1; + projects.insert(c.as_str()); + } + } + (count, projects.len()) +} + +/// 切号对齐的统一入口:预览(`opts.dry_run=true`)与真实执行(false)共用。 +/// +/// 两者唯一分歧在主题:真实执行调用 `sync_theme_for_switch`(写 leveldb + 云端, +/// 预览绝不能调),预览只给 `planned` 占位。主题跟随受 `align_files` +/// (「同步设置与文件」)管辖:关闭时不执行、报告也不写 `settings.theme` +/// ——前端按字段缺失隐藏该行,预览与执行天然一致。 +/// +/// `protected_ids` = 真实执行时本次手动复制出来的会话 id(预览为空)。 +/// `copy_session_ids` = 预览时将要复制的源会话 id(真实执行为空,已计入 protected_ids)。 +/// `keep_sids` = 统一保留名单(autoLink 计算的 keepTargetSids;预览时为 dry_run 报告的同名字段)。 +fn run_switch_sync( + target_acc: &Value, + opts: &AlignOptions, + protected_ids: &[String], + copy_session_ids: &[String], + keep_sids: &[String], +) -> Option { + let target_uid = account_uid(target_acc); + if target_uid.is_empty() || !opts.any_enabled() { + return None; + } + let source_uid = crate::modules::session::current_user_uid(WbVariant::Cn); + let mut report = align_data(&target_uid, source_uid.as_deref(), opts); + + append_project_and_slim(&mut report, &target_uid, opts, protected_ids, keep_sids); + + if opts.dry_run { + report["dryRun"] = json!(true); + annotate_copy_impact(&mut report, copy_session_ids); + if opts.align_files { + report["settings"]["theme"] = json!({ "planned": true }); + } + return Some(report); + } + + // 主题跟随账号(受「同步设置与文件」管辖):本地继承(leveldb 注入,启动瞬间 + // 生效)+ 云端继承(把 target 的云端外观选择改成 source 的值,根治回跳)。 + if opts.align_files { + let source_acc = source_uid.as_deref().and_then(|uid| { + crate::modules::account::load_accounts() + .into_iter() + .find(|a| account_uid(a) == uid) + }); + let source_token = source_acc + .as_ref() + .and_then(|a| crate::modules::account::get_str(a, "access_token")); + let target_token = crate::modules::account::get_str(target_acc, "access_token"); + report["settings"]["theme"] = crate::modules::ui_theme::sync_theme_for_switch( + source_uid.as_deref(), + &target_uid, + source_token.as_deref(), + target_token.as_deref(), + ); + } + Some(report) +} + +/// 切号预览(dry_run):统计「对齐 + 项目侧栏 + 会话瘦身」将发生的变更。 +/// +/// 与 [`post_close_sync`] 的区别:**不写库、不写快照、不写云端**。 +/// `copy_session_ids` = 本次勾选要复制的会话(预览不真复制,只用于估算瘦身抵消)。 +/// `keep_sids` = 统一保留名单(预览链路取自 autoLink dry_run 报告)。 +pub fn preview_sync( + target_acc: &Value, + opts: &AlignOptions, + copy_session_ids: &[String], + keep_sids: &[String], +) -> Option { + let mut dry_opts = opts.clone(); + dry_opts.dry_run = true; + run_switch_sync(target_acc, &dry_opts, &[], copy_session_ids, keep_sids) +} + +/// 切号「关进程之后」的数据后置同步:归属/文件对齐 + 界面主题跟随。 +/// +/// 从 `switch.rs` 内联块下沉到这里(本地专属文件),使 switch.rs 的本地改动保持最小。 +/// 返回 `align_report`;主题跟随(原独立 theme_report)已并入报告的 `settings.theme` +/// 子项——主题即账号外观设置,归入「设置同步」呈现(2026-09-12 定稿)。 +/// `protected_ids` = 本次切号刚手动复制到目标账号的会话 id:瘦身时必须跳过, +/// 否则「复制多条同项目会话 + 瘦身」会让复制体互相挤掉,用户只看到 1 条。 +/// `keep_sids` = 统一保留名单(autoLink 计算的 keepTargetSids)。 +pub fn post_close_sync( + target_acc: &Value, + opts: &AlignOptions, + protected_ids: &[String], + keep_sids: &[String], +) -> Option { + let mut full_opts = opts.clone(); + full_opts.dry_run = false; + run_switch_sync(target_acc, &full_opts, protected_ids, &[], keep_sids) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// 临时目录句柄:Drop 时整体清理。 + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + let p = std::env::temp_dir().join(format!("wb-align-{tag}-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).ok(); + } + } + + /// L4 settings:源补齐目标、secret 键跳过、无变化不写。 + #[test] + fn align_settings_claw_fills_target_and_skips_secrets() { + let t = TmpDir::new("claw"); + let f = t.0.join("settings.json"); + fs::write( + &f, + r#"{"claw":{"users":{ + "src":{"a":"1","nested":{"b":"2"},"apikey":"SRC-SECRET"}, + "dst":{"a":"OLD","apikey":"DST-SECRET"} + }}}"#, + ) + .unwrap(); + let r = align_settings_claw_users_at(&f, "src", "dst", false); + assert_eq!(r["changed"], 2, "a 覆盖 + nested 补齐;apikey 跳过"); + let out: Value = serde_json::from_str(&fs::read_to_string(&f).unwrap()).unwrap(); + let dst = &out["claw"]["users"]["dst"]; + assert_eq!(dst["a"], "1"); + assert_eq!(dst["nested"]["b"], "2"); + assert_eq!(dst["apikey"], "DST-SECRET", "secret 键绝不被源覆盖"); + // 无变化:再跑一次 changed=0,且不重写文件 + let r2 = align_settings_claw_users_at(&f, "src", "dst", false); + assert_eq!(r2["changed"], 0); + } + + /// L4 storage:缺失复制、内容不同覆盖(带备份)、.bak 跳过、备份失败不覆盖。 + #[test] + fn align_storage_dir_copies_with_backup_and_skips_bak() { + let t = TmpDir::new("storage"); + let src = t.0.join("user-src"); + let dst = t.0.join("user-dst"); + fs::create_dir_all(src.join("sub")).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("new.txt"), b"new").unwrap(); + fs::write(src.join("same.txt"), b"same").unwrap(); + fs::write(dst.join("same.txt"), b"same").unwrap(); + fs::write(src.join("old.txt"), b"newer").unwrap(); + fs::write(dst.join("old.txt"), b"older").unwrap(); + fs::write(src.join("x.bak"), b"bak").unwrap(); + fs::write(src.join("credential"), b"secret").unwrap(); + + let backups = t.0.join("backups"); + let mut copied = 0; + let mut skipped = 0; + let mut deferred = 0; + let mut samples = Vec::new(); + align_storage_dir( + &src, + &dst, + &backups, + false, + &mut copied, + &mut skipped, + &mut deferred, + &mut samples, + ); + assert_eq!(copied, 2, "new.txt 补齐 + old.txt 覆盖;same.txt 相同不复制"); + assert_eq!(skipped, 2, ".bak + credential 跳过"); + assert_eq!(fs::read(dst.join("new.txt")).unwrap(), b"new"); + assert_eq!(fs::read(dst.join("old.txt")).unwrap(), b"newer"); + // 覆盖前备份生成了 old.txt 的还原点(结构:/storage///old.txt) + let ts_dir = backups.join("storage"); + let mut found_backup = false; + for ts in fs::read_dir(&ts_dir).unwrap().flatten() { + let p = ts.path().join("user-dst").join("old.txt"); + if p.is_file() && fs::read(&p).unwrap() == b"older" { + found_backup = true; + } + } + assert!(found_backup, "被覆盖文件必须有还原点"); + + // 备份失败(backup_root 指向一个文件路径 → create_dir_all 失败)→ 宁可不覆盖 + let fail_root = t.0.join("not-a-dir"); + fs::write(&fail_root, b"x").unwrap(); + fs::write(dst.join("old.txt"), b"older").unwrap(); + let mut c2 = 0; + let mut s2 = 0; + let mut d2 = 0; + let mut sam2 = Vec::new(); + align_storage_dir( + &src, + &dst, + &fail_root, + false, + &mut c2, + &mut s2, + &mut d2, + &mut sam2, + ); + assert_eq!(fs::read(dst.join("old.txt")).unwrap(), b"older", "备份失败必须放弃覆盖"); + let _ = (c2, s2, d2, sam2); + } + + /// 画像缓存:源→目标覆盖 + 备份生成;目标缺失视为不同。 + #[test] + fn align_memory_profile_copies_with_backup() { + let t = TmpDir::new("memory"); + let mem = t.0.join("memory"); + fs::create_dir_all(&mem).unwrap(); + fs::write(mem.join("src_memory.md"), b"v2").unwrap(); + fs::write(mem.join("dst_memory.md"), b"v1").unwrap(); + let backups = t.0.join("backups"); + + let r = align_memory_profile_at(&mem, &backups, "src", "dst", false); + assert_eq!(r["changed"], true); + assert_eq!(fs::read(mem.join("dst_memory.md")).unwrap(), b"v2"); + // 还原点含旧值 + let mut found = false; + for ts in fs::read_dir(backups.join("memory")).unwrap().flatten() { + let p = ts.path().join("dst_memory.md"); + if p.is_file() && fs::read(&p).unwrap() == b"v1" { + found = true; + } + } + assert!(found, "画像覆盖前必须有备份"); + // 源缺失 → skipped + let r2 = align_memory_profile_at(&mem, &backups, "ghost", "dst", false); + assert_eq!(r2["skipped"], true); + } + + /// L5 my-files:数组并集去重写回两个账号。 + #[test] + fn merge_my_files_unions_lists_across_accounts() { + let t = TmpDir::new("myfiles"); + let a = t.0.join("user-a-personal").join("scoped").join("s1"); + let b = t.0.join("user-b-personal").join("scoped").join("s2"); + fs::create_dir_all(&a).unwrap(); + fs::create_dir_all(&b).unwrap(); + fs::write( + a.join("my-files.json"), + r#"{"favorites":["x","y"],"name":"A"}"#, + ) + .unwrap(); + fs::write( + b.join("my-files.json"), + r#"{"favorites":["y","z"],"name":"B"}"#, + ) + .unwrap(); + + let r = merge_my_files_all_at(&t.0, &t.0.join("backups"), &["a".into(), "b".into()], false); + assert_eq!(r["changed"], 2, "两份都与并集不同"); + for p in [a.join("my-files.json"), b.join("my-files.json")] { + let v: Value = serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap(); + assert_eq!(v["favorites"], json!(["x", "y", "z"]), "并集去重"); + } + } + + /// 轮转:每类只留最近 N 份时间戳目录;非时间戳条目不碰。 + #[test] + fn prune_backups_keeps_latest_and_skips_non_ts_entries() { let root = std::env::temp_dir().join(format!("wb-align-prune-{}", uuid::Uuid::new_v4())); + let db = root.join("db"); + fs::create_dir_all(&db).unwrap(); + // 25 个时间戳目录(字典序 = 时间序,全部唯一) + for i in 0..25 { + let dir = db.join(format!("2026-08-{:02}T10-00-{:02}Z", 1 + i / 24, i % 60)); + fs::create_dir_all(&dir).unwrap(); + } + // 非时间戳条目 + 文件条目:都不能被碰 + let custom = db.join("keep-me"); + fs::create_dir_all(&custom).unwrap(); + fs::write(db.join("a-file.txt"), b"x").unwrap(); + // 第二个类别目录:不足 keep 份,应整体不动 + let settings = root.join("settings"); + fs::create_dir_all(settings.join("2026-09-01T00-00-00Z")).unwrap(); + + let removed = prune_backups_at(&root, 20); + assert_eq!(removed, 5, "25 份删到 20 份"); + assert_eq!( + fs::read_dir(&db).unwrap().flatten().count(), + 22, // 20 份时间戳 + keep-me + a-file.txt + "非时间戳目录与文件必须保留" + ); + assert!(custom.is_dir()); + assert!(settings.join("2026-09-01T00-00-00Z").is_dir()); + fs::remove_dir_all(&root).ok(); + } + + /// 五个开关任一开启即视为需要对齐(全关时整个流程跳过)。 + #[test] + fn any_enabled_covers_all_five_switches() { + let mut o = AlignOptions::default(); + assert!(!o.any_enabled(), "全关时应跳过对齐"); + o.align_automations = true; + assert!(o.any_enabled()); + o = AlignOptions::default(); + o.align_files = true; + assert!(o.any_enabled()); + o = AlignOptions::default(); + o.slim_keep = 1; + assert!(o.any_enabled(), "slim_keep>0 也算开启"); + o.slim_keep = 0; + assert!(!o.any_enabled()); + } + + /// 复制体与瘦身项目的交集:条数按会话计,项目数按 cwd 去重。 + #[test] + fn copy_hits_counts_sessions_and_distinct_projects() { + let slimmed: BTreeSet<&str> = ["/p/a", "/p/b"].into_iter().collect(); + let cwds = vec![ + "/p/a".to_string(), + "/p/a".to_string(), + "/p/c".to_string(), + ]; + assert_eq!(copy_hits(&slimmed, &cwds), (2, 1)); + + let none: Vec = vec![]; + assert_eq!(copy_hits(&slimmed, &none), (0, 0)); + + let both = vec!["/p/a".to_string(), "/p/b".to_string()]; + assert_eq!(copy_hits(&slimmed, &both), (2, 2)); + } + + /// 预览拿到「本次会搬过去的会话」就必须写 `copyPlanned`——共享(autoLink)路径 + /// 之前没把 planned sid 传进来,导致字段缺失、前端退化成没有条数的兜底提示。 + /// 空名单时**不写**字段,让前端据此判断「这次没有搬运」。 + #[test] + fn annotate_copy_impact_writes_total_and_stays_absent_when_empty() { + let mut report = json!({ "slim": { "groups": [] } }); + annotate_copy_impact(&mut report, &["s1".to_string(), "s2".to_string()]); + assert_eq!(report["slim"]["copyPlanned"]["total"], 2); + + let mut none = json!({ "slim": { "groups": [] } }); + annotate_copy_impact(&mut none, &[]); + assert!( + none["slim"].get("copyPlanned").is_none(), + "没有搬运时不该写 copyPlanned" + ); + } + #[test] + fn secret_key_rules_match_multi_sync() { + assert!(is_secret_key("botToken")); + assert!(is_secret_key("channel_id")); + assert!(is_secret_key("ownerId")); + // multi_sync 原逻辑:输入键名不剥点、规则键才剥点,故 "master.key" 键名匹配不上 + // (文件名场景由 SECRET_FILENAMES 兜底)——保持口径一致 + assert!(!is_secret_key("master.key")); + assert!(!is_secret_key("nickname")); + assert!(!is_secret_key("themeColor")); + assert!(is_secret_file("master.key")); + assert!(!is_secret_file("expert.json")); + assert!(is_merge_only("my-files.json")); + } +} diff --git a/crates/wb-switch-core/src/modules/auth_file.rs b/crates/wb-switch-core/src/modules/auth_file.rs index 15cd533a..32bea0cd 100644 --- a/crates/wb-switch-core/src/modules/auth_file.rs +++ b/crates/wb-switch-core/src/modules/auth_file.rs @@ -255,7 +255,8 @@ pub fn import_from_auth_file(variant: WbVariant) -> Option { imported_account_from_root(read_auth_file(variant)?, variant) } -fn imported_account_from_root(root: Value, variant: WbVariant) -> Option { +/// 从认证文件 root JSON 构造账号库记录。同文件 discover 模块复用(补录用)。 +pub(crate) fn imported_account_from_root(root: Value, variant: WbVariant) -> Option { let account_obj = root .get("account") .filter(|v| v.is_object()) diff --git a/crates/wb-switch-core/src/modules/automations.rs b/crates/wb-switch-core/src/modules/automations.rs new file mode 100644 index 00000000..0e5a2c18 --- /dev/null +++ b/crates/wb-switch-core/src/modules/automations.rs @@ -0,0 +1,201 @@ +//! 带走定时任务(L3 归属层,DB):把 `automations` 与 `automation_delivery_outbox` +//! 的 `owner_user_id` 对齐到目标账号。 +//! +//! 独立成模块(2026-09-19 主人定):上游发 PR 单独走一个,不与「同步设置与文件」 +//! (`align.rs` 的 L4/L5)捆绑。与 align 的关系: +//! - `align_data`(真实执行/预览)在 `align_automations` 开关下调 +//! [`align_automations_owner_in_db`]; +//! - 独立入口 [`align_automations_owner`] 由 `/api/automations/align` 使用 +//! (不切号也能对齐,需先完全退出 WorkBuddy)。 + +use serde_json::{json, Value}; +use std::path::Path; + +use crate::modules::config::{backup_dir, now_ms, utc_iso}; +use crate::modules::session::{backup_workbuddy_db, open_db, table_exists, workbuddy_db_path}; +use crate::modules::variant::WbVariant; + +/// 把未删除自动化的 owner 对齐到目标账号(含备份)。db 不存在返回 None。 +pub fn align_automations_owner(target_uid: &str) -> Option { + let db = workbuddy_db_path(WbVariant::Cn); + if !db.is_file() { + return None; + } + let backup = backup_workbuddy_db(WbVariant::Cn, &backup_dir().join("automations").join(utc_iso())) + .map(|p| p.to_string_lossy().to_string()); + let (automations, outbox) = + align_automations_owner_in_db(&db, target_uid, false).unwrap_or((0, 0)); + Some(json!({ + "targetUid": target_uid, + "automationsUpdated": automations, + "outboxUpdated": outbox, + "backup": backup, + })) +} + +/// 低层:对齐 automations + outbox 的 owner,返回 (automations 行数, outbox 行数)。 +pub(crate) fn align_automations_owner_in_db( + db_path: &Path, + target_uid: &str, + dry_run: bool, +) -> Result<(usize, usize), String> { + if !db_path.is_file() { + return Ok((0, 0)); + } + let Some(conn) = open_db(db_path, false) else { + return Ok((0, 0)); + }; + + let mut automations_updated = 0usize; + if table_exists(&conn, "automations") { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM automations \ + WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid], + |r| r.get(0), + ) + .unwrap_or(0); + if n > 0 && !dry_run { + conn.execute( + "UPDATE automations SET owner_user_id = ?1, updated_at = ?2 \ + WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid, now_ms()], + ) + .map_err(|e| e.to_string())?; + } + automations_updated = n as usize; + } + + let mut outbox_updated = 0usize; + if table_exists(&conn, "automation_delivery_outbox") { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM automation_delivery_outbox \ + WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid], + |r| r.get(0), + ) + .unwrap_or(0); + if n > 0 && !dry_run { + conn.execute( + "UPDATE automation_delivery_outbox SET owner_user_id = ?1, updated_at = ?2 \ + WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid, now_ms()], + ) + .map_err(|e| e.to_string())?; + } + outbox_updated = n as usize; + } + + Ok((automations_updated, outbox_updated)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + + fn temp_db(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "wb_switch_test_{}_{name}.db", + uuid::Uuid::new_v4().simple() + )) + } + + fn setup(db: &Path) { + let conn = Connection::open(db).unwrap(); + conn.execute_batch( + "CREATE TABLE automations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_user_id TEXT, + status TEXT NOT NULL DEFAULT 'ACTIVE', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER + ); + CREATE TABLE automation_delivery_outbox ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + owner_user_id TEXT, + status TEXT NOT NULL, + finished_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO automations (id, name, owner_user_id, created_at, updated_at, deleted_at) + VALUES + ('a-1', '旧账号的自动化', 'uid-a', 1, 1, NULL), + ('a-2', '已删除的自动化', 'uid-a', 1, 1, 100), + ('a-3', '已是目标账号', 'uid-b', 1, 1, NULL), + ('a-4', '无归属 legacy', NULL, 1, 1, NULL); + INSERT INTO automation_delivery_outbox + (id, automation_id, owner_user_id, status, finished_at, created_at, updated_at) + VALUES + ('o-1', 'a-1', 'uid-a', 'pending', NULL, 1, 1), + ('o-2', 'a-1', 'uid-a', 'finished', 999, 1, 1);", + ) + .unwrap(); + } + + #[test] + fn align_automations_moves_live_rows_to_target() { + let db = temp_db("align_auto"); + setup(&db); + + let (n, o) = align_automations_owner_in_db(&db, "uid-b", false).unwrap(); + assert_eq!(n, 2, "a-1(owner 不同)/a-4(NULL) 两行;a-2 已软删、a-3 已是目标,均不动"); + assert_eq!(o, 1, "只有未投递完成的 o-1 会被对齐"); + + let conn = Connection::open(&db).unwrap(); + let get = |id: &str| -> Option { + conn.query_row( + "SELECT owner_user_id FROM automations WHERE id = ?1", + [id], + |r| r.get(0), + ) + .ok() + }; + assert_eq!(get("a-1").as_deref(), Some("uid-b")); + assert_eq!(get("a-2").as_deref(), Some("uid-a"), "软删行保留原归属"); + assert_eq!(get("a-3").as_deref(), Some("uid-b")); + assert_eq!(get("a-4").as_deref(), Some("uid-b"), "legacy 无归属行一并接管"); + + let outbox_owner: String = conn + .query_row( + "SELECT owner_user_id FROM automation_delivery_outbox WHERE id = 'o-2'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(outbox_owner, "uid-a", "已完成的投递行不动"); + } + + #[test] + fn align_automations_dry_run_does_not_write() { + let db = temp_db("align_auto_dry"); + setup(&db); + let (n, _o) = align_automations_owner_in_db(&db, "uid-b", true).unwrap(); + assert_eq!(n, 2, "dry-run 也要统计计划行数"); + let owner: String = Connection::open(&db) + .unwrap() + .query_row( + "SELECT owner_user_id FROM automations WHERE id='a-1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(owner, "uid-a", "dry-run 不落盘"); + } + + #[test] + fn align_missing_db_is_noop() { + let db = temp_db("missing"); + assert_eq!( + align_automations_owner_in_db(&db, "uid-b", false).unwrap(), + (0, 0) + ); + } +} diff --git a/crates/wb-switch-core/src/modules/discover.rs b/crates/wb-switch-core/src/modules/discover.rs new file mode 100644 index 00000000..16099a8b --- /dev/null +++ b/crates/wb-switch-core/src/modules/discover.rs @@ -0,0 +1,320 @@ +//! 本机"曾登录账号"发现与补录。 +//! +//! 数据源(证据链): +//! 1. **官方 auth 历史**:`~/AppData/Local/CodeBuddyExtension/Data/Public/auth/` +//! `workbuddy-desktop....info` —— WorkBuddy 每次登录/切号留档, +//! 含完整 account + token,是"曾登录过"的最权威记录。 +//! 2. **数据残留**:settings.json `claw.users` 键 ∪ `storage/user-*` 目录 ∪ +//! `memory/_memory.md`(本模块 [`discover_accounts`]),无凭据,仅证明确实用过。 +//! +//! 对照在册账号库(~/.wb-switch/accounts.json)输出"识别到但未登记"的账号, +//! 供 UI 一键补录(adopt_account:凭据来自最新 auth 历史备份)。 + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use crate::modules::account::{get_str, load_accounts, save_collected_account}; +use crate::modules::auth_file::{auth_file_path, imported_account_from_root}; +use crate::modules::config::{backup_dir, home_dir, now_ms, utc_iso}; + +// --------------------------------------------------------------------------- +// 本机数据落点(`~/.workbuddy/*`)—— 对齐模块(align)也复用这几个路径 +// --------------------------------------------------------------------------- + +fn workbuddy_root() -> PathBuf { + home_dir().join(".workbuddy") +} + +/// `~/.workbuddy/settings.json`(`claw.users` 账号键的来源)。 +pub(crate) fn settings_path() -> PathBuf { + workbuddy_root().join("settings.json") +} + +/// `~/.workbuddy/memory`(画像缓存 `_memory.md`)。 +pub(crate) fn memory_dir() -> PathBuf { + workbuddy_root().join("memory") +} + +/// `~/.workbuddy/storage`(`user-*` 目录)。 +pub(crate) fn storage_dir() -> PathBuf { + workbuddy_root().join("storage") +} + +/// 账号清单 = settings `claw.users` 键 ∪ `storage/user-` 目录 ∪ 画像缓存文件。 +/// +/// 无凭据,只说明「这个 uid 在本机确实用过」⇒ 发现的 residual 来源; +/// 对齐模块的缺省源选择(`align::pick_source`)也用它。 +pub fn discover_accounts() -> Vec { + let mut accs: Vec = Vec::new(); + if let Ok(text) = std::fs::read_to_string(settings_path()) { + if let Ok(data) = serde_json::from_str::(&text) { + if let Some(users) = data + .get("claw") + .and_then(|c| c.get("users")) + .and_then(|u| u.as_object()) + { + accs.extend(users.keys().cloned()); + } + } + } + if let Ok(entries) = std::fs::read_dir(storage_dir()) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if let Some(uid) = name.strip_prefix("user-") { + if !uid.ends_with("-personal") { + accs.push(uid.to_string()); + } + } + } + } + if let Ok(entries) = std::fs::read_dir(memory_dir()) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if let Some(uid) = name.strip_suffix("_memory.md") { + accs.push(uid.to_string()); + } + } + } + accs.sort(); + accs.dedup(); + accs +} + +/// 扫描 auth 目录:`workbuddy-desktop.*.info`(排除当前登录文件),按 uid 保留最新。 +fn scan_auth_history() -> Vec<(i64, Value)> { + let current = auth_file_path(crate::modules::variant::WbVariant::Cn); + let Some(dir) = current.parent().map(|p| p.to_path_buf()) else { + return vec![]; + }; + scan_auth_history_in(&dir, ¤t) +} + +/// 可注入目录的实现(单测用)。 +fn scan_auth_history_in(dir: &std::path::Path, current: &std::path::Path) -> Vec<(i64, Value)> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("[discover] 读取 auth 目录失败: {e}"); + return vec![]; + } + }; + + let mut seen: HashMap = HashMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if !name.starts_with("workbuddy-desktop") || !name.ends_with(".info") { + continue; + } + if path == current { + continue; // 当前登录态不算历史 + } + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + let Ok(root) = serde_json::from_str::(&text) else { + continue; + }; + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + // 新旧判据:**主判据 = 文件名内嵌的 ISO 时间戳**(`workbuddy-desktop..….info`)。 + // 理由:该时间戳由客户端写入备份时生成、天然有序;而 mtime 在快速连续写入时会落在 + // 同一毫秒,导致「同 uid 取最新」取错(2026-09-16 修此 bug,由测试 + // `scan_keeps_newest_per_uid_and_skips_current` 捕获)。 + // ISO 8601 字符串的**字典序 == 时间序**,故直接字符串比较,无需日期运算; + // 解析不到时间戳时退化为空串,实际由 mtime 决定。 + let stamp = name + .strip_prefix("workbuddy-desktop.") + .and_then(|rest| rest.split('.').next()) + .unwrap_or("") + .to_string(); + let Some(rec) = imported_account_from_root(root, crate::modules::variant::WbVariant::Cn) else { + continue; // 无 access_token 的备份无恢复价值 + }; + let uid = get_str(&rec, "uid").unwrap_or_default(); + if uid.is_empty() { + continue; + } + match seen.get(&uid) { + Some((prev_stamp, prev_mtime, _)) + if prev_stamp.as_str() > stamp.as_str() + || (prev_stamp.as_str() == stamp.as_str() && *prev_mtime >= mtime) => + { + continue + } + _ => { + seen.insert(uid, (stamp, mtime, rec)); + } + } + } + seen.into_values().map(|(_, mtime, rec)| (mtime, rec)).collect() +} + +fn backup_accounts_file() -> Option { + let src = crate::modules::config::accounts_file(); + if !src.is_file() { + return None; + } + let dir = backup_dir().join("accounts").join(utc_iso()); + std::fs::create_dir_all(&dir).ok()?; + let dst = dir.join("accounts.json"); + std::fs::copy(&src, &dst).ok()?; + Some(dst) +} + +/// 识别本机所有曾登录/留有数据的账号,对照在册账号库。 +/// +/// 返回每项: +/// ```json +/// { "uid", "nickname", "email", "source": "auth-history" | "residual", +/// "backupFiles": 0, "backedUpAt": 0, "inAccountList": false, +/// "accessTokenExpiresAt": 0, "refreshTokenExpiresAt": 0, +/// "restorable": false } +/// ``` +/// `restorable` = 有 auth 历史备份 且 refresh token 未过期(可补录并刷新)。 +pub fn discover_known_accounts() -> Value { + let in_accounts = load_accounts(); + let history = scan_auth_history(); + + let mut items: Vec = Vec::new(); + // 1) auth 历史优先(含凭据,可直接补录) + for (mtime, rec) in history { + let uid = get_str(&rec, "uid").unwrap_or_default(); + let now = now_ms(); + let access_exp = rec.get("expiresAt").and_then(|v| v.as_i64()).unwrap_or(0); + let refresh_exp = rec + .get("refreshExpiresAt") + .and_then(|v| v.as_i64()) + .unwrap_or(access_exp); + let in_list = in_accounts.iter().any(|a| { + get_str(a, "uid").as_deref() == Some(uid.as_str()) + || get_str(a, "id").as_deref() == Some(uid.as_str()) + }); + items.push(json!({ + "uid": uid, + "nickname": rec.get("nickname").cloned().unwrap_or(Value::Null), + "email": rec.get("email").cloned().unwrap_or(Value::Null), + "source": "auth-history", + "backupFiles": 1, + "backedUpAt": mtime, + "inAccountList": in_list, + "accessTokenExpiresAt": access_exp, + "refreshTokenExpiresAt": refresh_exp, + "restorable": refresh_exp == 0 || refresh_exp > now, + })); + } + + // 2) 数据残留账号(无凭据备份,仅提示"曾登录",restorable=false) + let residual_uids = discover_accounts(); + let known_uids: Vec = items.iter().filter_map(|i| get_str(i, "uid")).collect(); + for uid in residual_uids { + if known_uids.contains(&uid) { + continue; + } + let in_list = in_accounts.iter().any(|a| { + get_str(a, "uid").as_deref() == Some(uid.as_str()) + || get_str(a, "id").as_deref() == Some(uid.as_str()) + }); + items.push(json!({ + "uid": uid, + "nickname": Value::Null, + "email": Value::Null, + "source": "residual", + "backupFiles": 0, + "backedUpAt": 0, + "inAccountList": in_list, + "accessTokenExpiresAt": 0, + "refreshTokenExpiresAt": 0, + "restorable": false, + })); + } + + items.sort_by_key(|i| { + ( + i.get("inAccountList") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + std::cmp::Reverse(i.get("backedUpAt").and_then(|v| v.as_i64()).unwrap_or(0)), + ) + }); + json!({ "accounts": items }) +} + +/// 补录:用 uid 对应的最新 auth 历史备份构造账号记录并写入账号库。 +/// +/// 写前先备份 accounts.json 到 `~/.wb-switch/backups/accounts//`。 +pub fn adopt_account(uid: &str) -> Result { + let history = scan_auth_history(); + let mut newest: Option<(i64, Value)> = None; + for (mtime, rec) in history { + if get_str(&rec, "uid").as_deref() == Some(uid) + && newest.as_ref().is_none_or(|(m, _)| *m < mtime) + { + newest = Some((mtime, rec)); + } + } + let Some((_, rec)) = newest else { + return Err(format!("uid {uid} 无 auth 历史备份,无法补录")); + }; + backup_accounts_file(); + let saved = save_collected_account(rec).map_err(|e| e.to_string())?; + Ok(crate::modules::account::account_meta(&saved)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 在临时 auth 目录构造两个账号的历史备份,验证扫描去重取最新 + 排除当前登录。 + #[test] + fn scan_keeps_newest_per_uid_and_skips_current() { + let tmp = std::env::temp_dir().join(format!("wb-discover-test-{}", now_ms())); + std::fs::create_dir_all(&tmp).unwrap(); + let write = |name: &str, uid: &str, nick: &str, tok: &str| { + let root = json!({ + "account": {"uid": uid, "nickname": nick}, + "auth": {"accessToken": tok, "refreshToken": "RT", + "tokenType": "Bearer", "domain": "d", "expiresAt": 1} + }); + std::fs::write(tmp.join(name), serde_json::to_string(&root).unwrap()).unwrap(); + }; + // 旧备份 u-1 → 新备份 u-1(应留新);u-2 一份;当前登录文件应跳过 + write("workbuddy-desktop.2026-09-01T00-00-00-000Z.1.aaaa.info", "u-1", "一号", "AT-1-old"); + write("workbuddy-desktop.2026-09-02T00-00-00-000Z.1.bbbb.info", "u-1", "一号", "AT-1-new"); + write("workbuddy-desktop.2026-09-03T00-00-00-000Z.1.cccc.info", "u-2", "二号", "AT-2"); + write("workbuddy-desktop.info", "u-cur", "当前", "AT-cur"); // 应被排除 + // 非 workbuddy 前缀文件应被忽略 + write("other.info", "u-x", "杂鱼", "AT-x"); + + let hits = scan_auth_history_in(&tmp, &tmp.join("workbuddy-desktop.info")); + let mut by_uid: HashMap = HashMap::new(); + for (_, rec) in &hits { + by_uid.insert( + get_str(rec, "uid").unwrap(), + get_str(rec, "access_token").unwrap(), + ); + } + assert_eq!(by_uid.len(), 2, "应识别 u-1 + u-2,共两个历史账号"); + assert_eq!(by_uid.get("u-1").unwrap(), "AT-1-new", "同 uid 应取最新备份"); + assert_eq!(by_uid.get("u-2").unwrap(), "AT-2"); + assert!(!by_uid.contains_key("u-cur"), "当前登录文件应被排除"); + assert!(!by_uid.contains_key("u-x"), "非 workbuddy 前缀应被忽略"); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// 无 token 的备份应被忽略(imported_account_from_root 返回 None)。 + #[test] + fn no_token_root_is_ignored() { + let root = json!({ "account": { "uid": "u-1", "nickname": "n" } }); + assert!(imported_account_from_root(root, crate::modules::variant::WbVariant::Cn).is_none()); + } +} diff --git a/crates/wb-switch-core/src/modules/mod.rs b/crates/wb-switch-core/src/modules/mod.rs index 3c92c932..3518a033 100644 --- a/crates/wb-switch-core/src/modules/mod.rs +++ b/crates/wb-switch-core/src/modules/mod.rs @@ -1,4 +1,6 @@ pub mod account; +pub mod align; +pub mod automations; pub mod auth_file; pub mod checkin; pub mod codebuddy_cli; @@ -7,10 +9,12 @@ pub mod codebuddy_ide; pub mod config; pub mod credit_usage; pub mod credits; +pub mod discover; pub mod export_import; pub mod limits; pub mod oauth; pub mod official_usage; +pub mod oplog; pub mod process; pub mod rate_limit_events; pub mod rate_limit_hook; @@ -21,5 +25,6 @@ pub mod switch; pub mod token_stats; pub mod travel; pub mod update; +pub mod ui_theme; pub mod variant; pub mod vscode_cn_inject; diff --git a/crates/wb-switch-core/src/modules/oplog.rs b/crates/wb-switch-core/src/modules/oplog.rs new file mode 100644 index 00000000..892321e2 --- /dev/null +++ b/crates/wb-switch-core/src/modules/oplog.rs @@ -0,0 +1,104 @@ +//! 切号操作留痕(本地专属模块,上游无此文件,改动零合并冲突)。 +//! +//! 目的:切号 / 数据对齐的结果此前只在 UI 当次展示,事后无法回溯「何时切到谁、 +//! 勾了哪些对齐项、各层改了多少」。这里把结果追加到 +//! `~/.wb-switch/switch_logs.json`,仅留痕,不阻断切号流程。 +//! +//! 与 wb_multi_sync 的 `logs/sync-<日期>.log` 对应,但按条存 JSON 便于后续在 +//! 设置页做展示(UI 未接入时可直接读文件)。 + +use crate::modules::config::{atomic_write, now_ms, store_dir, utc_iso}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +/// 保留最近多少条(与自动轮换日志同量级)。 +pub const SWITCH_LOG_MAX_RECORDS: usize = 200; + +pub fn switch_logs_file() -> PathBuf { + store_dir().join("switch_logs.json") +} + +/// 读取全部切号日志(保持写入顺序,最旧在前)。 +pub fn load_switch_logs() -> Vec { + let Ok(text) = std::fs::read_to_string(switch_logs_file()) else { + return vec![]; + }; + serde_json::from_str::>(&text).unwrap_or_default() +} + +/// 保存切号日志(保留最近 N 条,保持插入顺序)。 +pub fn save_switch_logs(logs: &[Value]) -> std::io::Result<()> { + let mut kept: Vec = logs.to_vec(); + if kept.len() > SWITCH_LOG_MAX_RECORDS { + kept.drain(..kept.len() - SWITCH_LOG_MAX_RECORDS); + } + std::fs::create_dir_all(store_dir())?; + let content = serde_json::to_string_pretty(&kept).unwrap_or_default(); + atomic_write(&switch_logs_file(), &content) +} + +/// 追加一条切号日志(写失败不抛错,绝不阻断切号)。 +pub fn add_switch_log(entry: &Value) { + let mut logs = load_switch_logs(); + logs.push(entry.clone()); + let _ = save_switch_logs(&logs); +} + +/// 组装一条切号日志记录。 +/// +/// - `action`:`switch`(正常切换)/ `dry-run`(预览)/ `error`(失败) +/// - `from` / `to`:源账号 uid、目标账号 uid +/// - `options`:本次勾选项(复制会话数、三个对齐开关) +/// - `result`:成功时的分段结果,或失败原因 +pub fn switch_log_entry( + action: &str, + from_uid: Option<&str>, + to_uid: &str, + options: &Value, + result: &Value, +) -> Value { + json!({ + "ts": now_ms(), + "at": utc_iso(), + "action": action, + "from": from_uid, + "to": to_uid, + "options": options, + "result": result, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_carries_action_origin_and_options() { + let entry = switch_log_entry( + "switch", + Some("u-from"), + "u-to", + &json!({"copySessions": 2, "alignAutomations": true}), + &json!({"ok": true}), + ); + assert_eq!(entry["action"], json!("switch")); + assert_eq!(entry["from"], json!("u-from")); + assert_eq!(entry["to"], json!("u-to")); + assert_eq!(entry["options"]["copySessions"], json!(2)); + assert!(entry["ts"].as_i64().unwrap_or(0) > 0); + assert!(entry["at"].as_str().is_some_and(|s| !s.is_empty())); + } + + #[test] + fn save_keeps_only_latest_records_and_preserves_order() { + let mut logs: Vec = (0..SWITCH_LOG_MAX_RECORDS + 5) + .map(|i| json!({ "seq": i })) + .collect(); + if logs.len() > SWITCH_LOG_MAX_RECORDS { + logs.drain(..logs.len() - SWITCH_LOG_MAX_RECORDS); + } + assert_eq!(logs.len(), SWITCH_LOG_MAX_RECORDS); + assert_eq!(logs[0]["seq"], json!(5)); + assert_eq!(logs[logs.len() - 1]["seq"], json!(SWITCH_LOG_MAX_RECORDS + 4)); + } +} diff --git a/crates/wb-switch-core/src/modules/session.rs b/crates/wb-switch-core/src/modules/session.rs index 7ffe3cac..1f558111 100644 --- a/crates/wb-switch-core/src/modules/session.rs +++ b/crates/wb-switch-core/src/modules/session.rs @@ -220,7 +220,7 @@ fn find_project_jsonl(variant: WbVariant, cid: &str) -> Option { } /// 备份 workbuddy.db(含 -wal/-shm),返回主库备份路径。对照 `backup_workbuddy_db`。 -fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { +pub(crate) fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { let db = workbuddy_db_path(variant); if !db.is_file() { return None; diff --git a/crates/wb-switch-core/src/modules/switch.rs b/crates/wb-switch-core/src/modules/switch.rs index 127bc2b0..b316239e 100644 --- a/crates/wb-switch-core/src/modules/switch.rs +++ b/crates/wb-switch-core/src/modules/switch.rs @@ -1,13 +1,18 @@ -//! 账号切换:备份 → 关进程 → 复制会话(可选)→ 写认证 → 启动。 +//! 账号切换:备份 → 关进程 → 复制会话 / 数据对齐(可选)→ 写认证 → 启动。 //! //! 对照 server.py `switch_account`。切换过程中通过进度回调向前端推送实时进度, //! 避免界面长时间无反馈被误认为卡死。core 不依赖 Tauri,进度回调由宿主适配 //! (桌面端转发为 `switch-progress` 事件,HTTP 端写入轮询/SSE)。 +//! +//! dry_run=true 为预览模式:只统计将发生的对齐变更,不关 App、不写库、不写凭据。 +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::modules::account; +use crate::modules::align::{self, AlignOptions}; use crate::modules::auth_file; +use crate::modules::oplog; use crate::modules::process::{close_workbuddy, launch_workbuddy}; use crate::modules::session; use crate::modules::variant::WbVariant; @@ -15,13 +20,101 @@ use crate::modules::variant::WbVariant; /// 切换进度回调(宿主注入,如 Tauri `app.emit` 或 HTTP 进度缓存)。 pub type ProgressFn = Box; -/// 切换账号。copy_session_ids 非空时按路径 B 复制勾选会话(新 id,云端可同步)。 +/// 切换选项。 +/// +/// serde 必须用 camelCase:HTTP api(api_switch)直接把前端扁平 JSON 反序列化成 +/// 本结构,字段名对不上会被当未知字段忽略、静默落回 default(勾选静默失效)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SwitchOptions { + #[serde(default = "default_true")] + pub restart: bool, + #[serde(default)] + pub share_sessions: bool, + #[serde(default)] + pub copy_session_ids: Vec, + /// 带走定时任务(multi_sync L3):把 `automations` / `automation_delivery_outbox` + /// 的归属改成目标账号,让目标账号也能看到、继续管这些定时任务。 + #[serde(default = "default_true")] + pub align_automations: bool, + /// 同步设置与文件(multi_sync L4/L5):settings 深合并 / storage 补齐 / + /// 账号资料 / my-files 并集 / 界面主题跟随;SECRET_KEYS 命中即跳过防串号。 + #[serde(default)] + pub align_files: bool, + /// 预览模式:只统计变更,不落盘。 + #[serde(default)] + pub dry_run: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for SwitchOptions { + fn default() -> Self { + Self { + restart: true, + share_sessions: false, + copy_session_ids: Vec::new(), + align_automations: true, + align_files: false, + dry_run: false, + } + } +} + +/// 切换账号。 +/// +/// 薄包装:调用 [`switch_account_inner`] 做实际切换,并把结果留痕到 +/// `~/.wb-switch/switch_logs.json`(见 `oplog` 模块)。留痕失败不影响切换结果。 pub fn switch_account( progress_fn: Option<&ProgressFn>, account_id: &str, - restart: bool, - share_sessions: bool, - copy_session_ids: &[String], + opts: &SwitchOptions, +) -> Result { + let from_uid = session::current_user_uid(WbVariant::Cn); + let to_uid = account::find_account(account_id) + .map(|acc| account_uid(&acc)) + .unwrap_or_default(); + let outcome = switch_account_inner(progress_fn, account_id, opts); + let log_options = json!({ + "copySessions": opts.copy_session_ids.len(), + "alignAutomations": opts.align_automations, + "alignFiles": opts.align_files, + "dryRun": opts.dry_run, + "restart": opts.restart, + }); + match &outcome { + Ok(result) => oplog::add_switch_log(&oplog::switch_log_entry( + if opts.dry_run { "dry-run" } else { "switch" }, + from_uid.as_deref(), + &to_uid, + &log_options, + result, + )), + Err(err) => oplog::add_switch_log(&oplog::switch_log_entry( + "error", + from_uid.as_deref(), + &to_uid, + &log_options, + &json!({ "ok": false, "error": err }), + )), + } + outcome +} + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +fn switch_account_inner( + progress_fn: Option<&ProgressFn>, + account_id: &str, + opts: &SwitchOptions, ) -> Result { let progress = |message: &str| { eprintln!("[switch] progress: {message}"); @@ -35,35 +128,88 @@ pub fn switch_account( account::find_account(account_id).ok_or_else(|| format!("账号不存在: {account_id}"))?; // 档位以账号自身为准:签名里的参数无法表达「用 A 档位操作 B 档位账号」。 let variant = account::variant_of(&acc); + + // 预览模式:不关 App、不写库、不写凭据,只算对齐计划 + // ⚠️ 备份必须放在本分支**之后**:放这儿会让每次预览都 fs::copy 一份 auth 备份(纯垃圾)。 + if opts.dry_run { + progress("预览模式:统计将对齐的数据…"); + let align_opts = AlignOptions { + align_automations: opts.align_automations, + align_files: opts.align_files, + slim_keep: 0, + dry_run: true, + }; + // 预览不真复制,但要把「将要复制的会话」传进去,用于量化瘦身的抵消条数。 + let align_data = align::preview_sync(&acc, &align_opts, &opts.copy_session_ids, &[]) + .unwrap_or_else(|| { + json!({ "dryRun": true, "noop": true, "targetUid": account_uid(&acc) }) + }); + return Ok(json!({ + "ok": true, + "dryRun": true, + "account": account::account_display_name(&acc), + "alignData": align_data, + })); + } + let backup = auth_file::backup_auth_file(variant); let mut copy_report: Option = None; let mut session_report: Option = None; - if restart { + let mut align_report: Option = None; + if opts.restart { progress("正在关闭 WorkBuddy…"); close_workbuddy(variant, 20)?; - // 只有重启场景才做会话操作(数据库在运行中不宜写入) + // 只有重启场景才做会话/数据操作(数据库在运行中不宜写入) // 能力探测只对国际版生效:国内版数据根与改造前同构,探测会把「从未用过 // 会话」的国内版机器判成不支持并整段跳过(A1 零回归)。 let copy_available = variant != WbVariant::Ai || variant.supports_session_copy(); - if !copy_session_ids.is_empty() && copy_available { + if !opts.copy_session_ids.is_empty() && copy_available { progress("正在复制会话到目标账号…"); // 复制失败不阻断切换:报告里带上错误,切换本身仍然继续。 copy_report = Some( - match session::copy_sessions_for_switch(&acc, copy_session_ids) { + match session::copy_sessions_for_switch(&acc, &opts.copy_session_ids) { Ok(report) => report, Err(error) => json!({"error": error}), }, ); } - if share_sessions { + + // 数据对齐(带走定时任务 + 同步设置与文件,可选):align::post_close_sync + // 统一入口 —— 定时任务改归属、settings/storage/资料/my-files 按目标账号补齐、 + // 界面主题跟随;落盘前自动备份,SECRET_KEYS 命中即跳过防串号。 + if opts.align_automations || opts.align_files { + let target_uid = account_uid(&acc); + if target_uid.is_empty() { + align_report = Some(json!({ "error": "该账号缺少 uid,无法对齐" })); + } else { + progress(if opts.align_files { + "正在同步设置与文件…" + } else { + "正在把定时任务带到目标账号…" + }); + align_report = align::post_close_sync( + &acc, + &AlignOptions { + align_automations: opts.align_automations, + align_files: opts.align_files, + slim_keep: 0, + dry_run: false, + }, + &[], + &[], + ); + } + } + + if opts.share_sessions { // 旧的「全体转移」兼容路径(默认关闭),Rust 版暂未实现 session_report = Some(json!({"error": "share_sessions 兼容路径暂未在 Rust 版实现"})); } } progress("正在写入认证文件…"); auth_file::write_account_to_auth_file(&acc, variant)?; - if restart { + if opts.restart { progress("正在启动 WorkBuddy…"); launch_workbuddy(variant, Some(&progress))?; } @@ -81,5 +227,8 @@ pub fn switch_account( if let Some(s) = session_report { result["sessionShare"] = s; } + if let Some(a) = align_report { + result["alignData"] = a; + } Ok(result) } diff --git a/crates/wb-switch-core/src/modules/ui_theme.rs b/crates/wb-switch-core/src/modules/ui_theme.rs new file mode 100644 index 00000000..5e6cc693 --- /dev/null +++ b/crates/wb-switch-core/src/modules/ui_theme.rs @@ -0,0 +1,474 @@ +//! UI 主题跟随账号(L6):**云端为准,本地只做一件事 —— 把云端定下的 key 预写进 Local Storage**。 +//! +//! 目标:切号后目标账号的主题与源账号一致。 +//! +//! 两层各司其职,缺一不可: +//! - ① 云端(真源):主题**按账号存在云端**(`/portal/user-asset/appearance` 的 `kind=theme` +//! `resource_key`)。读 source 云端 key → 与 target 不同则写 target 云端 ⇒ 之后无论在哪台 +//! 设备拉回云端拿到的都是继承值(**永久跟随**)。只改本地,几秒后会被云端回写覆盖。 +//! - ② 本地(体验层):云端回写是**异步**的(实测几秒)⇒ 只改云端的话,重启瞬间显示的是 +//! 上一个账号留在 leveldb 里的主题。把①的 key 写进 leveldb ⇒ 重启瞬间即对。 +//! +//! ★★ 键位(2026-09-17 本机 `.log` 逐字节逆向 + `app.asar` 取证,**改这条之前先读**): +//! `workbuddy.appearance.state::personal::`,值 = `0x01` + `{"currentTheme":""}`。 +//! key **原样透传** —— `light` / `dark` / 皮肤 id(如 `theme-tkboqn`)都照写,**不做映射、不做枚举** +//! (主题清单由服务端下发,前端不硬编码;枚举方案会随皮肤上下线过期)。 +//! 另有 `workbuddy.appearance.mode::::::`(明暗 light/dark/auto)与 legacy 键 +//! `agent-ui-theme`(App 仍读作迁移输入)—— **两者都不写**:云端只下发一个 `kind=theme` 的 key, +//! 推不出目标明暗,宁可不写也不猜。 +//! +//! **已砍掉的部分(别再加回来,理由都在)**:旧键映射与写入(legacy 键只影响一次性迁移)、 +//! 读当前值做「已达标就跳过」(只扫 `.log`,compaction 后读不到是常态 ⇒ 极少生效)、 +//! `ui_prefs` 审计(无人消费)。剩下的事就一件:**拿云端的 key,追加一条 record**。 +//! +//! 安全设计(append-only,不改写任何现有字节): +//! - 只向**该目录最新的 `.log`** 末尾追加一条完整 record;LevelDB 打开时按序 replay, +//! 我们这条因 sequence 更大而覆盖旧值。 +//! - record 的 CRC 若算错或格式不符,LevelDB 会**丢弃该 record** —— 最坏情况是「预写不生效」, +//! 原有数据不受影响。 +//! - 追加前把该 `.log` 备份到 `~/.wb-switch/backups/localstorage//`(单文件,实测整个 +//! Local Storage 才 0.5 MB,成本可忽略,恢复只需还原这一个文件)。 +//! +//! ⚠️ 采样纪律:**只能读 `.log`**(`.ldb` 有前缀压缩 + snappy,搜不到 ≠ 不存在);App 运行中会 +//! 持续写 + compaction,**键随时被搬走**;读到的值也**无法区分是「App 回写」还是「我们写的」** +//! ⇒ 用途是复验「结果对不对」,不能据此断言「预写生效」。 +//! +//! 格式(2026-09-10 / 09-17 两次字节级验证): +//! - 键前缀 = `_file://\0\x01`(origin 固定为打包渲染进程);keylen 前置为 varint +//! - batch = seq(u64 le) + count(u32 le) + [type(1) + varint(klen) + key + varint(vlen) + value] +//! - record = crc32c-masked(4 le) + len(2 le) + type(1) + batch(crc 覆盖 type+batch;vlen **含** value 首字节) + +use serde_json::{json, Value}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::modules::config::{backup_dir, home_dir, now_ms, utc_iso}; + +/// 主题状态键前缀(完整键 = 前缀 + uid)。 +const APPEARANCE_STATE_PREFIX: &[u8] = b"_file://\x00\x01workbuddy.appearance.state::personal::"; +/// Chromium localStorage value 首字节(0x01 = UTF-8 文本)。 +const VALUE_PREFIX: u8 = 0x01; +/// WorkBuddy userData 下的 Local Storage 位置。 +const LEVELDB_SUBDIR: &str = "app/session/Local Storage/leveldb"; +/// LevelDB log 常量。 +const BLOCK: usize = 32 * 1024; +const RECORD_HEADER: usize = 7; +const RECORD_FULL: u8 = 1; +const BATCH_TYPE_VALUE: u8 = 1; + +fn leveldb_dir() -> PathBuf { + home_dir().join(".workbuddy").join(LEVELDB_SUBDIR) +} + +// --------------------------------------------------------------------------- +// CRC32C(Castagnoli,LevelDB 标准校验) +// --------------------------------------------------------------------------- + +fn crc32c_table() -> &'static [u32; 256] { + use std::sync::OnceLock; + static TABLE: OnceLock<[u32; 256]> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut t = [0u32; 256]; + for (i, e) in t.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0x82F6_3B78 ^ (c >> 1) } else { c >> 1 }; + } + *e = c; + } + t + }) +} + +fn crc32c(data: &[u8]) -> u32 { + let t = crc32c_table(); + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + crc = t[(crc ^ b as u32) as usize & 0xFF] ^ (crc >> 8); + } + crc ^ 0xFFFF_FFFF +} + +/// LevelDB 的 crc 掩码:rotate right 15 再加固定常数。 +fn mask_crc(crc: u32) -> u32 { + crc.rotate_right(15).wrapping_add(0xA282_EAD8) +} + +// --------------------------------------------------------------------------- +// 编码 +// --------------------------------------------------------------------------- + +fn put_varint32(out: &mut Vec, mut v: u32) { + loop { + if v < 0x80 { + out.push(v as u8); + break; + } + out.push(((v & 0x7F) as u8) | 0x80); + v >>= 7; + } +} + +/// 构造一条含单条 put 的 WriteBatch payload。 +fn build_batch(seq: u64, key: &[u8], value: &[u8]) -> Vec { + let mut p = Vec::with_capacity(12 + key.len() + value.len() + 6); + p.extend_from_slice(&seq.to_le_bytes()); + p.extend_from_slice(&1u32.to_le_bytes()); // count = 1 + p.push(BATCH_TYPE_VALUE); + put_varint32(&mut p, key.len() as u32); + p.extend_from_slice(key); + put_varint32(&mut p, value.len() as u32); + p.extend_from_slice(value); + p +} + +/// 把 batch payload 封成一条 FULL log record。 +fn build_log_record(payload: &[u8]) -> Vec { + assert!(payload.len() + RECORD_HEADER <= BLOCK, "单条 record 必须放进一个 block"); + let mut crc_input = Vec::with_capacity(1 + payload.len()); + crc_input.push(RECORD_FULL); + crc_input.extend_from_slice(payload); + let masked = mask_crc(crc32c(&crc_input)); + + let mut out = Vec::with_capacity(RECORD_HEADER + payload.len()); + out.extend_from_slice(&masked.to_le_bytes()); + out.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + out.push(RECORD_FULL); + out.extend_from_slice(payload); + out +} + +// --------------------------------------------------------------------------- +// 写入 leveldb +// --------------------------------------------------------------------------- + +/// 目录里编号最大的 `.log`(LevelDB 永远只往它追加)。 +fn latest_log(dir: &Path) -> Result { + let mut logs: Vec<(u64, PathBuf)> = fs::read_dir(dir) + .map_err(|e| e.to_string())? + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let num: u64 = name.strip_suffix(".log")?.parse().ok()?; + Some((num, e.path())) + }) + .collect(); + logs.sort_by_key(|(n, _)| *n); + logs.pop() + .map(|(_, p)| p) + .ok_or_else(|| "leveldb 无 .log 文件(可能刚被 compaction),跳过主题预写".to_string()) +} + +/// 完整状态键(前缀 + uid)。 +fn appearance_state_key(uid: &str) -> Vec { + let mut k = APPEARANCE_STATE_PREFIX.to_vec(); + k.extend_from_slice(uid.as_bytes()); + k +} + +/// 状态键的 value:云端 key **原样**,不猜、不改写。 +fn appearance_state_json(resource_key: &str) -> String { + json!({ "currentTheme": resource_key }).to_string() +} + +/// 向指定 `.log` 末尾追加一条 record(处理 block 剩余空间)。 +fn append_record(log: &Path, seq: u64, key: &[u8], value: &[u8]) -> Result<(), String> { + let payload = build_batch(seq, key, value); + let record = build_log_record(&payload); + + let len = fs::metadata(log).map_err(|e| e.to_string())?.len() as usize; + let rem = BLOCK - (len % BLOCK); + let mut out: Vec = Vec::with_capacity(RECORD_HEADER + record.len() + rem); + if rem < RECORD_HEADER || rem < RECORD_HEADER + record.len() { + out.extend(std::iter::repeat_n(0u8, rem)); // trailer / 本 block 放不下,另起 block + } + out.extend_from_slice(&record); + + let mut f = fs::OpenOptions::new().append(true).open(log).map_err(|e| e.to_string())?; + f.write_all(&out).map_err(|e| e.to_string()) +} + +/// 备份单个 `.log`(我们只改它 ⇒ 恢复只需还原它),返回备份路径。 +/// `root` 由调用方给(生产 = `~/.wb-switch/backups/localstorage`),便于测试不碰用户目录。 +fn backup_log(log: &Path, root: &Path) -> Result { + let name = log + .file_name() + .ok_or_else(|| "leveldb .log 路径异常".to_string())? + .to_string_lossy() + .to_string(); + let dst_dir = root.join(utc_iso()); + fs::create_dir_all(&dst_dir).map_err(|e| e.to_string())?; + let dst = dst_dir.join(name); + fs::copy(log, &dst).map_err(|e| e.to_string())?; + Ok(dst) +} + +/// 本地层:备份 .log → 追加状态记录。返回备份路径。 +fn write_local_state(dir: &Path, uid: &str, resource_key: &str, backup_root: &Path) -> Result { + let log = latest_log(dir)?; + let backup = backup_log(&log, backup_root)?; // 先备份,写失败可原地还原 + let value: Vec = std::iter::once(VALUE_PREFIX) + .chain(appearance_state_json(resource_key).into_bytes()) + .collect(); + append_record(&log, now_ms() as u64, &appearance_state_key(uid), &value)?; + Ok(backup) +} + +// --------------------------------------------------------------------------- +// 云端层:WorkBuddy 把明暗/皮肤选择按账号存云端(/portal/user-asset/appearance), +// 切号启动后由外观运行时拉回——本地注入只能保证启动瞬间,云端改掉才能永久跟随。 +// 接口规格 2026-09-11 从 app.asar 渲染层逆向 + 本机实测(Bearer access_token)。 +// --------------------------------------------------------------------------- + +const APPEARANCE_GET_URL: &str = "https://www.workbuddy.cn/portal/user-asset/appearance/get"; +const APPEARANCE_SET_URL: &str = "https://www.workbuddy.cn/portal/user-asset/appearance/set"; + +fn cloud_http() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + // 本机 WARP 代理会碍事,workbuddy.cn 直连可达 + .no_proxy() + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()) +} + +/// 读账号云端 theme 选择(resource_key:`light` / `dark` / 皮肤 id)。 +fn fetch_cloud_theme(token: &str) -> Option { + let resp: Value = cloud_http() + .get(APPEARANCE_GET_URL) + .bearer_auth(token) + .send() + .ok()? + .json() + .ok()?; + if resp.get("code").and_then(|c| c.as_i64()) != Some(0) { + return None; + } + let items = resp.get("data")?.get("items")?.as_array()?; + items + .iter() + .filter_map(|i| i.as_object()) + .find(|o| o.get("kind").and_then(|k| k.as_str()) == Some("theme")) + .and_then(|o| o.get("resource_key")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) +} + +/// 写账号云端 theme 选择。 +fn push_cloud_theme(token: &str, resource_key: &str) -> Result<(), String> { + let resp: Value = cloud_http() + .post(APPEARANCE_SET_URL) + .bearer_auth(token) + .json(&json!({ "kind": "theme", "resource_key": resource_key })) + .send() + .map_err(|e| e.to_string())? + .json() + .map_err(|e| e.to_string())?; + match resp.get("code").and_then(|c| c.as_i64()) { + Some(0) => Ok(()), + _ => Err(format!("set 响应异常: {resp}")), + } +} + +/// 云端继承:把 source 账号的 theme 选择写到 target 账号云端,返回**最终生效的 key**。 +/// 任一步失败返回 Err(不阻断切号)。 +fn inherit_cloud_theme(source_token: &str, target_token: &str) -> Result { + let source_theme = fetch_cloud_theme(source_token) + .ok_or_else(|| "读取 source 云端主题失败".to_string())?; + let target_theme = fetch_cloud_theme(target_token); + if target_theme.as_deref() == Some(source_theme.as_str()) { + return Ok(json!({ "changed": false, "theme": source_theme })); + } + push_cloud_theme(target_token, &source_theme)?; + Ok(json!({ "changed": true, "theme": source_theme, "previous": target_theme })) +} + +// --------------------------------------------------------------------------- +// 对外入口 +// --------------------------------------------------------------------------- + +/// 切号时同步主题:① 云端继承(永久跟随)→ ② 用①的 key 预写本地(重启瞬间即对)。 +/// +/// ①失败 ⇒ **不写本地**(宁可不写,也不写错值)。本地层失败不影响①的结果。 +/// 必须在 WorkBuddy 完全关闭后、写 auth/重启前调用。 +pub fn sync_theme_for_switch( + source_uid: Option<&str>, + target_uid: &str, + source_token: Option<&str>, + target_token: Option<&str>, +) -> Value { + let dir = leveldb_dir(); + if target_uid.is_empty() { + return json!({ "inherited": false, "cloud": null, "skipped": true }); + } + + // ① 云端继承(真源) + let cloud = match (source_token, target_token) { + (Some(src), Some(tgt)) if !src.is_empty() && !tgt.is_empty() => match inherit_cloud_theme(src, tgt) { + Ok(v) => v, + Err(e) => json!({ "changed": false, "error": e }), + }, + _ => json!({ "changed": false, "reason": "缺少 source/target token" }), + }; + + // ② 本地预写(值取自①) + let final_key = cloud + .get("theme") + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let local = match final_key.as_deref() { + None => json!({ + "inherited": false, + "reason": "云端主题未取到,跳过本地预写(避免写入不确定的值)", + }), + Some(key) if !dir.is_dir() => json!({ + "inherited": false, + "theme": key, + "reason": "找不到 Local Storage 目录,跳过本地预写", + }), + Some(key) => match write_local_state(&dir, target_uid, key, &backup_dir().join("localstorage")) { + Ok(backup) => json!({ + "inherited": true, + "theme": key, + "resourceKey": key, + "backup": backup, + }), + Err(e) => json!({ "inherited": false, "theme": key, "error": e }), + }, + }; + + json!({ + "local": local, + "cloud": cloud, + // 继承语义下源身份经 token 体现,uid 仅留作排障线索 + "sourceUid": source_uid, + }) +} + +// --------------------------------------------------------------------------- +// 测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const UID: &str = "00000000-0000-0000-0000-000000000000"; + const UID2: &str = "11111111-1111-1111-1111-111111111111"; + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wbs-theme-{tag}-{}", std::process::id())); + fs::create_dir_all(&d).unwrap(); + d + } + + fn expected_record(seq: u64, uid: &str, key: &str) -> Vec { + let value: Vec = std::iter::once(VALUE_PREFIX) + .chain(appearance_state_json(key).into_bytes()) + .collect(); + build_log_record(&build_batch(seq, &appearance_state_key(uid), &value)) + } + + #[test] + fn crc32c_matches_known_vectors() { + assert_eq!(crc32c(b"123456789"), 0xE306_9283); + assert_eq!(crc32c(b""), 0x0000_0000); + assert_eq!(crc32c(b"a"), 0xC1D0_4330); + } + + #[test] + fn varint_encodes_expected_bytes() { + let mut buf = Vec::new(); + put_varint32(&mut buf, 84); + assert_eq!(buf, vec![0x54]); // 实测 keylen + let mut buf = Vec::new(); + put_varint32(&mut buf, 32); + assert_eq!(buf, vec![0x20]); // 实测 vlen + let mut buf = Vec::new(); + put_varint32(&mut buf, 300); + assert_eq!(buf, vec![0xAC, 0x02]); + } + + /// 键/值的形状必须与实测字节一致(keylen `0x54`=84、vlen `0x20`=32)。 + /// 对不上说明前缀或 JSON 形状漂了 —— 这是防「静默写错键」的第一道闸。 + #[test] + fn appearance_state_shape_matches_observed_bytes() { + let key = appearance_state_key(UID); + assert_eq!(key.len(), 84); + assert!(key.starts_with(b"_file://\x00\x01workbuddy.appearance.state::personal::")); + assert!(key.ends_with(UID.as_bytes())); + + let json = appearance_state_json("theme-tkboqn"); + assert_eq!(json, r#"{"currentTheme":"theme-tkboqn"}"#); + assert_eq!(json.len() + 1, 32); + } + + /// 写入的字节必须与手工构造的 record 完全一致(vlen 含类型字节、CRC 覆盖 type+batch)。 + #[test] + fn append_writes_expected_bytes() { + let dir = tmp("bytes"); + let log = dir.join("000007.log"); + fs::write(&log, build_log_record(&build_batch(1, b"_file://\x00\x01seed", b"\x01{}"))).unwrap(); + let before = fs::metadata(&log).unwrap().len() as usize; + + let value: Vec = std::iter::once(VALUE_PREFIX) + .chain(appearance_state_json("theme-tkboqn").into_bytes()) + .collect(); + append_record(&log, 42, &appearance_state_key(UID), &value).unwrap(); + + let data = fs::read(&log).unwrap(); + assert_eq!(&data[before..], &expected_record(42, UID, "theme-tkboqn")[..]); + fs::remove_dir_all(&dir).ok(); + } + + /// block 边界:剩余 < record 头时先补 trailer 再另起 block(record 不跨 block 起始)。 + #[test] + fn append_handles_block_boundary() { + let dir = tmp("blk"); + let log = dir.join("000003.log"); + let prefix_len = BLOCK - 3; + fs::write(&log, vec![0xABu8; prefix_len]).unwrap(); + + let value: Vec = std::iter::once(VALUE_PREFIX) + .chain(appearance_state_json("dark").into_bytes()) + .collect(); + append_record(&log, 7, &appearance_state_key(UID), &value).unwrap(); + + let data = fs::read(&log).unwrap(); + let expected = expected_record(7, UID, "dark"); + assert_eq!(data.len(), prefix_len + 3 + expected.len()); + assert_eq!(&data[prefix_len + 3..], &expected[..]); + fs::remove_dir_all(&dir).ok(); + } + + /// 按账号分键 + 只碰**最新**的那个 .log + 备份落盘。 + #[test] + fn writes_latest_log_for_the_right_account() { + let dir = tmp("uid"); + let bk = tmp("bk"); + fs::write(dir.join("000002.log"), Vec::::new()).unwrap(); + fs::write(dir.join("000009.log"), Vec::::new()).unwrap(); + + let backup = write_local_state(&dir, UID, "dark", &bk).unwrap(); + assert!(backup.exists(), "备份必须落盘"); + assert!(backup.starts_with(&bk), "备份应落在调用方给的根目录下"); + + assert!(fs::read(dir.join("000002.log")).unwrap().is_empty(), "旧的 .log 不该被写"); + let latest = fs::read(dir.join("000009.log")).unwrap(); + assert!( + latest.windows(appearance_state_key(UID).len()).any(|w| w == appearance_state_key(UID)), + "状态键必须出现在最新 .log 里" + ); + let needle = br#"{"currentTheme":"dark"}"#; + assert!( + latest.windows(needle.len()).any(|w| w == needle), + "值必须原样写入" + ); + + assert_ne!(appearance_state_key(UID), appearance_state_key(UID2)); + fs::remove_dir_all(&dir).ok(); + fs::remove_dir_all(&bk).ok(); + } +} diff --git a/crates/wb-switch-server/src/api.rs b/crates/wb-switch-server/src/api.rs index d5d23b30..4d4d0578 100644 --- a/crates/wb-switch-server/src/api.rs +++ b/crates/wb-switch-server/src/api.rs @@ -142,6 +142,8 @@ pub fn router() -> Router { get(api_update_config).post(api_save_update_config), ) .fallback(static_handler) + // 本地新增接口(定时任务归属对齐)集中在 api_local.rs + .merge(crate::api_local::router()) } fn json_ok(v: Value) -> Response { @@ -420,23 +422,8 @@ async fn api_switch(Json(body): Json) -> Response { if account_id.trim().is_empty() { return json_err("缺少 accountId".to_string(), StatusCode::BAD_REQUEST); } - let restart = body - .get("restart") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let share_sessions = body - .get("shareSessions") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let copy_ids: Vec = body - .get("copySessionIds") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); + let mut opts: switch::SwitchOptions = serde_json::from_value(body).unwrap_or_default(); + opts.restart = true; { let mut running = SWITCH_RUNNING.lock().unwrap(); @@ -452,13 +439,7 @@ async fn api_switch(Json(body): Json) -> Response { }); let result = tokio::task::spawn_blocking(move || { - switch::switch_account( - Some(&progress), - &account_id, - restart, - share_sessions, - ©_ids, - ) + switch::switch_account(Some(&progress), &account_id, &opts) }) .await; diff --git a/crates/wb-switch-server/src/api_local.rs b/crates/wb-switch-server/src/api_local.rs new file mode 100644 index 00000000..f2a5d8b8 --- /dev/null +++ b/crates/wb-switch-server/src/api_local.rs @@ -0,0 +1,57 @@ +//! 本地专属 HTTP 路由(上游无此文件 → 与上游合并零冲突)。 +//! +//! 这里只放本项目新增的接口:定时任务归属对齐。 +//! `api.rs` 只保留一行 `.merge(api_local::router())`,避免在上游热点文件里堆代码。 + +use axum::extract::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::Router; +use serde_json::{json, Value}; + +use wb_switch_core::modules::{account, automations}; + +fn json_ok(v: Value) -> Response { + Json(v).into_response() +} + +fn json_err(e: String, code: StatusCode) -> Response { + (code, Json(json!({ "ok": false, "error": e }))).into_response() +} + +/// 本地路由表(由 `api::router()` merge)。 +pub fn router() -> Router { + Router::new().route("/api/automations/align", post(api_align_automations)) +} + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// POST /api/automations/align —— 自动化归属对齐(不切号)。需先完全退出 WorkBuddy。 +async fn api_align_automations(Json(body): Json) -> Response { + let account_id = body + .get("accountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if account_id.trim().is_empty() { + return json_err("缺少 accountId".to_string(), StatusCode::BAD_REQUEST); + } + let Some(target) = account::find_account(&account_id) else { + return json_err("账号不存在".to_string(), StatusCode::BAD_REQUEST); + }; + let uid = account_uid(&target); + if uid.is_empty() { + return json_err("该账号缺少 uid,无法对齐".to_string(), StatusCode::BAD_REQUEST); + } + match automations::align_automations_owner(&uid) { + Some(v) => json_ok(v), + None => json_err("workbuddy.db 不存在".to_string(), StatusCode::BAD_REQUEST), + } +} diff --git a/crates/wb-switch-server/src/main.rs b/crates/wb-switch-server/src/main.rs index 5eb97c9a..91d99ccf 100644 --- a/crates/wb-switch-server/src/main.rs +++ b/crates/wb-switch-server/src/main.rs @@ -8,6 +8,7 @@ //! ``` mod api; +mod api_local; use serde_json::json; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1a4d8cf5..00e5821b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -317,24 +317,26 @@ pub async fn switch_account( restart: Option, share_sessions: Option, copy_session_ids: Option>, + align_automations: Option, + align_files: Option, + dry_run: Option, ) -> Result { if account_id.trim().is_empty() { return Err("缺少 accountId".to_string()); } - let restart = restart.unwrap_or(true); - let share_sessions = share_sessions.unwrap_or(false); - let copy_ids = copy_session_ids.unwrap_or_default(); + let opts = switch::SwitchOptions { + restart: restart.unwrap_or(true), + share_sessions: share_sessions.unwrap_or(false), + copy_session_ids: copy_session_ids.unwrap_or_default(), + align_automations: align_automations.unwrap_or(true), + align_files: align_files.unwrap_or(false), + dry_run: dry_run.unwrap_or(false), + }; let progress: switch::ProgressFn = Box::new(move |message| { let _ = app.emit("switch-progress", json!({ "message": message })); }); tauri::async_runtime::spawn_blocking(move || { - switch::switch_account( - Some(&progress), - &account_id, - restart, - share_sessions, - ©_ids, - ) + switch::switch_account(Some(&progress), &account_id, &opts) }) .await .map_err(|e| e.to_string())? diff --git a/src-tauri/src/commands_local.rs b/src-tauri/src/commands_local.rs new file mode 100644 index 00000000..7c980221 --- /dev/null +++ b/src-tauri/src/commands_local.rs @@ -0,0 +1,35 @@ +//! 本地专属 Tauri 命令(上游无此文件 → 与上游合并零冲突)。 +//! +//! 只放本项目新增的命令:定时任务归属对齐。`commands.rs` 保持上游原貌, +//! 新命令在 `lib.rs` 的 `invoke_handler` 里以 `commands_local::xxx` 注册。 + +use serde_json::{json, Value}; + +use wb_switch_core::modules::{account, automations}; + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// 不切号,把当前定时任务归属立即对齐到指定账号(适用于先用旧版切完号、补做对齐的场景)。 +/// 需先完全退出 WorkBuddy。 +#[tauri::command(rename_all = "camelCase")] +pub async fn align_automations(account_id: String) -> Result { + if account_id.trim().is_empty() { + return Err("缺少 accountId".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + let target = account::find_account(&account_id).ok_or("账号不存在")?; + let uid = account_uid(&target); + if uid.is_empty() { + return Err("该账号缺少 uid,无法对齐".to_string()); + } + automations::align_automations_owner(&uid).ok_or_else(|| "workbuddy.db 不存在".to_string()) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b795048e..ef914ed6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ mod commands; +mod commands_local; #[cfg(target_os = "macos")] mod instance_lock; #[cfg(desktop)] @@ -192,6 +193,7 @@ pub fn run() { commands::export_accounts_to_path, commands::preview_import_accounts, commands::import_accounts, + commands_local::align_automations, commands::switch_account, commands::list_sessions, commands::copy_sessions, diff --git a/src/components/align-options.tsx b/src/components/align-options.tsx new file mode 100644 index 00000000..38ce2043 --- /dev/null +++ b/src/components/align-options.tsx @@ -0,0 +1,412 @@ +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import type { AlignDataReport } from "@/lib/types"; + +/** 对齐勾选项(本地新增 UI,上游对话框只保留 import)。 */ +export interface AlignOptions { + alignAutomations: boolean; + alignFiles: boolean; +} + +/** 条目语气:normal 正常 / warn 注意 / error 出错了。 */ +export type PreviewTone = "normal" | "warn" | "error"; + +/** 预览里的一行:label 在左、value 靠右;chips 放项目名一类短标签。 */ +export interface PreviewItem { + label: string; + value?: string; + tone?: PreviewTone; + /** 短标签(项目名等),渲染在 label 下一行的可换行容器里。 */ + chips?: string[]; +} + +/** 预览里的一组:左侧色条 + 可选组标题。 */ +export interface PreviewGroup { + title?: string; + tone?: PreviewTone; + items: PreviewItem[]; +} + +/** 预览报告:总览一行 + 若干分组 + 末行结论。 */ +export interface PreviewReport { + summary?: string; + groups: PreviewGroup[]; + footnote?: string; +} + +/** 预览状态:报告 + 是否已过期(结果算完之后又动过勾选)。 */ +export interface PreviewState { + report: PreviewReport; + stale: boolean; +} + +interface Props { + value: AlignOptions; + onChange: (next: AlignOptions) => void; + preview: PreviewState | null; +} + +function Row({ + title, + hint, + checked, + onCheckedChange, +}: { + title: string; + hint: string; + checked: boolean; + onCheckedChange: (v: boolean) => void; +}) { + return ( +
+
+
{title}
+
{hint}
+
+ +
+ ); +} + +/** 左侧色条:让正常 / 注意 / 出错三档一眼分开,不靠缩进。 */ +const BAR: Record = { + normal: "border-l-border", + warn: "border-l-amber-500", + error: "border-l-destructive", +}; +/** 组标题色。 */ +const TITLE_TONE: Record = { + normal: "text-muted-foreground", + warn: "text-amber-700 dark:text-amber-400", + error: "text-destructive", +}; +/** label 色(左列)。 */ +const LABEL_TONE: Record = { + normal: "text-muted-foreground", + warn: "text-amber-700 dark:text-amber-400", + error: "text-destructive", +}; +/** value 色(右列,要点数字)。 */ +const VALUE_TONE: Record = { + normal: "text-foreground", + warn: "text-amber-700 dark:text-amber-400", + error: "text-destructive", +}; + +function PreviewGroupBlock({ group }: { group: PreviewGroup }) { + const tone = group.tone ?? "normal"; + return ( +
+ {group.title && ( +
{group.title}
+ )} +
+ {group.items.map((item, i) => { + const itemTone = item.tone ?? tone; + return ( +
+
+ + {item.label} + + {item.value && ( + + {item.value} + + )} +
+ {item.chips && item.chips.length > 0 && ( +
+ {item.chips.map((c) => ( + + {c} + + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +/** 预览面板:总览 + 分组 + 结论;过期时整块压暗并给重跑提示。 */ +function PreviewPanel({ preview }: { preview: PreviewState }) { + const { report, stale } = preview; + return ( +
+
+
+ 切换前预览按当前勾选算 +
+ {stale && ( + + 设置动过了,重新预览一下 + + )} +
+ + {report.summary &&
{report.summary}
} + + {report.groups.map((g, i) => ( + + ))} + + {report.footnote && ( +
{report.footnote}
+ )} +
+ ); +} + +/** 切号弹窗里的对齐开关 + 预览结果。 */ +export function AlignOptionsPanel({ value, onChange, preview }: Props) { + return ( + <> + onChange({ ...value, alignAutomations: v })} + /> + + onChange({ ...value, alignFiles: v })} + /> + + {preview && } + + ); +} + +/** 取路径最后一段做项目名(`D:\w-dev\x` → `x`)。 */ +function basename(cwd: string): string { + const norm = cwd.replace(/[\\/]+$/, ""); + const i = Math.max(norm.lastIndexOf("\\"), norm.lastIndexOf("/")); + return i >= 0 ? norm.slice(i + 1) : norm; +} + +/** + * 把「切换前预览」的对齐报告整理成分组文本。 + * + * ⚠️ 只服务预览(`dryRun=true`):真实执行完成后的口播在 `doSwitch` 里自拼 toast, + * 不走这里 ⇒ 别把 `r.dryRun === false` 的分支当活路径读,它只是同源的兜底。 + */ +export function formatAlignReport(r: AlignDataReport): PreviewReport { + if (r.error) { + return { + groups: [ + { tone: "error", title: "出问题了", items: [{ label: r.error, tone: "error" }] }, + ], + }; + } + if (r.noop) { + return { groups: [{ items: [{ label: "这次没有勾选要处理的项目" }] }] }; + } + + // ── 组:同步设置(定时任务归属 + 设置/资料/主题跟随)── + // ⚠️ 这一组是「同步」,不是「转移」:数据不会被搬走,源账号仍保留自己的一份。 + const move: PreviewItem[] = []; + // `syncChanged` 只数**真会动**的项:「已经一致 / 待更 0」不算, + // 总览报这个数(明细仍列全部 6 行,好对照哪些不用管)。 + let syncChanged = 0; + if (r.automations) { + if (r.automations.updated) syncChanged += 1; + move.push({ label: "定时任务", value: `${r.automations.updated} 条` }); + if (r.automations.outbox) { + syncChanged += 1; + move.push({ label: "待发送任务", value: `${r.automations.outbox} 条` }); + } + } + const f = r.settings; + if (f) { + if (f.claw) { + if (f.claw.changed) syncChanged += 1; + move.push( + f.claw.changed + ? { label: "应用设置", value: `${f.claw.changed} 项` } + : { label: "应用设置", value: "已经一致" }, + ); + } + if (f.storage) { + // 三类是互斥动作,只报真正会发生的那几种:0 的项不显示。 + // `deferred` = 合并型文件(如汇总文件),只并内容不覆盖,说「汇总」没人懂。 + if (f.storage.copied || f.storage.deferred) syncChanged += 1; + const sb: string[] = []; + if (f.storage.copied) sb.push(`复制 ${f.storage.copied} 份`); + if (f.storage.skipped) sb.push(`跳过 ${f.storage.skipped} 份`); + if (f.storage.deferred) sb.push(`合并 ${f.storage.deferred} 份(不覆盖)`); + move.push({ label: "用户数据", value: sb.length ? sb.join(" · ") : "已经一致" }); + } + if (f.memory) { + if (f.memory.changed) syncChanged += 1; + move.push({ label: "账号资料", value: f.memory.changed ? "待同步" : "已经一致" }); + } + if (f.myFiles) { + if (f.myFiles.changed) syncChanged += 1; + move.push({ + label: "我的文件", + value: `${f.myFiles.files} 份 · 待更 ${f.myFiles.changed}`, + }); + } + if (f.theme) { + // 主题跟随受「同步设置与文件」管辖:报告没写 theme 字段 = 本次没开,行直接隐藏。 + if (f.theme.planned !== false) syncChanged += 1; + move.push({ label: "界面主题", value: "跟随目标账号" }); + } + } + + // ── 组:清理旧会话 ── + let slimGroup: PreviewGroup | null = null; + if (r.slim) { + if (r.slim.error) { + slimGroup = { + title: "清理旧会话", + tone: "error", + items: [{ label: "出错了", value: r.slim.error, tone: "error" }], + }; + } else { + const items: PreviewItem[] = [ + { + label: `本机 · 每项目留 ${r.slim.keep ?? 3} 条`, + value: `软删 ${r.slim.planned ?? 0} 条`, + }, + ]; + const gs = r.slim.groups ?? []; + if (gs.length) { + const chips = gs.slice(0, 3).map((g) => `${basename(g.cwd)} ${g.count}`); + if (gs.length > 3) chips.push(`+${gs.length - 3}`); + items.push({ label: `涉及 ${gs.length} 个项目`, chips }); + } + if (r.dryRun) { + // 本次会搬过去的会话(手动复制 + 共享)都不参与收起;没搬运就不显示这行(免得空喊一句)。 + const cp = r.slim.copyPlanned; + if (cp && cp.total > 0) { + items.push({ + label: `这次搬过去的 ${cp.total} 条不会被软删${ + cp.hitCount ? `(其中 ${cp.hitCount} 条就在上面这些项目里)` : "" + }`, + tone: "warn", + }); + } + } + const cl = r.slim.cloud; + if (cl?.enabled) { + if (!cl.tokenReady) { + items.push({ + label: "云端这轮不动(没读到该账号的登录凭证)", + value: "只清本机", + tone: "warn", + }); + } else if (r.dryRun) { + items.push({ label: "云端 · 仅本账号", value: `约 ${cl.planned ?? 0} 条` }); + } else { + const removed = cl.removed ?? 0; + const gone = cl.alreadyGone ?? 0; + const total = cl.deleted ?? removed + gone; + if (total > 0) { + items.push({ + label: gone > 0 ? "云端清掉(含本来就没有的)" : "云端删掉", + value: `${gone > 0 ? total : removed || total} 条`, + }); + } + if (cl.noToken) { + items.push({ + label: "缺登录凭证,只清在本机", + value: `${cl.noToken} 条`, + tone: "warn", + }); + } + if (cl.failed) { + items.push({ + label: "云端没删掉,本机先留着,下次切号再试", + value: `${cl.failed} 条`, + tone: "warn", + }); + } + if (cl.foreign) { + items.push({ label: "属于其他账号,没动", value: `${cl.foreign} 条` }); + } + if (!total && !cl.noToken && !cl.failed && !cl.foreign) { + items.push({ label: "云端这轮没有要清的", value: "0 条" }); + } + } + } + slimGroup = { title: "清理旧会话", tone: "warn", items }; + } + } + + // ── 组:云端正本(对账 + 全账巡检,只读) ── + let cloudGroup: PreviewGroup | null = null; + const cl = r.slim?.cloud; + if (cl?.enabled) { + const items: PreviewItem[] = []; + const inv = cl.inventory; + if (inv?.enabled) { + items.push({ label: "云端名下会话", value: `${inv.cloud ?? 0} 条` }); + items.push({ label: "其中本机对得上", value: `${inv.aligned ?? 0} 条` }); + if (inv.foreign) { + items.push({ label: "别的设备在用,没动", value: `${inv.foreign} 条` }); + } + if (inv.stale) { + items.push({ label: "本机已经删了", value: `${inv.stale} 条` }); + } + // 只报「活着但还没上云」:已删且云端也没了是常态,混进来数字会虚高一百多。 + if (inv.localOnlyAlive) { + items.push({ + label: "本机还在、但没上云", + value: `${inv.localOnlyAlive} 条`, + tone: "warn", + }); + } + } + // 只留「看得懂、用得上」的四行:云端名下 / 对得上 / 别的设备 / 没上云。 + // 「同步记录 / 本机查不到来历 / 清遗留」属内部账本口径:清遗留清的是早就 + // 失效的云端映射行(实测抽样全 404,不是用户的云端资产),显示只会吓人。 + if (items.length) cloudGroup = { title: "云端正本", items }; + } + + // ── 总览:一行给「会动多少」,数字好扫 ── + const bits: string[] = []; + if (move.length) { + // 报「列了几项(几项要动)」:只报要动的会跟明细行数对不上,只报总数又会被当成全都要动。 + bits.push( + syncChanged + ? `同步设置 ${move.length} 项(${syncChanged} 项要动)` + : `同步设置 ${move.length} 项 · 都已经一致`, + ); + } + if (r.slim && !r.slim.error) bits.push(`清理旧会话 ${r.slim.planned ?? 0} 条`); + if (cl?.enabled && cl.tokenReady && r.dryRun) { + // 只报「跟着本机收起一起删」的那部分,跟下面云端正本里看得见的数字对得上。 + // 对账清遗留(reconcile)不报:它清的是失效映射行,明细里也不显示。 + bits.push(`清理云端约 ${cl.planned ?? 0} 条`); + } + + const groups: PreviewGroup[] = []; + if (move.length) groups.push({ title: "同步设置", items: move }); + if (slimGroup) groups.push(slimGroup); + if (cloudGroup) groups.push(cloudGroup); + + return { + summary: bits.length ? bits.join(" · ") : undefined, + groups, + footnote: r.dryRun ? "以上是预览,还没真正执行" : "切换完成(已先备份数据与设置)", + }; +} diff --git a/src/components/switch-account-dialog.tsx b/src/components/switch-account-dialog.tsx index dab671d1..6e56324d 100644 --- a/src/components/switch-account-dialog.tsx +++ b/src/components/switch-account-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ChevronDown, ChevronRight, ExternalLink, Folder, Loader2 } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; @@ -15,6 +15,12 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; +import { + AlignOptionsPanel, + formatAlignReport, + type PreviewReport, + type PreviewState, +} from "@/components/align-options"; import { Switch } from "@/components/ui/switch"; import * as api from "@/lib/api"; import { accountVariant, variantAppName } from "@/lib/variant"; @@ -34,6 +40,12 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [copySessions, setCopySessions] = useState(false); + const [alignAutomations, setAlignAutomations] = useState(true); + const [alignFiles, setAlignFiles] = useState(true); + const [preview, setPreview] = useState(null); + const [previewing, setPreviewing] = useState(false); + /** 影响预览结果的输入版本号:动一次 +1,用于判断在途预览是否已经过时。 */ + const previewSeq = useRef(0); const [selected, setSelected] = useState>(new Set()); /** 展开的节点:任务 / 空间 / 文件夹。默认全部收起。 */ const [expanded, setExpanded] = useState>(new Set()); @@ -67,6 +79,9 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro useEffect(() => { if (open && account) { setCopySessions(false); + setAlignAutomations(true); + setAlignFiles(true); + setPreview(null); setSelected(new Set()); setExpanded(new Set()); setError(""); @@ -110,6 +125,50 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro }); } + // 预览是「按当前勾选算」的一次性快照:任一影响结果的输入一变,旧结果就作废。 + // 只标脏不清空 —— 清空等于让用户白点一次;标脏还能对照看差在哪。 + useEffect(() => { + previewSeq.current += 1; + setPreview((cur) => (cur && !cur.stale ? { ...cur, stale: true } : cur)); + }, [alignAutomations, alignFiles, copySessions, selected]); + + async function doPreview() { + if (!account) return; + setPreviewing(true); + setError(""); + const seqAtStart = previewSeq.current; + try { + const res = await api.switchAccount({ + accountId: account.id, + // 预览不真复制,但要把「将要复制的会话」传进去,用于量化瘦身的抵消条数 + copySessionIds: copySessions ? [...selected] : undefined, + alignAutomations: alignAutomations, + alignFiles: alignFiles, + dryRun: true, + }); + // 出结果之前设置又动过 ⇒ 这份已经不是当前勾选的结果,直接标脏,别让用户照着它按确认。 + const stale = previewSeq.current !== seqAtStart; + const report: PreviewReport = res.alignData + ? formatAlignReport(res.alignData) + : { groups: [{ items: [{ label: "这次没有要处理的数据" }] }] }; + setPreview({ report, stale }); + } catch (e) { + setPreview({ + report: { + groups: [ + { + tone: "error", + title: "出问题了", + items: [{ label: api.asError(e), tone: "error" }], + }, + ], + }, + stale: false, + }); + } finally { + setPreviewing(false); + } + } async function doSwitch() { if (!account) return; setBusy(true); @@ -120,9 +179,17 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro const res = await api.switchAccount({ accountId: account.id, copySessionIds: requestedCopy ? [...selected] : undefined, + alignAutomations: alignAutomations, + alignFiles: alignFiles, }); const nickname = account.nickname || account.email || account.uid || "该账号"; const parts: string[] = []; + if (res.alignData?.automations) { + parts.push(`已带走 ${res.alignData.automations.updated} 个定时任务`); + } + if (res.alignData?.settings?.storage?.copied) { + parts.push(`已同步 ${res.alignData.settings.storage.copied} 个用户数据文件`); + } const copyError = res.sessionCopy?.error; const copiedCount = res.sessionCopy?.copied?.length ?? 0; if (copiedCount > 0) { @@ -404,10 +471,23 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro )} + { setAlignAutomations(v.alignAutomations); setAlignFiles(v.alignFiles); }} + preview={preview} + /> + + diff --git a/src/lib/api.ts b/src/lib/api.ts index 85632f94..a961b8b9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -82,6 +82,7 @@ type Route = { method: "GET" | "POST"; path: string }; const ROUTES: Record = { get_status: { method: "GET", path: "/api/status" }, get_accounts: { method: "GET", path: "/api/accounts" }, + align_automations: { method: "POST", path: "/api/automations/align" }, get_codebuddy_cli_status: { method: "GET", path: "/api/codebuddy-cli/status" }, install_codebuddy_cli_helper: { method: "POST", path: "/api/codebuddy-cli/install-helper" }, switch_codebuddy_cli_account: { method: "POST", path: "/api/codebuddy-cli/switch" }, @@ -326,11 +327,22 @@ export function switchAccount(args: { restart?: boolean; shareSessions?: boolean; copySessionIds?: string[]; + /** 带走定时任务:把定时任务归属对齐到目标账号(后端默认开)。 */ + alignAutomations?: boolean; + /** 同步设置与文件:设置/主题/用户数据按目标账号补齐(后端默认关)。 */ + alignFiles?: boolean; + /** 预览模式:只统计变更,不落盘。 */ + dryRun?: boolean; }): Promise { return call("switch_account", args as unknown as Record); } /** 切换进度(webui 轮询用;桌面端走事件,此函数无副作用)。 */ +/** 不切号,把定时任务归属立即对齐到指定账号(需先完全退出 WorkBuddy)。 */ +export function alignAutomations(accountId: string): Promise { + return call("align_automations", { accountId }); +} + export function switchProgress(): Promise<{ running: boolean; progress: string | null }> { return call("switch_progress"); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 984c193a..73511502 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -118,6 +118,56 @@ export interface SwitchResult { variant?: WbVariant; backup: string | null; sessionCopy?: SessionCopyReport; + dryRun?: boolean; + alignData?: AlignDataReport; +} + +/** 切号时的数据对齐报告(含「带走定时任务」与「同步设置与文件」)。 */ +export interface AlignDataReport { + targetUid?: string; + dryRun?: boolean; + noop?: boolean; + /** 定时任务归属对齐:updated = 改了几行,outbox = 投递队列被改的行数。 */ + automations?: { updated: number; outbox: number }; + /** 设置同步:应用设置深合并 / 用户数据补齐 / 账号资料 / 我的文件 / 界面主题。 */ + settings?: { + sourceUid?: string | null; + claw?: { changed: number; skipped?: boolean }; + storage?: { copied: number; skipped: number; deferred: number }; + memory?: { changed: boolean }; + myFiles?: { files: number; changed: number }; + theme?: Record; + }; + /** 「清理旧对话」的瘦身报告(随 #62 落地;本版本后端恒不产出,类型先行)。 */ + slim?: { + uid?: string; + keep?: number; + planned?: number; + deleted?: number; + error?: string; + groups?: { cwd: string; count: number }[]; + copyPlanned?: { total: number; hitCount: number; hitProjects: number }; + cloud?: { + enabled: boolean; + tokenReady: boolean; + planned?: number; + deleted?: number; + removed?: number; + alreadyGone?: number; + noToken?: number; + failed?: number; + foreign?: number; + inventory?: { + enabled: boolean; + cloud?: number; + aligned?: number; + foreign?: number; + stale?: number; + localOnlyAlive?: number; + }; + }; + }; + error?: string; } export interface CheckinConfig {