Cherry Studio userDataRelocation 模块深度解析:Electron 用户数据目录安全迁移的完整实现
2026/9/12 17:42:25 网站建设 项目流程

Cherry Studio userDataRelocation 模块深度解析:Electron 用户数据目录安全迁移的完整实现

【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

导读

本文围绕 Cherry Studio 主进程中的userDataRelocation服务域(src/main/services/userDataRelocation/README.md),完整讲解其"请求—重启—执行—提交"端到端的数据目录迁移机制:如何安全地把整个 ElectronuserData目录树复制或切换到新位置、如何在启动前(preboot)阶段执行而绝不进入正常生命周期、以及一套基于所有权标记与失败回滚的数据安全模型。读完本文,你将掌握该模块的模块边界、BootConfig 契约、校验规则、复制与回滚事务实现,以及它为什么被设计成"专用启动 + 裸 IPC 进度窗口"的形态。


一、模块定位:谁在拥有 userData 迁移这件事

userDataRelocation是一个位于 src/main/services/userDataRelocation/ 下的独立服务域,职责描述在 README 首段非常明确:

Owns userData relocation end to end: validating a requested target directory, persisting the request, executing the copy/switch on the next launch, committing the new location, and driving the dedicated progress window.

它从端到端"拥有"用户数据目录迁移的全过程:校验用户请求的目标目录、持久化请求、在下一次启动时执行复制/切换、提交新位置、并驱动专用的进度窗口。

这里需要先澄清一个关键术语边界:本文中的userData指的是 Electron 操作系统层面的整个app.getPath('userData')目录树,而不是口语中"用户内容"的意思。目录树被视为一个不透明整体(opaque unit),复制时永远覆盖整棵树——包括用户文件、Chromium 运行状态(Cookies、Local Storage、IndexedDB)、日志等——不存在"只迁移用户内容子集"这种精选模式。这一语义在 src/main/core/preboot/userDataLocation.ts 的注释中有明确说明(Windows/Linux 下应用日志也在 userData 内,macOS 的日志在~/Library/Logs)。相关的启动概念可参见 docs/references/architecture/ 下的 preboot 说明文档。


二、执行模型:一次"专用启动"完成迁移

README 描述了整个执行模型的核心思路:迁移获得一次专用的启动(dedicated launch)。完整时序如下:

  1. 运行中的应用通过 IPC 发起请求,请求被持久化到 BootConfig;
  2. 应用执行 relaunch;
  3. 下一次启动时——此时上一个进程已完全退出、源目录树处于静止(quiescent)状态——preboot 阶段执行整棵目录的复制或切换;
  4. 提交新位置后再次 relaunch,进入新位置的正常启动。

关键约束是:一次迁移启动永远不会继续进入生命周期引导(lifecycle bootstrap)。也就是说,迁移启动的终点是"再次 relaunch",而不是打开主界面。

对应到代码,入口函数是runUserDataRelocation(),它在 src/main/main.ts 的startApp中被调用:

const relocationResult = await runUserDataRelocation() if (relocationResult === 'handled') return

函数返回'handled'表示本次启动属于迁移(调用方必须停止正常启动流程,流程以 relaunch 结束);返回'skipped'表示没有待处理的请求,正常启动继续(见 execution.ts)。从调用位置可以确认,它位于runDataReset()之后、备份恢复门与 v2 迁移门之前,且一定早于application.bootstrap()

另外注意 readUserDataRelocationState 的两个前置条件:

  • 仅打包版执行if (!app.isPackaged) return null——开发模式使用带后缀的 dev userData,不执行迁移(与请求侧的isPackaged门一致);
  • 过期请求丢弃:如果持久化请求中的from不是本次启动解析出的 userData(例如可执行文件被移动、BootConfig 被复制到另一台机器),该请求会被丢弃并清除,避免迁移错误的目录树。

三、模块地图:五个文件与一个依赖方向

模块内部由五个源码文件加一个出口文件组成(README 的 Module map 表):

文件职责
execution.tsrunUserDataRelocation()— 启动期入口,拥有整个流程:状态读取、sessionData 隔离、复制/恢复/回滚、提交、进度、窗口驱动、relaunch
request.ts运行时请求面:inspectUserDataRelocationTarget()requestUserDataRelocation()
validation.ts共享的路径校验:请求断言、受保护树规则、路径原语
window.ts专用的 pre-lifecycleBrowserWindow控制器
types.ts对共享 BootConfig schema 的领域类型别名
index.tsbarrel——目录外部代码的唯一导入面

