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,含schemaVersion、encryption.iv、encryption.salt(均为 16 字节的 hex 字符串) |
| ciphertext | 变长 | 加密后的载荷密文 |
| tag | 16 字节 | 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 中按以下顺序执行:
- 校验文件以
RAYCFG3\n开头,否则抛.notRaycastFile; - 读取并校验 header 长度,解压 header JSON,解码出
schemaVersion、iv、salt; - 校验
schemaVersion == 3,校验iv与salt各为 16 字节; - 用
scrypt(passphrase, salt, N=16384, r=8, p=1, dkLen=32)派生 32 字节密钥; - 以
iv作为 nonce,构造AES.GCM.SealedBox并AES.GCM.open;认证失败(口令错误)抛.incorrectPassphrase; - 对解密得到的载荷 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 | 魔数不匹配 |
incorrectPassphrase | AES-GCM 认证失败(或文件被篡改) |
corrupt | 结构非法:头部截断、schema 版本不符、iv/salt 非法、header 长度越界等 |
tooLarge | 解压输出超过 512 MB 上限 |
四、数据映射:Raycast 字段如何落到 Tinycast 域模型
解密得到的 payload 是按类别组织的 JSON,顶层包含settings、clipboardHistory、snippets(顶层对象,其条目以title命名)、quicklinks(内含quicklinks与openWithPlatforms)。把这些 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 字段 | 映射说明 |
|---|---|---|
openAtLogin | launchAtLogin | 布尔直传 |
hyperKeyIncludeShift | hyperKeyIncludesShift | 布尔直传 |
hyperKeyCode | hyperKey | 字符串代码经hyperKeyCodes表转换(caps_lock/right_control/right_shift/right_option/right_command);没有映射到.none的项,因此不会清空已有设置 |
showInMenuBar | showInMenuBar | 布尔直传 |
skinTone | emojiSkinTone | 通过递归搜索skinTone键获取;"default"映射为.none |
popToRootTimeout | popToRootSeconds | 仅精确匹配:不在 Tinycast 选项集内的超时值直接跳过,不做截断 |
windowMode | compactMode | Raycast 是字符串,Tinycast 只有紧凑开关:"compact"→ true |
showFavoritesInCompactMode | showFavoritesInCompactMode | 布尔直传 |
4.2 热键映射:一律映射为.combo
Raycast 的每个热键都遵循同一形态,因此 binding(from:) 统一解析:
- 按键必须是
LayoutIndependent键码(type == "LayoutIndependent"),保留code作为 Carbon 键码; - 修饰键名称映射:
Meta→Command、Ctrl→Control、Alt→Option、Shift→Shift; - Raycast 没有双击(double-tap)绑定,所以导入的热键一律构造为
.combo类型。
热键来源有三处(mapHotkeys):
settings.general.globalHotkey→ 调色板开关热键;settings.commands[]中的macosHotkey,按extensionId分发:e:r:clipboard-history→ Tinycast 剪贴板历史命令;e:r:emoji-picker→ Tinycast 表情搜索命令;e:r:applications→ 应用热键,按 bundle ID 归类(见下文路径解析);
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); - 文本:取第一个
mimeType以text/plain开头的 representation 的content,非空则生成文本剪贴条目; - 图片:取
mimeType以image/开头且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<<0 | shortcuts | 热键 + 相关设置(含 hyper key) |
| 1<<1 | favorites | 收藏应用 |
| 1<<2 | emojiSkinTone | 表情肤色 |
| 1<<3 | launchAtLogin | 登录自启 |
| 1<<4 | menuBarVisibility | 菜单栏图标显示 |
| 1<<5 | clipboardHistory | 剪贴板历史(含每应用排除列表) |
| 1<<6 | popToRoot | 返回根的超时设置 |
| 1<<7 | compactMode | 紧凑模式及其中收藏显示 |
| 1<<8 | snippets | 文本片段 |
| 1<<9 | aliases | 应用别名 |
| 1<<10 | quicklinks | 快捷链接 |
Result.selecting(_:)(RaycastImport.swift)按所选类别裁剪结果:由于apply()本身是逐字段的,裁剪只需丢弃未选类别对应的字段即可,不会影响已选部分。界面上的类别选择器(RaycastImportSelection.swift)以三列网格 + 全选/全不选按钮呈现,与 Backup 面板和首次启动引导共用。
六、导入执行流程:主线程外完成重活
BackupActions.importRaycast 是导入的入口,其执行顺序与容错策略如下:
- 解密与映射移出主线程:
Task.detached(priority: .userInitiated)+autoreleasepool包裹RaycastImportReader.read(...).selecting(options),使大型 JSON 树能一次性释放内存; - Snippets 导入是"上报"而非"抛出":
importSnippets失败只记录snippetsError,不影响其余数据继续导入;导入前若 snippets 开关已开,会先启动 snippets 存储,让导入的片段立刻进入启动器;若导入成功但开关未开,snippetsNeedEnabling会提示用户去设置中开启关键词(导入任何内容都不会授予按键监听权限); - Quicklinks 合并:走
addImportedQuicklinks,若 quicklinks 存储不可用,记quicklinksError但继续; - 设置与热键应用:
result.backup.apply(to: core)返回逐类别的ApplySummary; - 剪贴板历史写入:
clipboardStore.importEntries; - 返回
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只是数据:Result、selecting(_:)与RaycastImportOptions。
"由谁校验载荷"也因此被确定下来:解码器只保证容器本身有效,RaycastImportReader才负责把载荷映射为域模型——映射时的精确校验(如PopToRootTimeout、EmojiSkinTone、HyperKeyPhysicalKey、KeyShortcut等类型约束)都发生在 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 长度字段注入0、1、0x00100001、0xffffffff等越界值,确认全部被拒; - 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),仅供参考