迈从键盘如何自定义“不存在的键”
迈从的驱动确实美观,网页驱动十分跨平台,也十分方便,可惜对 macOS 的支持还是差点。
注:本文写作背景是 2025 年 11 月,关于迈从改键软件的信息可能已经变动。
TL;DR:
按照苹果的文档,编辑 assets/tailwind.js,然后使用代理工具进行覆写。可以移步到文章的末尾查看。
起因
新购买的迈从键盘对 macOS 的支持很有限,F 区不符合通常 macOS 的功能。见:

但是迈从是这样实现的:F3 被设置为了切换窗口,F4 被设置为 Command+H 组合键,F5 被设置为 Command+Space 组合键,F6 被设置为区域截屏(Command+Shift+4)。这其实是很蠢的,因为说明书上说 F4 是显示桌面,F5 是“短按语音,长按 Siri”,但是实际却用组合键来实现,忽视了用户修改组合键的可能。
打开迈从的网页驱动,可以发现,虽然迈从声称对 macOS 有支持,但只有 Windows 模式才支持改键,实在是令人难绷。不过 Windows 和 macOS 主要的区别就是 Windows 键和 Alt 键换一个位置就行,也就是 Windows 换成 Option,Option 换成 Command。接下来就是要修改 F 区。macOS 默认 F 区是媒体键,Fn+F区是原本的 F键,但 Windows 是反过来的,所以需要先把 Fn 层修改为 F1~F12,然后把默认层改成媒体键。其中,亮度、播放、声音一共 8 个键,迈从已经定义,剩下 F3~F6 的 Mission Control,Spotlight,Dictation 和 DND 键是没有定义的,所以需要一些其他方法来实现。
这里要吐槽一下,迈从提供的很多 Windows 相关的功能,本质上也是组合快捷键,哪怕有些功能是有单独的键支持的。
漫长的逆向
既然迈从提供了网页版的驱动,那就能看到他的源代码(虽然是加了混淆的),这对我这个逆向小白非常友好。
但也还是经过了各种艰难的断点调试,关键字搜索。最后终于搞清楚了整个程序是怎么设置的。
破局的关键:debug 模式
function enableDebug() {
debugEnabled || (debugEnabled = !0,
restoreConsole(),
originalConsole.warn("[Debug Mode] 日志已开启"),
localStorage.setItem("debugEnabled", "1"))
}
function disableDebug() {
debugEnabled = !1,
suppressConsole(),
localStorage.removeItem("debugEnabled")
}
function initConsoleDebugHotkey() {
if (typeof window > "u")
return;
localStorage.getItem("debugEnabled") === "1" ? enableDebug() : suppressConsole();
let t = !1;
window.addEventListener("keydown", e => {
const r = e.shiftKey
, i = e.altKey
, s = e.code === "Digit7" || e.key === "7";
r && i && s && !t && (t = !0,
debugEnabled ? disableDebug() : enableDebug())
}
),
window.addEventListener("keyup", e => {
(e.code === "Digit7" || e.key === "7" || e.code === "ShiftLeft" || e.code === "AltLeft" || e.code === "ShiftRight" || e.code === "AltRight") && (t = !1)
}
)
}
通过这段代码可以看到,Shift+Alt+7 可以开启 Debug 模式,这个模式可以输出很多 debug 信息。这段代码在生产模式中还存在就挺难绷的,不过我还是得给这个程序员说声谢谢。搞逆向的肯定都喜欢这些安全意识不高的程序员吧🤣。(不过话说回来,这些代码倒也说不上机密,留这么一个口子说不定方便技术人员帮客户排查难以复现的问题,有利有弊吧)
有了调试信息,那我每一步操作就可以知道是在代码的哪个地方了,要不然几十万行混淆代码,真是大海捞针了。
每当改键行为发生的时候,都会调用这个函数:(这个函数在 tailwind.js 里面)
async writeCommand(e, n, a) {
if (this.isWired) {
let i = z1.getCommandConfig(e).wiredParser;
Array.isArray(i) || (i = await i()),
n.command = z1.getCommandConfig(e).wiredCommand;
const s = await y9(i, "parser", "parser", n, null, {
str: ""
})
, r = new Uint8Array(s)
, o = 519;
return a && a.delay && await new Promise(c => setTimeout(c, a.delay)),
await this.device.sendFeatureReport(this.currentReportId, new Uint8Array([...r, ...new Array(o - r.length).fill(0)])),
console.log("有线写操作完成"),
!0
} else {
let i = z1.getCommandConfig(e).wirelessParser;
Array.isArray(i) || (i = await i()),
n.command = z1.getCommandConfig(e).wirelessCommand;
const s = await y9(i, "parser", "parser", n, null, {
str: ""
})
, r = this.splitCommand(e, s, a);
return await this.sendPacketsWithAck(r)
}
}
这里面可以看到 sendFeatureReport 每次会向 6 这个 id 发送一个 519 字节的字节数组。其格式有待进一步解析。
经过调用栈的分析,可以发现,y9是在做一个序列化的打包,根据 i 提供的格式信息,把 n 里面提供的键位信息进行打包。这个 n 是由 sendCommand 传进来的,e 是 setKeySetting。源头是下面这个函数。
, E = () => {
const {keyLayer: ce} = s;
let oe = S.value.reduce( (ve, Ce) => (ve[Ce.key] = ce === "Default" ? Ce.defaultVal : ce === "Fn" ? Ce.fnVal : Ce.fn2Val,
ve), {});
console.log("keyBoardObj", oe);
let ye = {};
const pe = C[r]
, Ze = G[r];
ce === "Default" ? ye = {
space0: [0, 0, 1, 0, 248, 1],
...pe
} : ce === "Fn" ? ye = {
space0: [1, 0, 1, 0, 248, 1],
...Ze
} : ce === "Fn2" && (ye = {
space0: [2, 0, 1, 0, 248, 1],
...Ze
}),
oe = {
...oe,
...ye,
...d
},
i.emit("change-loading", !0),
navigator.deviceHandler.sendCommand("set", "setKeySetting", oe).finally( () => {
i.emit("change-loading", !1)
}
)
}
这个函数会根据当前修改的键位是 Default, Fn, 还是 Fn2,把上面的内容进行组合,然后 sendCommand。而 E 是由下面这个函数触发的。
, ne = () => {
const {keyLayer: ce, selectedItem: oe} = s
, {name: ye, val: pe} = oe;
if (["Fn", "Fn2"].includes(ce) && ["Fn", "Fn2"].includes(ye))
return _l({
message: n("keyboard.detaultTips")
});
const Ze = S.value.find(Ce => Ce.select)
, ve = S.value.findIndex(Ce => Ce.select);
Ze && JSON.stringify(pe) !== JSON.stringify(Ze[H.value[ce]]) && oe && (S.value[ve].name = ye,
ce === "Default" ? S.value[ve].defaultVal = pe : ce === "Fn" ? S.value[ve].fnVal = pe : ce === "Fn2" && (S.value[ve].fn2Val = pe),
E())
}
而这个函数中,Ze 就是找到要改的键,比如我现在要把 A 改成 B,那么 Ze 就是 A,而 oe 就是 B。
通过进一步分析,还能查找到每个键盘的默认键位,以及可以设置的键位列表。通过搜索键盘型号,就可以跳转到对应的列表,如:
G75_V2: {
baseKeyBoard: dDt,
commonObj: kge,
specialVal: YDt,
lightRgbObj: ODt,
lightDiyObj: PDt,
otherObj: lJt
},
协议分析
分析 S 这个列表,可以发现键盘上每个键都对应了 4 个 1 字节整数,这四个整数就决定了这个键会向电脑发送什么信息。而这个信息和 HID 的 KeyCode,可以在 HID 的手册里面看到。
这个驱动把所有的键分成了 3 类,分别是基础键(也就是各种字符),扩展键(Ctrl,Shift,F区等等),特殊键(各种媒体键,以及组合键等等),在代码里面也能看到,所以就可以分析每个键对应的 4 个整数的含义。
如果是 0 开头,则大多数可以直接发送 HID 的 Usage ID,格式为 [0, 0, 0, HID_USAGE_ID]
对于 Ctrl, Shift,Alt/Option,Windows/Command,则是 [0, KEY, 0, 0],这样设计方便各种组合键。
[
{
name: "Left Ctrl",
val: [0, 1, 0, 0],
},
{
name: "Left Shift",
val: [0, 2, 0, 0],
},
{
name: "Left Alt",
val: [0, 4, 0, 0],
},
{
name: "Left Win",
val: [0, 8, 0, 0],
},
{
name: "Right Ctrl",
val: [0, 16, 0, 0],
},
{
name: "Right Shift",
val: [0, 32, 0, 0],
},
{
name: "Right Alt",
val: [0, 64, 0, 0],
},
];
如果是组合键,那么就是 [0, ControlKey, OtherKey, 0] 或者 [0, ControlKey, 0, OtherKey]
如果是 7 开头,则和键盘的控制有关,比如说连接的设备、切换系统等,8 开头和灯光控制有关。13 开头则是 Fn 和 Fn2 键。
这里重点是 2 开头的,这个对应着 HID Consumer Page,详见 75 页。
也可以看苹果的文档,98 页。(这是 2025-09-09 的版本,不同版本可能有出入)
但是这里没有写 Dictation 的键,可以查看这里的讨论,发现是 0xCF
Comment
by u/bunsenfhoneydew from discussion
in olkb
Mission Control 和 LaunchPad 的键可以参考 QMK 的 Code
上面一些资源和思路要感谢知乎答主 https://www.zhihu.com/people/tommy_lau 的 这篇文章。
搞懂了这个就万事俱备,只欠东风了,如何才能修改代码呢?
修改代码并覆写
这里只要把这部分进行修改。这里借用了原有的图标,只是修改了键值。
{
name: "icon-a-closewindow_char",
chinese: "keyboard.closeWindow",
val: [2, 0, 0, 0xCF], // Dictation
},
{
name: "icon-a-lockcomputer_char",
chinese: "keyboard.lockComputer",
val: [2, 0, 2, 0xA0], // Launchpad
},
{
name: "icon-a-showdesktop_char",
chinese: "keyboard.showDesktop",
val: [2, 0, 2, 0x9F], // Mission Control
},
{
name: "icon-a-windowssafetyscreen_char",
chinese: "keyboard.windowsSecurity",
val: [2, 0, 2, 0x21], // Spotlight
},
{
name: "icon-a-taskmanager_char",
chinese: "mouse.taskManager",
val: [2, 0, 2, 0x9D], // Global Key. 可惜的是这个不能用于组合键
},
如果愿意,也可以把翻译的部分改成我们修改过的功能:
closeWindow: "Dictation",
lockComputer: "Application",
showDesktop: "Mission Control",
windowsSecurity: "Spotlight",
覆写:
- 可以通过DevTools 中 Source 中的 Overrides 进行覆写,这个是最方便的,可惜
tailwind.js实在是太大了,压缩之后有3MB,而格式化之后有12MB,可能出于这个原因,每次覆写的时候都会造成页面卡死。
网上搜索之后有相同的问题,但是使用他方法之后也没有解决问题,于是只好放弃这个办法。 - 使用代理工具如 Fiddler,Charles,Proxyman 等提供的覆写工具,Proxyman 的文档:https://docs.proxyman.com/advanced-features/map-local
- 由于这是个纯前端的网页,不涉及后端请求,其实可以把整个网页下载下来。在编写这篇文章的过程中,我还是采用了这个方法,因为代理工具的覆写可能和其他代理软件冲突,而且把整个网页存个档也方便我以后使用。