Tinycast 迁移指南:Raycast `.rayconfig` 文件格式解析、AES-256-GCM 解密与数据导入原理
2026/9/19 11:24:28 网站建设 项目流程

Tinycast 迁移指南:Raycast.rayconfig文件格式解析、AES-256-GCM 解密与数据导入原理

【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast

本文围绕 Tinycast 的 Raycast 数据迁移功能展开,深入解析.rayconfig导出文件的 RAYCFG3 容器格式、scrypt 密钥派生与 AES-256-GCM 解密链路,并完整说明设置、热键、剪贴板历史、Snippets 与 Quicklinks 等 11 类数据到 Tinycast 域模型的映射规则。读完本文,你将掌握该导入功能支持的格式范围、底层解密与校验流程、每个类别的字段映射细节,以及对应的测试验证方式,可直接对照仓库源码进行二次开发或排障。

一、支持范围:只认 v2.x 的.rayconfig

Tinycast 的 Raycast 导入功能只读取 Raycastv2.x导出的.rayconfig文件。该文件是一个RAYCFG3容器——即带版本号魔数的压缩 + 加密容器,内部承载一份 AES-256-GCM 加密载荷,密钥由 scrypt 从口令派生。

需要特别注意的是:v1.x 的导出格式与 Raycast X beta 格式都已不再支持。从v0.10.5起,这两类旧格式不是"保留兼容",而是直接删除、不再承载。这意味着如果你手头只有老版本 Raycast 的导出文件,需要先在 Raycast 中重新导出 v2.x 格式,再交给 Tinycast。

该判断有明确的源码依据:解码器 RaycastDecoder.swift 中硬编码了容器版本常量containerSchemaVersion = 3,任何其他schemaVersion一律抛出.corrupt错误拒绝读取。

二、容器格式:RAYCFG3 的字节级结构

原文档给出了完整的线格式(wire format),这是理解整个导入链路的地基:

file = "RAYCFG3\n" ‖ UInt32LE(header.count) ‖ gzip(header JSON) ‖ ciphertext ‖ tag(16) body = AES-256-GCM(gzip(payload JSON)) key = scrypt(passphrase, salt, N=16384, r=8, p=1, dkLen=32)

对照 RaycastDecoder.swift 的解析逻辑,容器各部分的作用如下:

长度/编码说明
魔数8 字节字面量"RAYCFG3\n"(含尾部换行),用于文件识别
header 长度4 字节小端 UInt32位于文件偏移 8..12,指明 gzip 后的 header JSON 长度
header变长,gzip 压缩JSON,含schemaVersionencryption.ivencryption.salt(均为 16 字节的 hex 字符串)
ciphertext变长加密后的载荷密文
tag16 字节AES-GCM 认证标签

头部长度字段本身不受认证保护,因此解码器对其做了严格边界检查:fixedHeaderLength = 12(魔数 8 字节 + 长度 4 字节),header 长度必须> 0<= maximumHeaderLength(1 MB),并且12 + headerLength不能超出文件总长度,否则直接判定为损坏(.corrupt),不会进入任何解密步骤。

密钥派生:零依赖的 scrypt 实现

解密密钥通过 Scrypt.derive 派生,参数固定为N=16384, r=8, p=1, dkLen=32(RFC 7914 标准参数)。值得注意的是,该实现是仓库自带的完整 scrypt 实现,不依赖任何第三方加密库:它由 PBKDF2-HMAC-SHA256、ROMix、BlockMix 与 Salsa20/8 四部分拼装而成,全程只使用 CryptoKit 的 HMAC 原语。这意味着导入功能在编译和分发时零额外依赖,也便于在无优化构建的测试环境里精确控制其性能开销。

解密与解压的完整链路

一次成功的解密在 RaycastDecoder.decrypt 中按以下顺序执行:

  1. 校验文件以RAYCFG3\n开头,否则抛.notRaycastFile
  2. 读取并校验 header 长度,解压 header JSON,解码出schemaVersionivsalt
  3. 校验schemaVersion == 3,校验ivsalt各为 16 字节;
  4. scrypt(passphrase, salt, N=16384, r=8, p=1, dkLen=32)派生 32 字节密钥;
  5. iv作为 nonce,构造AES.GCM.SealedBoxAES.GCM.open;认证失败(口令错误)抛.incorrectPassphrase
  6. 对解密得到的载荷 gzip 解压,得到最终 payload JSON。

解压上限:内存边界而非 zip 炸弹防护

