AJAZZ AK35i V3 Max 是一把三模机械键盘,右上角有一块 240 × 135 的 TFT 彩屏,会显示日期、时间、电量和连接模式。厂商只提供 Windows 驱动,macOS 下没有任何官方工具。

结果是一个原生 macOS 控制中心加命令行工具,1297 行 Swift,从「屏幕时间不准」到发布 DMG 都发生在同一个晚上。界面有七个页面,其中两个可用,五个锁着。

这篇讲那五个为什么锁着——答案和「还没写完」无关,也和「出于安全考虑的克制」这种说法没太大关系。

一、屏幕上的时间是错的

起点很小:键盘当时是蓝牙连着的,屏幕上的时间不准,而 Mac 上没有任何东西能改它。

官方路线是抱去一台 Windows 电脑,插线,打开驱动,点一次同步,再拔回来。

我问的是:难道 Mac 就一点办法都没有了吗。然后是第二个问题——如果我给你 Windows 驱动软件,你能手搓一个简易的 Mac 驱动吗。

这个项目就是从第二个问题开始的。

二、做出来的东西

九个源文件 1116 行,加十二个测试 181 行,一共 1297 行 Swift。仓库里另有 519 行 markdown。

产物是一个单文件二进制,同时是 GUI 和 CLI。判定逻辑在 AK35iLaunchContext:Finder 启动应用包时会追加一个 -psn_ 开头的进程序号参数,带它就开图形界面;从可执行文件路径看出自己在 .app/Contents/MacOS/ 里而又没有参数,也开图形界面;其余情况当命令行解析。

ak35i status [--json]              查看 USB HID 接口与各功能状态
ak35i time sync [--utc8] [--apply] 预览或写入屏幕时钟
ak35i preview time [--utc8]        不连接键盘,只打印将要发送的报文
ak35i autosync enable|disable      开关每日自动校时
ak35i backup | restore             查看备份安全状态

autosync enable 会在 ~/Library/LaunchAgents/ 写一个 plist,StartInterval 为 86400,同时 RunAtLoad

七个页面是概览、时钟、电量、灯光、屏幕、改键、宏。概览只读,时钟可写,其余五个打开后是一把锁的图标加一句话,说明这个功能缺什么。

三、时钟协议是借来的,不是逆向出来的

这一点值得先说清楚,因为它决定了后面所有事情的顺序。

那四个时钟报文不是从厂商驱动里逆出来的。它来自一个已经为同一个硬件 ID 公开过私有协议文档的开源项目——Aiacos/ajazz-control-center,一个跨平台的 AJAZZ 设备控制中心。协议在它的仓库里有专门的文档目录。

拿到的结构是四个连续的 64 字节 Feature Report:

1  Start      0x04 0x18 后面补零
2  Preamble   0x04 0x28 ... 第 9 字节 0x01
3  Data       0x01 0x5A 年偏移 月 日 时 分 秒 · 第 11 字节星期 · 末尾 0xAA 0x55
4  Save       0x04 0x02 后面补零

代码里构造的是 65 字节数组,第 0 位留给报告 ID,写的时候再剥掉——下一节的缺陷就出在这一位上。年份存的是相对 2000 的偏移,所以有一个钳制:小于 2000 写 0,大于等于 2255 写 0xFF。星期把 Calendar 的 1 到 7 减一,变成 0 为周日。

四个报文之间各睡 30 毫秒,每写完一个还会做一次不检查返回值的读回,最后再睡 100 毫秒。

真正决定这个项目形状的,是时钟在安全模型里的特殊位置。 代码里有一个 SafetyGate,它回答的问题是「这个功能出错了能不能回滚」:

enum SafetyGate {
    static func backupAvailability(for feature: AK35iFeature) -> BackupAvailability {
        switch feature {
        case .clock:
            return .notNeeded("时钟同步是一次性写入,不存在可导出的板载时钟配置。")
        case .screenUpload:
            return .unavailable("屏幕内容尚无经验证的硬件读取协议,不能伪造备份。")
        case .diagnostics:
            return .notNeeded("设备诊断不修改板载配置。")
        case .battery, .lighting, .keymap, .macros:
            return .unavailable("此功能尚未取得 V3 Max 的读取与回滚抓包,不能安全备份。")
        }
    }
}