依赖方向是无环的:execution → validation, window, typesrequest → validation, typesvalidation → types。外部依赖上,两个面都向下依赖core/preboot/userDataLocationdata/bootConfig

types.ts的实现非常简短,它从共享的BootConfigSchema中提取别名:

export type RelocationState = NonNullable<BootConfigSchema['temp.user_data_relocation']> export type PendingRelocation = Extract<RelocationState, { status: 'pending' }> export type FailedRelocation = Extract<RelocationState, { status: 'failed' }>

(见 types.ts)共享 zod schema 是唯一事实来源,这里只是为领域代码命名其变体。


四、两个面向与 BootConfig 契约

4.1 请求面(Request face,应用运行中)

内容
调用者ipc/handlers/app.ts——IpcApi 边界只保留isPackaged策略与IpcError映射
入口inspectUserDataRelocationTargetrequestUserDataRelocation

对应 IPC 注册在 src/main/ipc/handlers/app.ts:

'app.user_data_relocation.inspect': async ({ path }) => inspectUserDataRelocationTarget(path), 'app.user_data_relocation.request': async ({ path, copy }) => { if (!app.isPackaged) { throw new IpcError('USER_DATA_RELOCATION_UNAVAILABLE', 'userData relocation is available only in packaged builds') } requestUserDataRelocation(path, copy) },

请求面两个函数的语义(request.ts):

  • inspectUserDataRelocationTarget(targetPath)只校验、不产生任何副作用,用户还在挑选路径时可以反复调用。它返回{ valid: true, targetEmpty }{ valid: false, reason }UserDataRelocationInspection类型定义在 src/shared/types/userDataRelocation.ts);
  • requestUserDataRelocation(targetPath, copy):构造PendingRelocation(含uuidv4()生成的taskId、规范化的from/tocopy布尔值),校验后立即persist()写入 BootConfig——请求必须在 Electron relaunch 之前持久化,写失败则恢复原状态并抛出。

4.2 执行面(Execution face,下一次启动)

内容
调用者main.tspreboot 序列——唯一调用者
入口runUserDataRelocation

runUserDataRelocation是执行面唯一入口,完整流程(execution.ts):

  1. 读取迁移状态,无状态则返回'skipped'
  2. 仅当status === 'pending' && copy === true时执行 sessionData 隔离(见第五节);switch 模式不读源树、failed 状态只展示错误窗口,因此都不依赖临时文件系统——损坏的环境不能阻塞纯指针切换或错误说明;
  3. await app.whenReady()
  4. 打开专用进度窗口;
  5. 若状态为failed:发布 failed 进度,若窗口不可用则直接 relaunch(清空 failed 状态后重启),返回'handled'
  6. 否则执行executeRelocation(复制/切换 + 提交),成功则发布completed进度并 relaunch;
  7. 任何异常:文件系统已回滚,持久化failed状态,发布 failed 进度,然后 relaunch。

4.3 BootConfig 两个键的完整契约

README 明确了两个键的读写分工:

  • temp.user_data_relocation:请求面写入pending;执行面读取它、丢弃过期请求、任何错误时改写为failed,提交成功或用户从失败界面确认重启时清除。
  • app.user_data_path:提交步骤写入;每次启动时由core/preboot/userDataLocation.ts读取并解析 userData。

两个键在 BootConfig 的 load/set 边界都经过共享 zod schema 校验。schema 定义在 src/shared/data/bootConfig/bootConfigSchemas.ts:

'app.user_data_path': z.record(z.string(), z.string()), 'temp.user_data_relocation': z.union([ z.object({ status: z.literal('pending'), taskId: z.uuid(), from: z.string(), to: z.string(), copy: z.boolean() }), z.object({ status: z.literal('failed'), taskId: z.uuid(), from: z.string(), to: z.string(), copy: z.boolean(), error: z.string(), failedAt: z.string() }) ])