解码器对解压输出设置了两个上限,理解它们的语义差异很重要:

  • header 解压上限 1 MB:header 是未经认证的数据,因此这个上限同时也是对不可信输入的第一道防线;
  • payload 解压上限 512 MB:由于 AES-GCM 已经对密文完成了认证,这一上限不是zip 炸弹防护,而纯粹是内存边界。文档明确指出,剪贴板内容繁多的导出解压后可能超过 64 MB,因此 payload 上限定为 512 MB(maximumPayloadLength = 512 * 1024 * 1024,见 RaycastDecoder.swift)。

如果解压超限,会抛出.tooLarge错误,对应错误文案建议"清理部分 Raycast 剪贴板历史后重新导出"(见 RaycastImportError.swift)。

口令从哪里来:Tinycast 从不读取钥匙串

一个容易被忽略的事实是:即使你在 Raycast 中从未设置过密码,导出文件也总是加密的——Raycast 会自行生成一个口令并存入登录钥匙串(服务Raycast、账户export_passphrase),可在 Raycast → Settings → Extensions → Export Settings & Data 中查看。

Tinycast不会读取钥匙串,口令完全由用户在导入界面手动输入。这既避免了权限膨胀,也保证了导入动作的完全可审计。

三、加载即识别:容器签名驱动的文件判定

导入界面有一个贴心设计:识别一个文件是否是 Raycast 导出,只看容器签名(魔数),不需要口令。这正是 RaycastDecoder.isExport 的职责——它只检查原始字节是否以"RAYCFG3\n"开头。

因此 Backup 面板在用户选定文件的瞬间就会运行该判定(对应 BackupActions.isRaycastExport,以.mappedIfSafe方式只读文件前部字节);如果用户随后输入了错误口令,界面报的是"口令错误",而不会误报"这不是 Raycast 导出文件"。错误语义的分层非常清晰(RaycastImportError.swift):

错误触发条件
notRaycastFile魔数不匹配
incorrectPassphraseAES-GCM 认证失败(或文件被篡改)
corrupt结构非法:头部截断、schema 版本不符、iv/salt 非法、header 长度越界等
tooLarge解压输出超过 512 MB 上限

四、数据映射:Raycast 字段如何落到 Tinycast 域模型

解密得到的 payload 是按类别组织的 JSON,顶层包含settingsclipboardHistorysnippets(顶层对象,其条目以title命名)、quicklinks(内含quicklinksopenWithPlatforms)。把这些 Raycast 原生值翻译成 Tinycast 域类型的工作由 RaycastImportReader.swift 完成,RaycastImport.Result汇总为四块数据加一个统计值(RaycastImport.swift):

  • backup: SettingsBackup(设置、热键、收藏、别名)
  • clipboard: [ClipboardItem](剪贴板历史)
  • snippets: [Snippet]
  • quicklinks: [Quicklink]
  • missingImages: Int(文件已不存在的图片剪贴记录数)

下面逐类展开映射细节。

4.1 设置项映射

mapSettings 读取settings.general下的字段:

Raycast 字段Tinycast 字段映射说明
openAtLoginlaunchAtLogin布尔直传
hyperKeyIncludeShifthyperKeyIncludesShift布尔直传
hyperKeyCodehyperKey字符串代码经hyperKeyCodes表转换(caps_lock/right_control/right_shift/right_option/right_command);没有映射到.none的项,因此不会清空已有设置
showInMenuBarshowInMenuBar布尔直传
skinToneemojiSkinTone通过递归搜索skinTone键获取;"default"映射为.none
popToRootTimeoutpopToRootSeconds仅精确匹配:不在 Tinycast 选项集内的超时值直接跳过,不做截断
windowModecompactModeRaycast 是字符串,Tinycast 只有紧凑开关:"compact"→ true
showFavoritesInCompactModeshowFavoritesInCompactMode布尔直传

4.2 热键映射:一律映射为.combo

Raycast 的每个热键都遵循同一形态,因此 binding(from:) 统一解析:

  • 按键必须是LayoutIndependent键码(type == "LayoutIndependent"),保留code作为 Carbon 键码;
  • 修饰键名称映射:Meta→Command、Ctrl→Control、Alt→Option、Shift→Shift;
  • Raycast 没有双击(double-tap)绑定,所以导入的热键一律构造为.combo类型。

热键来源有三处(mapHotkeys):

  1. settings.general.globalHotkey→ 调色板开关热键;
  2. settings.commands[]中的macosHotkey,按extensionId分发:
    • e:r:clipboard-history→ Tinycast 剪贴板历史命令;
    • e:r:emoji-picker→ Tinycast 表情搜索命令;
    • e:r:applications→ 应用热键,按 bundle ID 归类(见下文路径解析);
  3. hyperKey相关设置随.shortcuts选项一并导入。