时钟是唯一一个 .notNeeded。不是因为它风险低,而是因为对它而言「先备份再写入」这个要求本身是空的——键盘里没有一份可以导出的时钟配置,写错了下一次写对就行。RGB、改键、宏都不是这样:它们改的是板载 Flash 上你读不出来的状态,没有基线就没有回滚。

解锁一个功能要四类证据:单项操作对应的报文、设备应答、断线重连后的持久化,还有一份可恢复的基线。时钟天然满足第四条,其余五个一条都不满足。

四、三个缺陷

1. 接口返回成功,屏幕上的时间是错的

第一版工具跑完之后,终端打印的是:

已找到:USB KEYBOARD (VID:PID 0C45:8009, 无序列号)
待同步的本地时间:2026-07-26 19:02:44
同步报文已写入。

看起来全对。我抬头看键盘屏幕——时间是错的。

这比「写入失败」难查得多。IOHIDDeviceSetReport 返回 kIOReturnSuccess,传输层没有任何异常可报。唯一说它错了的,是一块屏幕。

第一个假设走偏了:怀疑是型号对不上,因为手上那份说明书写的是「AK35I V3 单模有线」,那个版本不带屏幕,可能是另一条固件分支,于是打算放弃从同硬件 ID 借来的那套协议。

真正的原因在传输适配层,和协议无关。hidapi 这类跨平台库表示「无编号报告」的办法,是在缓冲区第 0 位补一个虚拟的 0x00,所以总长度是 64 + 1。而 macOS 的 IOHIDDeviceSetReport 把报告 ID 拿出来做了独立实参:

func IOHIDDeviceSetReport(
    _ device: IOHIDDevice,
    _ reportType: IOHIDReportType,
    _ reportID: CFIndex,
    _ report: UnsafePointer<UInt8>,
    _ reportLength: CFIndex
) -> IOReturn

把 65 字节原样交给它,那个本该被剥掉的 0x00 就成了载荷的第一个数据字节,整包右移一位:固件在第 0 位期待 0x04,读到 0x00;末尾的 0xAA 0x55 被挤出缓冲区。固件收下了一个格式合法但内容错位的包,于是屏幕显示了一个错误的时间——而系统层面一切正常。

修法是一个函数:

enum HIDFeatureReport {
    /// hidapi represents an unnumbered HID report with a leading zero byte.
    /// IOKit receives the report ID separately, so it must be omitted from the
    /// data buffer passed to IOHIDDeviceSetReport/GetReport.
    static func macOSPayload(from report: [UInt8]) -> [UInt8] {
        precondition(report.first == 0, "AK35i RTC reports are unnumbered")
        return Array(report.dropFirst())
    }
}

对应的回归测试叫 testMacOSHidTransportStripsUnnumberedReportPrefix。它用的时间是 2026 年 7 月 26 日 19:02:44——正是上面那次失败打印出来的那一秒。出错的那一刻被原样冻进了测试。

2. 双击打不开

图形界面做好之后,双击没反应。

原因不在代码里:Finder 会给应用包附加 com.apple.FinderInfo 扩展属性,而这会让 ad-hoc 签名校验直接失败。

修法写进了打包脚本,顺序是先清扩展属性、再签名、再验证,最后回头再查一次

xattr -cr "${staging_path}"
codesign --force --deep --sign - "${staging_path}"
codesign --verify --deep --strict --verbose=2 "${staging_path}"
plutil -lint "${staging_path}/Contents/Info.plist"

if xattr -p com.apple.FinderInfo "${staging_path}" >/dev/null 2>&1; then
    print -u2 "打包失败:应用仍带有 Finder 元数据,未替换现有版本。"
    exit 1
fi

最后那个检查是关键:它宁可让构建失败,也不让一个打不开的包顶掉上一个能用的版本。整个脚本在临时目录里装配,全部校验通过才把旧版本改名成 .previous-<时间戳>、再把新版本移进去。

3. 图形界面说没连接,命令行说连着

