第一次知道 context-mode 这个概念,是因为我实在受不了一件事:每天到公司插上显示器,我得手动切键盘布局、关掉外放、把鼠标速度调回去;下班拔掉显示器,又得重复一遍反操作。一开始我写了个 bash 脚本一键切换,但很快发现问题:脚本不知道我当下的状态,它只能按我的指令做事。后来我把思路反过来,让“状态”自己去触发配套动作,于是就有了 context-mode——一个可以让系统根据当前上下文自动调整行为模式的小工具。
这篇文章会讲清楚它的设计思路、核心实现、踩过的坑,以及和现成自动化工具的边界。如果你平时也喜欢折腾桌面环境、搞过各种“自动切换配置”的脚本,或者正在犹豫要不要自研一个场景感知工具,这篇文章应该能帮你省不少时间。我会尽量把“为什么这么做”也讲透,而不只是丢一堆可运行的代码。
1. context-mode 到底解决什么问题
1.1 从“手动切模式”到“自动切场景”
市面上很多工具都在做“模式”这件事:编辑器有暗色模式,手机有勿扰模式,笔记本有性能模式。但绝大多数模式切换都靠手动点按,或者靠定时任务。context-mode 想解决的,是更底层的那个问题:工具应该能感知到用户身处的“上下文”,然后在上下文发生变化时自动完成模式切换。
我自己最早遇到这个需求,是发现自己在“办公室”和“家里”两套外设环境下,系统配置几乎是两套完全不同的状态。办公室里插着扩展坞,外接显示器是主屏,键盘布局要用美式;家里没有扩展坞,屏幕回到笔记本自带屏,音频输出要走蓝牙音箱。这些变化如果都靠人去记,早晚会出错。时间一长,我就开始想:为什么不能让系统先知道“我插上了显示器”这个事实,然后自动把适配好的整套配置套上去?
context-mode 的本质,就是用一组“环境信号”来描述当前上下文,再根据预定义的规则把系统拉入对应模式。它的目标不是做一个全能自动化平台,而是专注把“上下文感知”和“模式切换”这两件事做扎实。
1.2 它和“一键切换脚本”有什么不同
很多人第一反应是:这不就是“根据条件执行脚本”吗?用 shell 写个if判断不就完了?表面上确实像,但实际做起来有几个关键差异:
- 条件来源多且杂。你不仅要判断“有没有外接屏”,还要同时判断电源状态、网络类型、当前前台应用、键盘鼠标状态等。多个条件交织在一起,用 shell 排列组合会非常痛苦。
- 事件触发和轮询不一样。脚本如果用轮询,每秒钟扫描一次状态,一是不优雅,二是会产生大量无效日志。context-mode 从设计上就是事件驱动,有变化才计算,没变化不动作。
- 切换要处理边界情况。状态在临界点反复抖动时,脚本很容易把系统配置来回横跳;context-mode 需要内置去抖、持久化和恢复机制。
所以,context-mode 不是一个脚本,而是一个有明确数据流和状态管理的守护进程。
2. context-mode 的整体架构:事件源、状态机与决策引擎
2.1 数据流拆解
我第一版实现特别粗暴:每 500 毫秒读一次所有传感器状态,然后逐条执行规则。结果 CPU 占用虽然不高,但日志里全是“正在评估规则”这类没意义的信息,而且一旦某个状态源响应慢,整个循环都会被拖住。
后来我把架构改成四层数据流:
- 上下文采集层:监听系统里的具体事件,比如显示器热插拔、电源切换、网络连接变化、空闲状态切换。
- 状态归一化层:把不同来源的原始事件转成统一的上下文键值对,比如
display.external_count=2或者power.line_power=true。 - 决策引擎层:根据配置里的上下文条件,算出当前应该处于哪个模式。
- 动作执行层:执行模式进入、退出时的动作,并记录这次切换的结果。
这样做的好处是每一层都可以独立替换。比如状态采集层,我既可以用 Linux 的 udev 消息,也可以用 macOS 的CGDisplayRegisterReconfigurationCallback,还可以用 Windows 的 WMI 事件。无论底层怎么变,只要最终输出同样的上下文键值对,决策引擎就不用改。
2.2 为什么选用有限状态机而不是跑一堆 if-else
早期我确实是 if-else 战士,遇到新需求就往上堆一个分支。直到有一次,我需要让“临时离开”和“会议中”这两个模式同时影响同一套音频配置,才发现条件之间根本不是互斥的,而是有优先级的。
如果只用 if-else 写,代码会变成这样:
if context['meeting']: mode = 'meeting' elif context['away']: mode = 'away' elif context['docked']: mode = 'docked' else: mode = 'default'看起来清晰,但只要新加一个“演示模式”,就得重新梳理优先级顺序,而且模式之间的切换关系根本没法表达。比如从“会议中”切到“离开”再切回“会议中”,如果中间还要恢复麦克风状态,if-else 是很难优雅处理的。
所以我最终采用有限状态机,但这里的“状态”不是指单一模式,而是指“当前模式 + 最近一次切换的时间 + 各上下文源的最后取值”。每次上下文事件到来时,状态机做一次状态迁移计算,而不是从头跑一遍全部规则。
状态机的迁移表放在配置里声明,决策引擎只负责执行迁移动作。这样模式之间的关系就变成一张有向图,哪些迁移合法、哪些非法一目了然。
3. 实现细节:从上下文采集到模式切换的完整链路
3.1 上下文采集层的插件化设计
context-mode 的采集层不能写死。不同操作系统的事件 API 完全不一样,同一系统不同桌面环境也不一样。我的做法是定义一套插件接口,每个插件只负责一件事:
class ContextSource: name: str async def start(self, emit): ... async def stop(self): ...emit是一个回调函数,插件把原始事件转成统一的上下文数据后调用它。以内置的显示器插件为例,它在启动时注册系统回调,当显示器数量变化时,立即算出来display.external_count和display.primary_id并交给状态机。
插件的价值在于隔离复杂度。电源管理插件可能依赖系统总线,网络插件可能要监听无线网络信号,这些逻辑如果全部堆在核心进程里,最后必然变成一团乱麻。
3.2 模式定义:声明式配置优先
我把模式定义做成了一份 YAML 配置。每个模式包含两部分:进入条件、进入后要执行的动作。
modes: docked: if: display.external_count: ">=1" power.line_power: true then: - audio.default_sink: "speaker" - input.pointer_speed: 0.7 - keyboard.layout: "us" - brightness.level: 80 mobile: if: display.external_count: "==0" power.battery: true then: - audio.default_sink: "bluetooth_speaker" - input.pointer_speed: 0.4 - keyboard.layout: "jp" - brightness.level: 50 else_actions: - notify: "进入移动模式"这里有个小设计值得说:then表示进入该模式时要执行的动作,else_actions表示离开该模式时执行的动作。很多人会忽略“退出动作”,结果就是插上显示器时一切正常,拔掉显示器后音量还停在外接音箱,非常难受。
配置文件里还可以声明优先级:
priority: meeting: 100 docked: 50 mobile: 10决策引擎在评估模式时,先算出所有满足条件的模式,再按优先级选最终模式。实际经验是,低优先级模式负责常规场景,高优先级模式负责临时打断场景,比如会议模式优先级最高,这样即使本人在办公室,只要检测到日程上有会议,也会先切到会议状态。
3.3 状态持久化与切换去抖
状态机跑起来之后,最怕的就是抖动。比如笔记本电源插头接触不良,电源事件在几秒内反复横跳,如果不做处理,系统就会不停地切换性能模式和电池模式。
我的处理方案是在切换动作前加一个“去抖窗口”。默认是 3 秒,也就是说,某个模式必须连续满足条件 3 秒才真正触发。实现方式是在状态机里维护一个候选模式:
候选模式 A 第一次出现 -> 记录时间 候选模式 A 持续出现 -> 到达窗口后切换 候选模式 A 中途消失 -> 取消候选,不动作除了内存去抖,还要把当前模式持久化到本地文件。这样守护进程崩溃重启后,能知道“崩溃前处于什么模式”,避免开机后乱执行一遍动作。文件内容我习惯用 JSON:
{ "current_mode": "docked", "since": "2025-06-10T09:30:00Z", "last_context": { "display.external_count": 2, "power.line_power": true } }这个持久化文件也是调试的好帮手。遇到诡异问题时,直接看它就知道系统最后一次认识到的上下文是什么,省去了翻日志的麻烦。
4. 实际使用中的踩坑与调优
4.1 网络状态判断不能只看连通性
我最初判断“是否在办公室”时,用的一个条件是“有线网络已连接”。结果有一天办公室网络调整,有线网络断了几分钟,context-mode 立刻把我的状态从“办公模式”切到了“移动模式”,桌面壁纸、鼠标速度全部变了,等我开会时才发现不对劲。
问题的根源在于:网络连通性只是一个瞬时状态,不能代表稳定上下文。后来我把网络条件改成了“接入的网络 SSID 或交换机名称在白名单内”,并且要求该条件在去抖窗口内持续成立。对于有线网络,我会优先读取网卡连接到的交换机 VLAN 信息,而不是只看 DHCP 有没有拿到地址。
如果你也要做类似判断,建议遵循几个原则:不要用“能 ping 通外网”来判断网络环境;要用可标识的、相对固定的网络特征,比如 SSID、BSSID、网关 MAC、802.1X 认证结果;对于可变的网络特征,一定要配合去抖窗口。
4.2 屏幕状态切换的时序竞争
显示器热插拔事件有一个很恼人的特性:拔掉外接屏时,操作系统不会立刻重新排布桌面,而是先触发“显示器断开”事件,过一会儿才触发“主显示器变化”事件。如果我在这两个事件之间去读屏幕信息,拿到的往往是一份中间状态。
我在 context-mode 里遇到的具体表现是:拔掉扩展坞后,系统先把外接屏标记为断开,但分辨率还没切回笔记本屏,此时如果我立刻执行brightness.level和keyboard.layout动作,index 可能已经错位。解决办法有两个:
- 在显示器插件里,不要直接用“断开事件”作为条件,而是等 500 毫秒后再读取一次完整状态,确认没有后续事件再来才上报。
- 把“外接屏数量变化”和“主显示设备变化”作为两个独立的上下文键,决策规则里对二者做“与”判断,避免半中间状态触发切换。
这种时序竞争问题在很多系统事件里都存在,不只是显示器。建议所有采集插件都做一层“稳定化处理”:事件触发后延迟一小段时间,再读一次最终状态,如果状态稳定才上报。
4.3 去抖参数怎么设才不“灵敏过度”
去抖窗口不是越大越好。我一开始设置成 5 秒,结果从办公桌挪到会议室时,笔记本已经被我拿起来了,显示器和键盘配置还在“办公模式”里硬撑 5 秒,体验非常割裂。后来我把不同上下文源区别对待:
| 上下文源 | 建议去抖窗口 | 原因 |
|---|---|---|
| 电源插拔 | 2-3 秒 | 防止接触不良,允许短暂延迟 |
| 显示器热插拔 | 0.5-1 秒 | 希望响应迅速,但要处理中间态 |
| 网络切换 | 3-5 秒 | 网络环境相对稳定,偶尔波动不应触发 |
| 空闲状态 | 60-120 秒 | 较短的空闲不构成上下文切换 |
这个表只是参考。真实场景里,去抖窗口应该根据“切换代价”来定:动作代价大的模式,去抖窗口适当放大;动作代价小的,比如换壁纸,窗口可以缩短。
5. 与同类方案对比:为什么不用现成的自动化工具
5.1 对比表格
写 context-mode 之前,我也试过几类现成方案,它们各有特点,但离我的需求都有距离。我整理了一个对比表:
| 方案 | 设计目标 | 上下文感知能力 | 配置复杂度 | 适合人群 |
|---|---|---|---|---|
| 通用自动化工具 | 面向宏操作和条件触发 | 偏应用层,系统级事件较少 | 图形化配置,上手快 | 不太想写代码的普通用户 |
| 桌面环境扩展脚本 | 定制窗口、快捷键、工作区 | 能获取桌面事件,但跨组件难 | 脚本化,不同环境不通用 | 桌面环境深度用户 |
| 自建 context-mode | 专注上下文感知模式切换 | 系统级事件源 + 可扩展插件 | 声明式 YAML,需一定技术基础 | 愿意维护配置的开发者 |
通用自动化工具强在“动作库”很丰富,比如打开指定应用、模拟键盘输入,但它对系统底层上下文的理解是被动的。桌面环境扩展脚本又往往和特定桌面绑定死,Windows 上能用,换到 macOS 就废了。context-mode 的定位更适合作为系统底层的一层服务,把上下文整理成结构化数据,再通过动作执行层去调用各类外部工具。
5.2 取舍:context-mode 不做什么
很多人一开始会用 context-mode 去实现所有自动化需求,比如“收到邮件就弹窗提醒”“每天定时备份文件”。这些任务当然可以做,但不是核心路径。context-mode 设计上刻意保持克制:
- 不内置定时任务调度器。定时任务交给 cron 或 systemd timer。
- 不强行处理应用内的宏操作。键盘模拟和 UI 自动化应该由专用工具负责。
- 不提供复杂 GUI。它面向配置文件,更适合版本管理和批量部署。
这样做的好处是核心逻辑不会膨胀。我需要加新功能时,只需要写一个新的上下文源插件或者新的动作执行器,而不是去啃一个几千行的大杂烩。
6. 扩展玩法与社区生态
6.1 自定义上下文源
我已经把常用上下文源做成了内置插件:电源、外接显示器、网络、空闲状态、当前前台窗口、蓝牙设备。但真正的乐趣在于自己写插件。
举个例子,我办公工位和会议室用的不是同一把人体工学椅。我在椅子上贴了一个 NFC 标签,工位这边是“办公模式”标签,会议室那边是“会议模式”标签。桌面上的 NFC 读卡器一旦读到标签,就通过插件把上下文键location.zone上报给 context-mode。决策引擎里加一条规则:
modes: meeting: if: location.zone: "conference" priority: 200这样我从工位起身去会议室坐下,桌面系统自动切到会议模式,麦克风静音解除、降噪开启、外接显示器上的窗口布局也调整成投影友好布局。
插件的编写难度不高,核心就是订阅某个事件源,然后emit统一数据。比如一个监听蓝牙设备的 Python 插件,核心逻辑可能只有几十行。关键是定义好插件和核心进程之间的通信协议,我目前用的是本地 Unix Socket + JSON 行协议,简单可靠。
6.2 和外部工具联动
context-mode 的动作执行层也没做太多原生物,而是预留了一个 shell 命令执行器。这样几乎任何工具都能被联动起来:
- then: - shell: "systemctl --user start dunst.service" - shell: "pkill -RTMIN+8 i3blocks"比如切换模式时通知状态栏刷新,或者让某个服务随模式启停。这些操作如果全部写进 context-mode 核心,既不安全也不灵活,但通过 shell 执行器就变成了用户自己的事。
联动方式还有一种:context-mode 会把模式变化事件发到本地事件总线,其他程序可以订阅这个事件。比如我在写一个终端主题工具,它监听 context-mode 的模式切换事件,当系统从mobile切到docked时,终端 prompt 会换配色。这种解耦方式很适合多设备协同的场景。
我在实际使用中发现,把“模式切换”当成一种可以被订阅的系统事件,能带来的玩法远超预期。只要上下文感知层足够稳定,后面接什么动作都只是想象力问题。如果你也在折腾这类场景自动化,欢迎把你在 context-mode 里做的有意思的上下文源发出来,我后续会把其中比较好的方案合并进默认插件库。