别拿主力机试:上手 HermesEnabler 前必须知道的五个坑
别拿主力机试:上手 HermesEnabler 前必须知道的五个坑
HermesEnabler 2 干的事情很直白:读写 Apple Watch 上的 MobileGestalt 缓存,改掉里面的 DeviceBrand、CacheExtra 能力位,让非 Hermès 机型也能在表盘列表里看到 Hermès 表盘,顺带放开 Always-On Display、Action Button、Double Tap、深度计、常亮指南针等一串"能力开关"。
它不是越狱,也不是装插件,而是用 MCM 容器的沙盒扩展做一次"临时越权",直接改系统缓存文件。这意味着两件事:一旦写坏,回滚通道只有 App 自己;而 App 本身又是靠免费签名侧载进来的,通道有保质期。下面这五个坑,全部能在仓库源码里找到对应证据,按"破坏力从高到低"排序。
先搞清楚它到底动了什么
核心文件只有一个:BQCore.m(1855 行 Objective-C),对 SwiftUI 层暴露的接口在 BQCore.h,Swift 侧通过 Bridging-Header.h 引入。
目标路径写死在文件顶部:
static NSString * const kMGCaches = @"/private/var/containers/Shared/SystemGroup/systemgroup.com.apple.mobilegestaltcache/Library/Caches";
写入方式不是"改文件后覆盖",而是 open(O_RDWR|O_NOFOLLOW) + ftruncate(fd, 0) + 重写 + fsync,失败才退回临时文件 + rename:
if (BQInPlaceWrite()) {
int fd = open(path.fileSystemRepresentation, O_RDWR | O_CLOEXEC | O_NOFOLLOW);
if (fd >= 0) {
BOOL ok = (ftruncate(fd, 0) == 0) &&
bq_write_all(fd, data.bytes, data.length) &&
(fsync(fd) == 0);
...
而写入之前,BQSaveGestalt 一定会先调 bq_auto_backup;再往前,bq_create_gestalt_backup 对 initial 类型的备份有一道硬保护:
if (initial && [[NSFileManager defaultManager] fileExistsAtPath:BQBackupPath()]) {
if (err) *err = @"An initial backup already exists and will not be overwritten.";
return nil;
}
理解这三段代码,"五个坑"基本就成立了一半。
坑一:initial 备份只有一次机会,而它存在 App 容器里
这是最容易犯、也最不可逆的错。
initial 备份落在 Documents/OriginalGestalt.plist,由 BackupView.swift 的"Create Initial Backup"按钮触发。代码规定它永不被覆盖,也没有任何 UI 能重建它——一旦你的第一次"初始备份"是在已经用别的工具(同类 Gestalt 修改器、测试版描述文件、其他魔改脚本)动过缓存之后做的,那么你备份下来的就是"已经被污染的状态",而真正的原厂值从此无处可取。
更麻烦的是它和 bundle id 绑定。App 的数据容器由 bundle id 决定,而 HermesEnabler2.entitlements 里的 App Group 也是按 bundle id 拼接的:
<key>com.apple.security.application-groups</key>
<array><string>group.$(COMPANION_BUNDLE_IDENTIFIER)</string></array>
工程默认值是 com.example.HermesEnabler(见 project.pbxproj 中的 COMPANION_BUNDLE_IDENTIFIER)。如果你某次重签换了 bundle id,或者干脆卸载重装,Documents/OriginalGestalt.plist 和 Documents/MGBackups/ 里的所有历史备份一起消失,手表却还停在被改过的状态。
最小验证:第一次打开 App 时不要点任何开关,先只点"Create Initial Backup",然后进"MG Backups"确认列表顶部出现 Initial Backup。这一步花不到 10 秒,是整个流程里性价比最高的 10 秒。
止损动作:备份完成后立刻把 OriginalGestalt.plist 导出来(用 Xcode 的 Devices 窗口下载手表 App 容器),存到电脑上。别指望"App 里有一份"就等于安全。
坑二:Restart Carousel 不是重启,是故意把 WebKit 撑爆
改了表盘能力位后需要让 Carousel 重新加载,App 里对应的按钮是"Restart Carousel",走的是 BQRespring()。名字叫 respring,实现却是一段压力测试代码:respring.html 里先塞 500 个带 backdrop-filter: blur(100px) 的全屏图层,再用 setInterval(..., 0) 每轮做三件事:
for (let i = 0; i < 500; i++) {
let d = document.createElement('div');
d.style.cssText = 'position: absolute; width: 100vw; height: 100vh; backdrop-filter: blur(100px); ...';
container.appendChild(d);
}
setInterval(() => {
navigator.share({ title: 'R', text: 'R'.repeat(100000) }).catch(() => {});
let x = new Uint8Array(1024 * 1024 * 10);
crypto.getRandomValues(x);
}, 0);
也就是持续申请 10 MB 随机缓冲区 + 反复触发分享面板,用内存与渲染压力把前端进程打崩,从而"重启"。这套做法来自项目致谢里提到的 Gester,能在 watchOS 上工作,但它有两个现实后果:
- 触发瞬间手表会明显卡顿几秒、发热、掉电,这是预期行为,不是故障;
- 如果系统先杀掉的是 HermesEnabler 自己(而不是 Carousel),那么
gRespringWebView就没了,界面上的"正在重启"永远等不到结果,你只能手动长按侧边按钮关机再开。日志里对应的失败信息是respring: required window, classes or HTML unavailable或respring: WebKit unavailable,两行都写进Documents/bqtool.log。
真正的风险在于时间点的叠加:ftruncate(fd, 0) 把 com.apple.MobileGestalt.plist 清空之后才开始写。代码在同一进程内做了内存回滚(bq_write_data 里保留 original 并重新写回),但这套回滚救不了"写到一半设备断电/被强制重启"。所以在电量低于 30%、或者手表正在充电但线接触不良的时候,不要碰任何写入类开关。
最小验证:随便点一个无副作用的能力开关(比如 Battery Information),确认返回的是 ... turned on. Restart Carousel to reload it. 而不是 Could not update ...。先验证"读—写—校验"这条链路是通的,再去点 Hermès。
止损动作:写入前后保持手表在充电器上;respring 卡住超过 30 秒,直接手动重启手表,不要连续点按钮。
坑三:报错会指导你去开一个 UI 里根本不存在的开关
这是我在读源码时最想提醒后来者的一点。
沙盒逃逸不是一条路,而是七条路依次尝试,注释写得很清楚(BQCore.m 中 BQEscape 的 1–7 步):class 13 的 part-domain 遍历("Works on builds where the traversal is not patched (27.0.x), fails on 27.2")、app group 的 part 目录符号链接(class 7,27.2 主力路径)、class 2 的 staged symlink、class 2 subpath、class 2 parent(默认关闭)、class 13 直连、class 12 geod 遍历。
其中 class 7 依赖 App Group。而用 Sideloadly 配免费 Apple ID 签名时,App Group 这类 capability 通常拿不到匹配的 provisioning profile,BQGroupID() 会一路回退:
NSString *defaultGroup = [NSString stringWithFormat:@"group.%@", [[NSBundle mainBundle] bundleIdentifier]];
if ([[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:defaultGroup]) { ... }
else if ([[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.kamui.escape"]) { ... }
两个都不可用就返回 nil,bq_escape_symlink 直接 -6,最终你看到的提示是:
no app group — open Settings → Class-2 fallback (experimental), enable it, then retry
问题是:这个"Settings"在仓库里没有入口。全仓库搜索 BQSetClass2ParentEnabled / BQClass2ParentEnabled,只出现在 BQCore.h 的声明和 BQCore.m 的实现里;ContentView.swift 的 List 只有 Features、Utilities、Status 三个 Section,Settings 从未被构造。也就是说,这条错误信息把你指向了一个当前源码版本并不提供的开关。
顺带一个更隐蔽的中间态:class 7 路径会调用 bq_swap_path,把 app group 的 Library/Caches 改名成 Caches.bqtool,再建一个指向 MobileGestalt 目录的符号链接。正常收尾走 BQEndEscape 还原;如果进程在中间被杀,下次启动靠 BQCoreInit 里的 bq_recover 抢救。class 2 parent 更是直接搬走 App 自己的 Library 目录,所以代码里特意加了崩溃哨兵:
if (gClass2ParentEnabled && [defs boolForKey:@"BQClass2ParentCrashGuard"]) {
gClass2ParentEnabled = NO;
...
BQLog(@"class2-parent disabled after a previous attempt did not finish");
}
最小验证:先只点"Create Initial Backup"。如果备份成功,说明你至少有了一条可用的逃逸路径(备份本身就要求逃逸成功);如果失败并弹出"MobileGestalt Access Failed",说明当前签名/版本组合下所有路径都没走通,此时任何开关都不会生效,硬点只会浪费耐心。
止损动作:遇到 -6 不要反复重试。换签名方式(保留 App Group 能力)、或换一个有 App Group 的 bundle id 重新构建,比在手表上戳十次有用。自建 IPA 可以走 scripts/create_ipa.sh,注意它 --help 里自称 build-unsigned-ipa.sh,是脚本改名后遗留的不一致,按实际文件名调用即可,别被 usage 误导。
坑四:版本三道门,watchOS_DEPLOYMENT_TARGET = 26.0 会骗过你
README 写得很克制:"仅支持 watchOS 27.0 至 27.3 beta 1"(README.zh-CN.md)。但工程配置里 WATCHOS_DEPLOYMENT_TARGET = 26.0,这意味着安装器不会拦你——装得上,和能用,是两件完全不同的事。
真正的门在 DeviceBrand 的偏移表上,只有六个 build 被验证过:
static const BQDeviceBrandLayout kDeviceBrandLayouts[] = {
{"24S5284h", "DB380F61-0014-418D-9799-876666B459E3", 0x1950, 0x348, 0x16e9},
{"24R359", "6F8372B0-1583-4031-A4BC-A54A18B0DD5A", 0x1947, 0x348, 0x16e1},
{"24R364", "6F8372B0-1583-4031-A4BC-A54A18B0DD5A", 0x1947, 0x348, 0x16e1},
{"24R365", "6F8372B0-1583-4031-A4BC-A54A18B0DD5A", 0x1947, 0x348, 0x16e1},
{"24S118", "32B4CA84-FBDA-496C-9B79-2101D597158B", 0x1950, 0x348, 0x16e9},
{"24S132", "32B4CA84-FBDA-496C-9B79-2101D597158B", 0x1950, 0x348, 0x16e9},
};
不在表里就直接拒绝写入,不会"尽力而为":
if (!layout) {
if (err) *err = [NSString stringWithFormat:@"DeviceBrand CacheData layout is not verified for build %s.", build];
return NO;
}
build 来自 sysctl kern.osversion。所以 27.3 beta 2、27.4、以及未来任何一次小版本更新,都会稳定复现这条错误——它不是坏了,是偏移表还没跟上。同一套逻辑在 Ultra Faces 的 bq_ultraCache 里还重复了一遍,用 CacheUUID + CacheData 长度做二次校验。
第四条门最容易被忽略:恢复备份也校验 CacheUUID 和长度。
if (![current[@"CacheUUID"] isEqual:backupPlist[@"CacheUUID"]] ||
[current[@"CacheData"] length] != [backupPlist[@"CacheData"] length]) {
BQEndEscape(h);
if (err) *err = @"Backup cache layout does not match this watch.";
return NO;
}
手表 OTA 升级之后,系统会重写缓存布局,于是你升级前那些 auto-* / manual-* 备份一夜之间全部作废,只剩一句"Backup cache layout does not match this watch."。想回滚,必须在升级之前回滚。
最小验证:不需要连 Xcode。直接点一次 Hermès 开关,如果失败,Status 区域会原样返回 DeviceBrand CacheData layout is not verified for build 24Sxxxx.,那串 build 号就是你当前系统的真实版本——这比翻设置里的版本号更可靠。
止损动作:把手表系统更新关掉(或至少在做实验的周期内不升级);看到"layout is not verified"就停手,别去试 Ultra Faces、分辨率、地区这些同样依赖布局的开关。
坑五:开关只是"宣告能力",不是"添加硬件"
bq_setMGCacheExtraKeys 的实现非常朴素——往 CacheExtra 字典里塞 @(enabled),再读回来比对:
NSMutableDictionary *updated = [cacheExtra mutableCopy];
NSNumber *storedValue = @(enabled);
for (NSString *key in keys) updated[key] = storedValue;
plist[@"CacheExtra"] = updated;
写的是 DeviceSupportsAbsoluteDepth、DeviceSupportsContinuousHeartRate、DeviceSupportsAlwaysOnDisplay 这类布尔字段。系统读到 YES 后会不会真的提供功能,取决于硬件与服务资格,代码注释也承认这点:"Depth sensor capability used by supported Apple Watch models."、watchOS 可能直接忽略 "fields that its build, hardware, language, or Apple services do not support"。
App 自己在 UI 上也没有掩饰:
- SensorsAndWorkoutsView.swift:"Capability flags do not add missing sensors or override Health permissions."
- IntelligenceView.swift:"Siri AI needs watchOS 27, a supported watch and a paired Apple Intelligence iPhone. ... These flags cannot add missing hardware or bypass service eligibility."
- DisplayAndControlsView.swift:"Some options require matching display, sensor, or audio hardware."
真正会造成"体验事故"的是两个具体点。
第一,Hermès 与 Ultra Faces 会互相挤占。WatchFacesView.swift 的 footer 明确写着:"If you enable Ultra Faces, you won't be able to use the Hermès Faubourg Party face." 两者改的是不同字段(DeviceBrand 写成小端 32 位 1,Ultra 写 main-screen-class 为 29),但同时开着就有一个表盘用不了。而且 BQSetHermesEnabled 在开启时要求"存在位"必须是 1,否则直接拒绝:
if (enabled && bytes[presentOffset] != 1) {
if (err) *err = @"DeviceBrand is not present in CacheData; no changes written.";
return NO;
}
第二,屏幕分辨率改的是"上报尺寸",不是物理像素。ResolutionOptionsView.swift 只接受六组精确预设,写错一个数字就 Unsupported screen resolution preset.;恢复必须用 (0, 0) 走备份还原分支(连"字段原本不存在"这种情况也一起还原)。文案里那句"A different size can clip watchOS content"是认真的——把 46 mm 报成 41 mm 的逻辑尺寸,界面会被裁。
地区那一组还有独立的分裂风险:RegionCode 与销售区域后缀是两个字段,写销售区域时如果当前布局里新旧两个 key 都存在、且值不一致,读取会直接报错 Sales region fields disagree; choose a value to align them.;改销售区域还可能让工厂信息工具显示"型号不符"。这类副作用不致命,但会让二手转卖和售后检测变得尴尬。
最小验证:一次只开一个开关,返回文案里的动词是 turned on 还是 Could not update,就是系统是否接受的最直接反馈;对需要重启的项(AOD、Action Button、Double Tap、分辨率、地区),重启后再看实际效果,不要靠"点了就以为生效"。
止损动作:把"硬件类开关"当成一次性实验,验证完立刻关掉;Ultra Faces 与 Hermès 二选一,不要为了好奇同时点亮。
最小验证与风险对照表
| 坑 | 一行验证 | 失败即止损 |
|---|---|---|
| 初始备份不可重建 | 首次进入只点 Create Initial Backup,确认 Initial Backup 出现 |
立刻导出 OriginalGestalt.plist,导出前不碰任何开关 |
| respring 是 OOM 诱导 | 点一次 Restart Carousel,接受短暂卡顿与发热 | 卡死 30 秒就手动重启,别连点 |
| App Group 缺失 | 备份能成功 = 逃逸路径可用 | 报 -6 就换签名方式,别反复重试 |
| 版本/build 不匹配 | 点一次开关,读返回里的 build 号 | 出现 layout is not verified 就停手,冻结系统更新 |
| 能力 ≠ 硬件 | 单开关验证,看 turned on 后系统是否真的给功能 |
未生效就关回去,别叠加多个实验变量 |
已经踩坑之后,按这个顺序补救
- 先停手。不要再点任何开关,也不要重复点"Restart Carousel"——多一次写入就多一个中间态。
- 重启手表。让 Carousel 重新读缓存,排除"只是没重载"的假故障;这一步不改变任何数据。
- 进 MG Backups,选
Initial Backup。确认弹窗文案("The current MG will be backed up automatically first")后执行。恢复前代码会自动再备份一次当前状态,所以你不用担心把现场弄丢;恢复本身还会做CacheUUID与长度的二次校验。 - 恢复成功后立刻重启手表,再回表盘列表确认 Hermès 是否回来(如果本来就是靠关掉它来恢复,就确认它已经消失)。
- 如果提示 "Backup cache layout does not match this watch.",说明选中的备份来自另一个 build。改用最近一次
Automatic Backup(auto-前缀,同一系统版本生成),不要强行选旧的initial。 - 如果 App 一打开就提示 MobileGestalt Access Failed,问题在沙盒逃逸而不是数据。先解决 App Group / 签名,再谈恢复;此时重装务必保持同一个 bundle id,否则容器一换,备份就真没了。
- 如果设备已经进不了桌面(开机循环),且免费签名也已过期、无法重新安装同 bundle id 的 App,那么剩下的是抹掉重配对。这一步会清空手表数据,是最后的兜底,不该被当成"重启一下试试"的同类操作。
回到标题那句话:这个项目的技术实现其实相当克制——七条逃逸路径都有回退、每次写入都强制自动备份、每个偏移都做写入后校验、连 class 2 parent 这种危险策略都配了崩溃哨兵。它真正的风险不在代码质量,而在时间窗口:初始备份只有一次、系统更新会让备份失效、免费签名只有几天、respring 依赖一个会被系统先杀掉的 WebView。主力机上省下的那台备用机成本,最后往往要用一次抹机来偿还。