schema 注释还强调了一个重要设计:temp.*命名空间是临时运行时状态绝不备份、不同步——在不同机器或不同时间恢复过期的temp.*条目可能导致静默数据损坏(例如重新执行一次已经发生过的迁移)。app.user_data_path是"可执行文件路径 → userData 目录"的映射表,之所以不用裸app.getPath('exe')而是用getNormalizedExecutablePath()(见 userDataLocation.ts),是因为 Linux AppImage 与 Windows 便携版的 exe 路径跨启动不稳定(AppImage 挂载点、PORTABLE_EXECUTABLE_DIR变化)。

4.4 提交步骤的原子性

commitUserDataRelocation(execution.ts)执行两次 BootConfig 写入一起提交:把规范化的目标路径写入app.user_data_path[exe],同时清除temp.user_data_relocation。如果persist()失败,会在重新抛出前恢复内存中的旧状态,防止后续 flush 记录一个文件系统事务已被回滚的路径。


五、Preboot 阶段的硬性约束

runUserDataRelocation()运行在application.bootstrap()之前。README 明确指出这些约束是时序的固有结果,而非风格选择,理解它们是理解本模块实现的关键:

  1. 没有生命周期服务可用——执行路径上任何代码都不得使用application.get()
  2. 进度窗口绕过 WindowManager——window.ts直接构建原始BrowserWindow,通过专用裸 IPC 通道(UserDataRelocationIpcChannels)+simplest.jspreload 通信。原因是迁移启动期间 IpcApiService 永远不会启动,IpcApi 基础设施全部不可用。这与 migration 窗口采用相同模式;
  3. 待处理的复制必须在第一次app.whenReady()await 之前重定向sessionData到一次性目录,否则 Chromium 会在复制中途开始往源树写入数据;
  4. 任何在窗口创建之前逃逸的错误都会导致硬退出且无 UI,且仍处于 pending 状态的请求会在每次启动时重放——形成启动循环(boot loop)。因此每条失败路径都必须降级为持久化的failed状态,下次启动只会在错误窗口中解释它;
  5. 迁移启动会在application.bootstrap()之前返回main.ts,因此它永远不会消费路径注册表较早的app.session快照。

5.1 sessionData 隔离的实现

对应实现是prepareIsolatedSessionData(execution.ts):在app.temp/relocation-session下用fs.mkdtempSync创建按 taskId 前缀命名的临时目录,然后app.setPath('sessionData', sessionDataPath),使 Chromium 的 Cookies、Local Storage、IndexedDB 等存储落到一次性目录而非源树。如果这一步失败,它会主动 fail 整个迁移并持久化 failed 状态(而不是抛出)——逃逸错误会造成无窗口硬退出 + 每次启动重放 pending 请求的不可恢复启动循环。

5.2 专用窗口与裸 IPC 通道

窗口控制器openUserDataRelocationWindow(window.ts)的特点:

  • 560×380、不可缩放、macOS 隐藏标题栏、其他平台无边框,show: false初始隐藏、ready-to-show后显示;
  • preload 使用 src/preload/simplest.ts(仅暴露@electron-toolkit/preloadelectronAPI),partition: 'user-data-relocation-window'contextIsolation: true
  • 两个ipcMain.handle通道(GetProgressRestart)都经过validateSender校验发送者来源;
  • 关键阶段保护CRITICAL_STAGES = { 'preparing', 'copying', 'committing' }期间窗口的 close 会被preventDefault()拒绝——复制事务进行中不允许用户关掉窗口;非关键阶段关窗等同于请求重启;
  • 渲染进程故障降级render-process-gone/unresponsive/did-fail-load会置unavailable = true,若当前不处于关键阶段则自动重启;窗口有 30 秒的 ready 超时(READY_TIMEOUT_MS),超时后继续"无头(headless)"执行——迁移不依赖 UI 存在;
  • 所有状态都封闭在返回的控制器闭包里,模块本身保持无状态。

渲染侧对应 src/renderer/windows/userDataRelocation/hooks/useRelocationProgress.ts:订阅Progress事件、invokeGetProgress获取初始进度、invokeRestart请求重启。IPC 通道常量定义在 src/shared/types/userDataRelocation.ts:

export const UserDataRelocationIpcChannels = { GetProgress: 'user-data-relocation:get-progress', Restart: 'user-data-relocation:restart', Progress: 'user-data-relocation:progress' } as const

六、迁移安全模型:绝不合并、绝不越权删除

README 的安全模型可以概括为三个层面:模式约束、所有权标记、路径保护。

6.1 模式约束:copy 与 switch