同一台机器,同一把键盘,ak35i status 在终端里能读到两条厂商通道、时钟显示「已验证」;图形界面却报设备未连接。

错误码是 0xE00002E2,macOS 的「未获许可」。

根因是设备发现的匹配条件。按 VID/PID 匹配整把键盘,会把标准键盘输入接口也一起匹配进来——而 macOS 对图形应用访问键盘输入接口有输入监控的隐私保护。终端进程有这个权限,所以同一份逻辑在 CLI 里正常,在 App 里被拒。

修法是发现阶段只匹配两条厂商私有集合,根本不去碰普通键盘那条:

/// Restrict discovery to non-keyboard vendor collections. Matching only by
/// VID/PID also includes the standard keyboard collection, which macOS
/// protects with Input Monitoring consent for graphical applications.
static let discoveryCollections = [
    AK35iHIDUsage(
        usagePage: AK35iHIDClockTransport.controlUsagePage,
        usage: AK35iHIDClockTransport.controlUsage
    ),
    AK35iHIDUsage(usagePage: 0xff68, usage: 0x0061),
]

写入路径的匹配更严一档:四个条件全中,且发现列表里恰好只有一个设备才打开,否则抛 ambiguousDevice 停下。

十二个测试里有三个分别锁着上面三个缺陷。这套测试与其说是在验证功能,不如说是一份「这个晚上踩过什么」的清单。

五、逆向 28.9 MB 驱动,解锁了零个功能

时钟跑通、UTC+8 也对上之后,才轮到那个原始问题:其他功能能不能一起做。

厂商驱动包 28,924,504 字节,里面只有一个 Windows PE32 可执行文件,29,483,325 字节,是个 Inno Setup 6.1.0 安装器——PE 映像本身约 0.9 MB,剩下约 28 MB 全是安装载荷。

为了不在 Mac 上装来源不明的 Windows 软件、也不为此装一堆本机依赖,解包放在一次性 Alpine 容器里做,驱动包和安装器都只读挂载,容器里没有启动任何 Windows 可执行文件。innoextract 解出 145 个文件。

看到的东西不少:

  • 真正的控制程序是 app/DeviceDriver.exe,安装器里两个同名条目逐字节相同。
  • config.xml 把物理身份钉死了:USB 条目是 VID_0C45&PID_8009&MI_00,产品名 USB KEYBOARD,和 Mac 上看到的一致;另有一个 2.4G 接收器条目,未验证。
  • 程序动态解析 hid.dllHidD_GetFeatureHidD_SetFeatureHidP_GetCaps,并用 DeviceIoControl(0xB0192) 取报告描述符。HidD_GetFeature 的缓冲区大小硬编码为 0x41,也就是 65——和时钟通道实测的「64 字节载荷加一个报告 ID」对得上。
  • 屏幕参数写着 240 × 135,RGB565 转换在 mui.dll 里,单个 GIF 槽位,帧头 256 字节,最多 141 帧。RGB 的亮度和速度上限都是 5。
  • 包里还躺着一个 AJAZZ AK820Pro 的示例 GIF——说明这是一套多型号共用的平台,包里的资源不能当作这一台的能力证明。
  • 甚至定位到了一个 33 字节的逻辑 Output 包构造器,会写低 8 位校验并重试读取确认。

最后这一条最接近「可以抄了」。但它卡在同一个地方:没能把这个构造器的调用点可靠地映射到界面上任何一个具体功能。 知道包长多少、校验怎么算,不等于知道哪一串字节对应「把灯调成红色」。

要跨过这一步,唯一的路是在 Windows 上用 USBPcap 做「单项修改 → 保存 → 重连」的逐项抓包。这条路当晚认真评估过:M1 上跑 Windows 11 ARM 虚拟机、把键盘 USB 直通进去是可行的,但 USBPcap 这类内核抓包方案在 ARM Windows 上不能当作前提。

虚拟机没有装。抓包没有发生。

所以整个静态逆向的产出不是任何一个功能,而是一份边界表。九行里三行「已验证」,五行「锁定」,一行「不承诺」——三个已验证的分别是 USB 设备发现、屏幕时钟同步,以及发现了那条 4 KiB 的屏幕数据通道(发现通道不等于知道怎么用它)。

