---
title: "给带屏幕的键盘写 macOS 驱动"
description: "AJAZZ AK35i V3 Max 的屏幕时钟在 Mac 上没法校准，官方只有 Windows 驱动。一个晚上写出了原生控制中心与 CLI：1297 行 Swift，七个页面开两个锁五个。这篇讲那个「接口返回成功、屏幕却是错的」缺陷，以及为什么逆向了 28.9 MB 厂商驱动之后，一个功能都没能解锁。"
pubDate: 2026-07-26
tags: ["工程复盘", "开源", "Swift", "macOS", "反向工程", "硬件"]
---

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/` 里而又没有参数，也开图形界面；其余情况当命令行解析。

```text
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：

```text
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`，它回答的问题是「这个功能出错了能不能回滚」：

```swift
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. 接口返回成功，屏幕上的时间是错的

第一版工具跑完之后，终端打印的是：

```text
已找到：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 拿出来做了独立实参：

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

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

修法是一个函数：

```swift
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 签名校验直接失败。

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

```zsh
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 里被拒。

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

```swift
/// 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.dll` 的 `HidD_GetFeature`、`HidD_SetFeature`、`HidP_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](https://github.com/Ike-li/ak35i-v3-max-macos-control-center)，MIT。