4.3 应用路径 → Bundle ID 的解析

Raycast 的应用命令 ID 中,启动的应用路径藏在::=::分隔符之后。appPath(fromCommandID:)取出路径尾部,再通过Bundle(url:)?.bundleIdentifier解析为 bundle ID(RaycastImportReader.swift)。热键、收藏(favorites)和别名(aliases)三个映射器共用这一套解析逻辑,且只有e:r:applications扩展的命令才参与映射。

  • 收藏:读取favoriteOrder并按升序排序,保留 Raycast 中的顺序,输出为 bundle ID 数组(mapFavorites);
  • 别名:读取非空、去除首尾空白的alias,以 bundle ID 为键写入字典(mapAliases)。

4.4 剪贴板历史映射

mapClipboard 处理clipboardHistory.clipboardEntries

  • 每条记录的items[].representations[]是嵌套结构,需扁平化遍历;
  • 时间戳可能带小数秒:优先用ISO8601DateFormatter.withFractionalSeconds解析,失败再退回整秒格式(parseDate);
  • 文本:取第一个mimeTypetext/plain开头的 representation 的content,非空则生成文本剪贴条目;
  • 图片:取mimeTypeimage/开头且contentType == "url"的 representation,其content是文件路径。只有当文件仍然存在时才生成图片剪贴条目;文件不存在的记录计入missing计数——是"上报"而不是"静默丢弃"(导入结果中missingImages会展示给用户,见 BackupActions.raycastText)。

每条导入记录都会获得全新的UUID,Raycast 原有的 ULID 被丢弃——这与 JSON quicklink 导入的行为一致。

4.5 Snippets 映射

RaycastSnippetImport.parse 读取顶层snippets对象的snippets数组:每条以title为名称、text为内容,keyword去除首尾空白后非空才保留。注意这里snippets是顶层对象(不是数组),其内部的snippets键才是条目数组。

4.6 Quicklinks 映射:合并而非覆盖

RaycastQuicklinkImport.parse 读取quicklinks对象,包含quicklinks数组与openWithPlatforms平台映射:

  • {Query}占位符会被重写为 Tinycast 的{argument}(rewrittenLink),重写是令牌级的:只有命令字为query(忽略大小写)的{...}令牌才被改写,其余原样保留;
  • openWith若以/开头视为应用路径,否则视为openWithPlatforms中的平台 id,两者最终都解析为 bundle ID——与应用热键的解析方式相同;
  • createdAt同样支持小数秒与整秒两种时间戳。

导入时 quicklinks 走的是 QuicklinkArchive.merge 语义:向现有库中新增,绝不整体替换。并且,只要成功导入至少一个 quicklink,就会打开quicklinksEnabled开关——因为打开一个链接不授予任何权限类(见 BackupActions.importRaycast)。

4.7 脚本命令不在.rayconfig

需要注意边界:Raycast 的脚本命令(script commands)不在.rayconfig文件里,它们是 Raycast 指向的文件夹中的文件,因此有独立的导入器,详见 custom-commands.md 中"Importing Raycast scripts"一节。

五、分类导入:11 个独立可选的类别

用户不必全量导入。RaycastImportOptions(RaycastImport.swift)是位掩码 OptionSet,定义 11 个独立类别 +all组合:

类别说明
1<<0shortcuts热键 + 相关设置(含 hyper key)
1<<1favorites收藏应用
1<<2emojiSkinTone表情肤色
1<<3launchAtLogin登录自启
1<<4menuBarVisibility菜单栏图标显示
1<<5clipboardHistory剪贴板历史(含每应用排除列表)
1<<6popToRoot返回根的超时设置
1<<7compactMode紧凑模式及其中收藏显示
1<<8snippets文本片段
1<<9aliases应用别名
1<<10quicklinks快捷链接

Result.selecting(_:)(RaycastImport.swift)按所选类别裁剪结果:由于apply()本身是逐字段的,裁剪只需丢弃未选类别对应的字段即可,不会影响已选部分。界面上的类别选择器(RaycastImportSelection.swift)以三列网格 + 全选/全不选按钮呈现,与 Backup 面板和首次启动引导共用。

六、导入执行流程:主线程外完成重活