迁移从不合并或清空任意目录

  • copy 模式只接受"缺失或空"的目标目录;目标已存在且非空时在 request/execution 两个阶段都会被拒绝;
  • 非空目标只能在 switch 模式中选择,且 switch 只是切换指针,已存在于目标中的文件原样保留assertUserDataRelocationRequest在 validation.ts:switch 要求目标存在,copy 要求目标为空)。

6.2 所有权标记:.cherry-relocation-owner.json

每个复制请求都有唯一的 taskId。所有临时工作树和被提升(promoted)的目标都携带该任务的所有权标记文件(常量RELOCATION_OWNER_MARKER = '.cherry-relocation-owner.json',内容为{ kind: 'cherry-studio-user-data-relocation', taskId },见 execution.ts)。恢复与回滚只有在标记匹配时才递归删除目录;未知文件、无匹配标记的目标、非空的 aside 目录都会被保留并导致操作安全失败。

关键实现点:

  • 工作目录层级:复制落到workPath/payload/而非workPath本身——fsp.cp必须自己创建目标(Node 24 各补丁版本对目标已存在的行为不一致:24.11 静默合并、24.14 抛ERR_FS_CP_EEXIST,唯一可移植的契约是让 cp 自建目标),而恢复不变量要求 owner 标记在第一个 payload 字节落地前就存在于 workPath 内。两个约束、各占一个目录层级;
  • 恢复逻辑recoverInterruptedCopy(execution.ts)的决策面:
    • 有所有权的 work tree → 删除(从未提升,纯属我们的);
    • 有所有权的 target → 删除(已提升但未提交,源树仍是权威数据);
    • 有 aside → 恢复它,但仅当它仍然为空且目标处没有无主数据;
    • 任何无主数据都保留,最多只删除空目录;
  • 回滚逻辑rollbackCopy(execution.ts):先删 work tree,再删已提升目标(仅当所有权标记匹配,否则报错拒绝删除),最后恢复 aside(aside 最后移动,确保目标处无其他占用)。回滚错误以返回值而非抛出的形式返回,让调用方把原始失败与回滚失败一起报告;
  • 提升前重打标记:复制完成后、rename(payloadPath, pending.to)之前,会在 payload 内重写一次 owner 标记——提升后目标自身必须携带标记,这样"提升与提交之间被中断"的启动也能证明所有权;
  • 提交后清理:提交成功后删除目标内的标记文件(失败仅告警);若存在 aside(原目标为空目录被改名),用非递归rmdir清理——故意非递归,保证认领之后新建的文件永远不会被删除。

6.3 路径校验:13 种拒绝原因

校验核心是assertRelocationPaths(validation.ts),它同时检查字面路径与符号链接解析后的有效路径,防止符号链接别名把目标走私进源树(反之亦然)。完整拒绝原因列表见 src/shared/types/userDataRelocation.ts:

source_missing, target_root, same_path, target_inside_source, target_contains_source, target_protected, target_not_absolute, target_parent_unwritable, target_not_directory, target_in_use, target_not_empty, target_missing, target_work_conflict

典型场景举例:目标必须是绝对路径;源与目标不能相同(字面或 realpath 解析后);目标不能在源树内、也不能包含源树;目标不能是文件系统根;目标必须存在或最近的已存在祖先必须是目录且可写;已存在的目标必须是目录;目标里出现SingletonLock/SingletonSocket(Chromium 活跃 profile 标记)时判定为target_in_use——另一个(可能仍在运行的)Cherry Studio 实例正在使用该目录,必须拒绝。

6.4 三层受保护路径

assertTargetIsNotProtected(validation.ts)按顺序检查三层保护:

  1. 应用目录树(relocation session 根、安装目录、应用根、extra resources、cherry home)——双向重叠(目标在树内或树在目标内)都拒绝;
  2. 众所周知的用户/系统目录(home、appdata、temp、downloads、documents、desktop)——仅精确匹配拒绝,这样应用专属子目录仍然可选;
  3. 操作系统顶层目录——当前平台精确匹配一级路径段即拒绝:Linux 为bin/boot/dev/etc/lib/lib64/proc/root/run/sbin/sys/usr/var,macOS 为system/library/applications/bin/sbin/usr/private,Windows 仅在系统卷上拒绝windows/program files/program files (x86)/programdata/recovery/$recycle.bin