界面上那五把锁不是占位符,是这张表在 UI 上的投影。

六、发布前删掉的五样东西

决定公开之前问了一句:这个仓库能推到 GitHub 吗,会不会泄露我的隐私。

当时列出来的有五项,今天都能在仓库里验证结果:

  1. 实体键盘序列号当时写在驱动溯源文档里。现在那份文档的第一条是「公共版本不记录任何实体设备序列号,也不以序列号末尾作为型号或驱动匹配证据」。
  2. 个人命名空间。Bundle ID 和 LaunchAgent 标识里带着本机用户名,改成了中性的 dev.ak35i.controlcenter.clock-sync
  3. Git 提交身份。首个公开提交会把本机 Git 身份带出去,改成了仓库专用身份。
  4. 运行时的序列号回显。GUI 和 CLI 当时会显示键盘序列号,自动校时的日志也可能记下它。处理方式不是在显示层过滤,而是在数据模型的边界上强制清空——AK35iDeviceSnapshot.from 重建每一个接口快照时把 serialNumber 一律写成 nil,快照自身的序列号字段也是 nil。测试 testDeviceSnapshotRedactsSerialNumbers 把这件事锁住了。
  5. 许可证。仓库当时没有 LICENSE,别人看得到代码但没有复用授权。补了 MIT,另加一份 NOTICE 说明商标归属、以及本仓库不包含或分发任何厂商驱动、固件、图片和 GIF。

第 4 条的做法值得单独说一句:把脱敏放在模型构造处而不是展示处,意味着之后任何新加的界面、日志或 JSON 输出都不可能再把它漏出去,因为上层根本拿不到那个值。

厂商材料靠 .gitignore 挡住——artifacts/dist/ 都不进版本库,仓库里实际跟踪的只有 36 个文件。

七、它没解决的那件事

工具做完之后我问了一句:蓝牙模式不能控制吗。

不能。而且这不是 macOS 的限制——厂商自己那份 Windows 驱动的语言资源里就明确写着蓝牙模式不支持设置。2.4G 有设备配置条目,但控制协议没有验证过。

于是实际用法是:切到 USB 同步一次时间,再切回蓝牙用。时钟会保留。

然后是那句真正的评价——那我每次对时间都要找一根数据线,岂不是很麻烦。

这是这个项目最诚实的一处结论。它解决的是「屏幕上的时间是错的」,没有解决「不用插线」。一把平时走蓝牙的键盘,为了校时得翻出数据线,这个成本和原来抱去 Windows 电脑相比降了很多,但没有降到零。

还有一处是实打实的疏漏:整个时钟功能建立在 Aiacos/ajazz-control-center 公开的协议文档上,而仓库从 README 到 NOTICE 到源码注释,没有任何一处提到它。NOTICE 花了三段声明商标归属、声明不分发厂商驱动与固件,唯独漏掉了真正被借用的那个上游。这是应该补的。

八、下次怎么做:那份没执行的工作单

仓库里有一份 CAPTURE_WORKSHEET.md,写得相当完整:准备步骤、每项功能的录制顺序、优先级、每个功能要交付什么。

它规定每次只改一个设置,录制前后都要截图,改完还要再录一遍恢复基线的过程;交付物包括基线抓包、单项变更抓包、操作录像、驱动哈希和效果照片。

这份工作单一次都没有执行过。

它写在那个晚上的末尾,而不是开头。顺序反了——如果先确认「抓包这条路在这台机器上到底走不走得通」,就会更早知道 ARM Windows 上的内核抓包不是一个能当前提的东西,也就能更早决定:要么去借一台 x86 Windows,要么干脆把项目定位成「只做时钟」,不去规划那五个页面。

实际发生的是五个页面先摆在了界面上,然后一直锁着。

下次的顺序应该是:先验证取证手段本身可用,再决定产品范围。一个做不到的解锁条件和一个没有的解锁条件,对用户来说是同一件事——但只有前者会让你多写五个页面。


代码在 Ike-li/ak35i-v3-max-macos-control-center,MIT。