BackupActions.importRaycast 是导入的入口,其执行顺序与容错策略如下:

  1. 解密与映射移出主线程Task.detached(priority: .userInitiated)+autoreleasepool包裹RaycastImportReader.read(...).selecting(options),使大型 JSON 树能一次性释放内存;
  2. Snippets 导入是"上报"而非"抛出"importSnippets失败只记录snippetsError,不影响其余数据继续导入;导入前若 snippets 开关已开,会先启动 snippets 存储,让导入的片段立刻进入启动器;若导入成功但开关未开,snippetsNeedEnabling会提示用户去设置中开启关键词(导入任何内容都不会授予按键监听权限);
  3. Quicklinks 合并:走addImportedQuicklinks,若 quicklinks 存储不可用,记quicklinksError但继续;
  4. 设置与热键应用result.backup.apply(to: core)返回逐类别的ApplySummary
  5. 剪贴板历史写入clipboardStore.importEntries
  6. 返回RaycastOutcome,包含各项导入数量、缺失图片数与错误提示,由 raycastText 汇总为一句句可读的摘要,并提示"退出并重开 Tinycast 以完成导入"。

此外导入前后还有两个贴心动作:pickRaycastFile()提供共用的.rayconfig文件选择器(Backup 面板与引导共用);quitRaycast()会退出正在运行的 Raycast(凡 bundle ID 以com.raycast为前缀、且不是后台无界面进程的应用),避免导入后热键冲突(BackupActions.swift)。

七、工程架构:纯层 / 平台层分离

导入链路刻意做了架构分层,与 WindowManagement 使用的模式一致:

  • RaycastDecoder是纯解码层:只做容器解包与解密,返回 Raycast 自身的载荷字节,完全不接触 AppKit/UI。因此它能被 raycast-test.swift 在无 UI 的独立 harness 中编译运行;
  • RaycastImportReader是平台层:需要 AppKit(Bundle解析 bundle ID、文件系统检查),因此位于Service/目录,由应用构建覆盖而非测试 harness;
  • RaycastImport只是数据Resultselecting(_:)RaycastImportOptions

"由谁校验载荷"也因此被确定下来:解码器只保证容器本身有效,RaycastImportReader才负责把载荷映射为域模型——映射时的精确校验(如PopToRootTimeoutEmojiSkinToneHyperKeyPhysicalKeyKeyShortcut等类型约束)都发生在 reader 层,解码器保持类型无关。

八、测试与验证:在 harness 中构建自己的夹具

raycast-test.swift 完整覆盖了容器识别、解密、gzip 切片与解压上限四条路径,其设计理念与文档的 Invariants 严格对应:

  • 绝不提交真实.rayconfig作为夹具:测试在进程内用Scrypt.derive+AES.GCM.seal自行构建容器(口令"12345678"、固定 salt/iv),既避免把用户真实数据引入仓库,也让字节完全可控;
  • scrypt 只派生一次fixture用一次 key derivation 生成全部测试数据,因为无优化构建下每次 scrypt 派生要花费数秒;
  • 识别测试isExport对空数据、无签名数据、"RAYCFG3"(缺换行)都返回 false,验证签名包含尾部换行;
  • 解密测试:正确口令可还原明文;错误口令抛.incorrectPassphrase;非零起始索引的Data切片(slice)同样能正确解密,验证解码器内部偏移量从切片自身起点计算;
  • 前置拒绝测试:去掉签名、短于固定头、未来 schema 版本(schemaVersion: 4)都在触发 key derivation 之前被拒绝;对0..<payloadStart的每个截断位置逐一验证要么.notRaycastFile(截断在魔数内)要么.corrupt;对 header 长度字段注入010x001000010xffffffff等越界值,确认全部被拒;
  • gzip 上限测试:70 MB 载荷在默认 64 MB 上限下抛tooLarge,在 512 MB payload 上限下可正常解压——这个夹具同样在运行时构建,因为超过默认上限的夹具无法常驻仓库。

九、小结

Tinycast 的 Raycast 导入是一个把"外部加密格式 → 内部域模型"做得相当严谨的功能:容器签名先行识别、错误语义精确分层、scrypt 参数与 AES-256-GCM 与 Raycast v2.x 完全对齐、11 类数据各自拥有明确的字段映射、解压上限按"已认证/未认证"区分设计,并通过纯层/平台层分离让核心解码逻辑可以在无 UI 的测试 harness 中被穷举验证。如果你想在迁移后继续导入 Raycast 脚本命令,可查阅 custom-commands.md 中的 Raycast 脚本导入章节;备份与恢复的整体设计则见 backup.md。

【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast

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

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

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

立即咨询