isPathInside有个值得注意的细节(validation.ts):..只有作为完整路径段才被排除——名为..archive的子项虽然也以..开头,但它确实在父目录内部。normalizeForCompare在 Windows/macOS(大小写不敏感文件系统)上做 case-fold 比较。

6.5 复制事务与进度报告

executeRelocation的复制事务(execution.ts):

  1. 复制前重新校验路径并恢复中断的复制;
  2. calculateTotalBytes预扫描源树总字节数(跟随符号链接、环形符号链接返回 0、目录环检测);
  3. assertEnoughFreeSpace检查目标卷剩余空间,安全系数FREE_SPACE_SAFETY_FACTOR = 1.2——复制可能瞬时占用比源更多的空间(分配取整、文件系统元数据),且把目标卷填满到最后一个字节会破坏从该卷启动的应用(execution.ts);
  4. 目标已存在 →rename到 aside(同一卷,提升就是 rename),随后断言 aside 为空(防止"校验后目标被改动");
  5. mkdir workPath→ 写入 owner 标记 → 断言有效分离(assertEffectiveSeparation,符号链接感知);
  6. fsp.cp递归复制到payloadPath,filter 负责:排除源根下的Singleton*、owner 标记、data-reset 标记文件(reset 标记绑定源 profile),跳过复制中消失的条目与损坏/环形符号链接,并对每个文件累加字节数发布文件粒度近似进度——进度条最多领先当前正在复制的文件,且只在整数百分比变化时发布,避免小文件洪泛 IPC 通道;
  7. payload 内重打标记 →rename(payloadPath, pending.to)提升 → 清理 workPath →commit()
  8. 任何异常走rollbackCopy,回滚失败会把两条错误消息拼在一起抛出。

进度状态机由RelocationStage定义(src/shared/types/userDataRelocation.ts):'preparing' | 'copying' | 'committing' | 'completed' | 'failed'RelocationProgress携带fromtobytesCopiedbytesTotal与可选error


七、测试覆盖与验证

模块自带完整测试套件,位于 src/main/services/userDataRelocation/tests/:

  • validation.test.ts:覆盖全部 13 种拒绝原因,包括符号链接别名绕过(别名指向源内部的目标)、..archive边界用例、Linux/macOS/Windows 各平台受保护顶层目录、临时 session 根与应用目录树重叠、/var/cherry(非保护)与/var(保护)的精确匹配差异等;
  • request.test.ts:验证请求持久化、磁盘满时的 persist 失败恢复、copy 目标非空拒绝(见request.test.ts'copy target must be empty'断言);
  • execution.test.ts:覆盖runUserDataRelocation的 handled/skipped 分支、failed 状态展示、过期请求丢弃、复制、回滚与提交等 30+ 场景;
  • window.test.ts:窗口控制器行为。

渲染侧另有 src/renderer/windows/userDataRelocation/tests/RelocationApp.test.tsx 与 useRelocationProgress.test.ts;IPC 边界测试见 src/main/ipc/handlers/tests/app.test.ts。


八、整体时序回顾

把请求面与执行面串起来,一次完整的 userData 迁移是:

  1. 用户在设置中选择目标路径,inspectUserDataRelocationTarget反复校验(无副作用);
  2. 用户确认,requestUserDataRelocation生成 taskId、持久化pending到 BootConfig 并立即persist()
  3. 应用 relaunch;
  4. 新启动进入 preboot,runUserDataRelocation读取状态:过期则丢弃,pending+copy 则先隔离 sessionData;
  5. app.whenReady后打开专用进度窗口(裸 IPC + simplest preload,30 秒 ready 超时);
  6. copy 模式:预扫描大小 → 空间检查(×1.2 安全系数)→ 复制到带 owner 标记的 workPath/payload → 提升 → 提交;switch 模式直接提交;
  7. commitUserDataRelocation原子写入app.user_data_path[exe]并清除 pending;
  8. 完成/失败界面确认后 relaunch,resolveUserDataLocation在新启动中从 BootConfig 解析新位置;
  9. 若中途任何环节失败:回滚文件系统、持久化failed、下次启动只在错误窗口中解释,杜绝启动循环。

这套设计把"数据安全"置于一切之上:空目标才复制、标记不匹配绝不递归删除、受保护路径三层拦截、失败必定降级为可解释的 failed 状态——这也是它在 preboot 阶段独立于生命周期运行的底气所在。

【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询