Project Case · macOS Native

XRun · macOS 菜单栏自动化中枢

起因是运营工作里那些必须重复做、又不该占用人力的事:脚本要定时跑、结果要有人看、失败要有人知道。 找不到顺手的现成方案,于是我用 Swift 自己写了一个—— 11,615 行原生代码、6 个模块、237 个构建版本,至今仍在自己的 Mac 上每天运行。

11,615 行
Swift 代码 / 57 个文件
2,277 行
Python 采集管线
6 个
功能模块
8 种
任务调度类型
26 个
在线运行的资讯源
v1.0.237
构建版本(37 次提交)
macOS 原生开发 Swift / SwiftUI 并发模型 算法设计 Python 数据管线 自动化 独立完成 · 设计到交付
XRun 菜单栏入口与管理面板总览
图 1 XRun 菜单栏入口与管理面板总览
Background

01 它解决什么问题

大部分效率工具的结局是「装了但不用」。XRun 的起点不是技术选型,而是四个具体摩擦点。

  • 定时任务不可靠。系统 cron 不感知笔记本休眠——合盖一晚上,早该跑的日报就不会跑,且毫无提示;
  • 失败了没人知道。脚本半夜报错,往往第二天看数据不对才发现;
  • 脚本散落无管理。不同项目、不同 Python 环境、不同参数全靠脑记,换台机器就全乱;
  • 信息采集靠手工。行业动态散在几十个网站与订阅源里,每天人工刷一遍,是典型的低价值重复劳动。

目标与非目标

范围收得很窄:只做一个人真正会天天用的工具。不做的事同样明确:不做多用户与权限体系、不做云端同步、不引入服务端。 这些取舍换来零部署成本、数据完全本地、离线可用——也正因如此它才活了下来,没被我自己弃用。

为什么是「菜单栏应用」

运营场景需要「随时瞄一眼」,而不是「专门打开一个网站」。菜单栏让状态永远在余光里: 灰色空闲、蓝色运行中、绿色今日全成功、红色则意味着需要处理——零点击即可感知系统健康度。 这也意味着必须写原生应用,因为要深度调用系统的休眠唤醒、锁屏、前台应用与进程管理等能力。

Modules

02 功能全景

六个模块,围绕同一条主线:让机器干活,人只看结果。

01 定时任务

把任意 Python 脚本变成可靠的定时任务。

  • 8 种调度:手动 / 一次性 / 间隔 / 每小时 / 每天 / 每周 / 每月 / Cron
  • 状态机空闲→运行中→成功/失败,菜单栏图标同步变色
  • 日志按「执行批次」分组,可展开看单次完整输出
  • 超时保护默认 1 小时且可单独配置;同一任务未跑完则本轮自动跳过
02 消息通道

任务出结果时主动推送,不用等人去查。

  • 飞书 Webhook 卡片式推送,含任务名、耗时、退出码、触发方式
  • 每个任务独立策略:总是 / 仅成功 / 仅失败 / 不通知
  • 发送结果写日志,通知本身失败也可追溯
03 闪念胶囊

随手记录,语法高亮编辑 + 实时预览。

  • 支持 Mermaid 图表、代码块卡片、任务列表、表格
  • 标签分类 + 全文搜索,输入即自动保存
  • 纯本地 Markdown 存储,带元数据,随时可迁移
04 资讯订阅

把几十个信息源压成一份「今日简报」。

  • 四类源统一接入:RSS / Atom / JSON API / 网页抓取
  • 兴趣关键词相关性打分,命中噪声词自动降权
  • 三层去重 + 故事合并:同一事件的多家报道聚合为一条
  • 重要性排序 + 多样性约束,生成精选简报
05 数据看板

看得见的时间去向,才有优化的依据。

  • 自动识别前台应用,按日统计活跃时长
  • 今日 / 本周 / 本月三档视图,9 个常用应用分组
  • 超过 5 分钟无键鼠操作即停止计时;跨午夜自动切分归属
06 应用日志

系统层面发生过什么,全部留痕。

  • 应用生命周期:启动、退出
  • 硬件电源事件:休眠、唤醒
  • 用户行为:屏幕锁定、解锁
  • 环境状态:网络变化、系统时间被修改
六个功能模块的实际界面
图 2 – 7 六个功能模块的实际界面
Architecture

03 架构设计

没有引入任何架构框架,用最朴素的「组合根 + Store 模式」控制复杂度。目标很明确:让并发正确、让数据不丢、让功能可以随时加减。

整体形态

应用采用 MenuBarExtra + Window 双场景:菜单栏负责随时可见的状态,独立窗口负责集中管理。 AppDelegate 作为唯一的组合根统一创建全部 Store、Manager 与 Scheduler 再注入视图,依赖关系集中一处,任何模块都可单独替换。

菜单栏图标 → MenuBarExtra 面板 → 管理面板窗口 → 6 个功能模块

四层分工

层级代表组件职责
数据层 TaskStore LogStore NoteStore NewsSourceStore 状态持有与落盘,统一写入本地应用支持目录
调度层 TaskScheduler ScheduleCalculator CronParser 调度时间计算、进程生命周期、超时与休眠补偿
服务层 NotificationManager ShellEnvironment PythonEnvironment SystemObserver 通知投递、环境探测、系统事件监听
视图层 主分栏视图 + 各模块视图 仅做展示与交互,不持有业务状态
入口 菜单栏图标 → MenuBarExtra 面板 → 管理面板窗口 L1 视图层 仅展示与交互,不持有业务状态 主分栏视图 任务卡片 日志视图 笔记编辑器 资讯简报 数据看板 系统日志 L2 服务层 与系统交互的能力封装 NotificationManager ShellEnvironment PythonEnvironment SystemObserver AppActivityTracker L3 调度层 时间计算与进程生命周期 TaskScheduler ScheduleCalculator CronParser TaskFormViewModel L4 数据层 状态持有与落盘 TaskStore LogStore SystemLogStore NoteStore NewsSourceStore 依赖 / 调用方向 统一落盘 ~/Library/Application Support/XRun/
图 8 XRun 四层架构与依赖方向(组件为源码中的实际类型名)

两条关键数据流

任务执行

定时器触发 → 查重 · 校验脚本 → 申请文件权限 → 启动子进程 → 流式读输出 → 写日志 · 更新状态 · 推送通知

休眠补偿

收到休眠通知 → 记录休眠起点 → 唤醒 → 后台统计错过的执行 → 写入警告日志 → 重新调度

这里有一个刻意的产品决策:错过就是错过,只记录、不自动补执行。 补执行听起来更智能,但会让「发日报」「清理数据」这类有副作用的脚本在你不知情时突然重跑,风险远大于收益。

六个关键设计决策

并发 全部状态类限定主线程。UI 状态更新天然串行、无需加锁;文件读写、子进程、环境扫描放到后台,且只传值类型快照,杜绝跨线程访问共享可变状态。
补偿 用数学计算代替循环枚举。统计休眠期间错过多少次执行时,「按间隔」类型直接用闭式公式算次数——否则「休眠 8 小时 + 5 分钟间隔」会产生近百次无意义循环,间隔越短越危险。
权限 文件权限成对申请与释放。访问用户选择的脚本路径需安全作用域书签,执行前申请、结束后释放,并用集合追踪「当前确实持有权限的任务」避免重复释放造成泄漏;书签失效时自动重签。
兼容 结构化字段优先,旧数据自动降级。运行状态改用显式事件字段而非猜日志文本,历史数据仍走字符串匹配兜底;所有模型解码都提供默认值,任何一版旧数据升级后都能正常打开。
编辑器 单向数据流,全文永不进入视图状态。笔记正文只在原生文本组件里存活,编辑过程不产生回写路径,从架构上消灭「光标跳到文首」「中文输入法崩坏」这一整类问题。
持久化 高频防抖、关键立即、退出兜底。日志写入 2 秒防抖;运行结束、配置变更等关键节点立即落盘;退出前强制刷新一次。既不打满磁盘,也不丢数据。
Deep Dive

04 技术深潜

三个最值得讲的实现,共同点是:第一版都能跑,但都有隐藏的坑,只有长期使用才会暴露。

深潜一:自己写一个 Cron 引擎

最直接的做法是「从当前时间逐分钟往后试,直到命中」。它看似简单,却有两个问题: 性能不可控——用户写 0 3 1 1 *(每年 1 月 1 日凌晨 3 点)时,逐分钟遍历要空转约 52 万次; 语义容易做错——标准 Cron 规定「日期」与「星期」同时被限定(都不为 *)时两者是或关系而非与关系,这个细节大多数简易实现都搞错了。

我的做法是字段级跳跃推进:不匹配时直接跳到该字段的下一个可能取值边界,而不是推进一分钟。

// 月份不匹配 → 直接跳到下月 1 日 00:00,而不是逐分钟试 if !monthSet.contains(month) { current = /* 下个月 1 日 00:00 */ continue } // 小时不匹配 → 跳到下一小时的第 0 分 // 分钟不匹配 → 才真正前进一分钟(最小粒度) // 标准语义:日期与星期同时被限定时取「或」 if dayRestricted && weekdayRestricted { if !dayMatches() && !weekdaySet.contains(cronWeekday) { current = startOfNextDay(current) continue } }

CronParser.swift · 字段级跳跃推进与星期/日期「或」语义

完整实现还覆盖了这些边界:

  • 语法:*、*/n、单值、a-b 区间、a-b/s 与 n/s 步长、逗号组合列表(如 1-5,10-20/2)
  • L 表示当月最后一天,不能当静态集合预解析,必须在匹配时动态计算;星期字段 7 与 0 都表示周日,需归一化
  • 区间支持回绕:5-1 表示周五、六、日、一
  • 所有字段做范围校验,非法表达式给出明确错误而非静默失败
  • 额外提供中文自然语言描述(「每月最后一天 09:00」),让配置结果可被复核
收益

最坏情况下(刚跑完年度任务就立刻求解下一次)也只需 123 次跳跃,对应 52.6 万次逐分钟推进,实测快约 4,300 倍;搜索有明确上限,遇到永不可能命中的非法组合(如 2 月 30 日)会安全退出并记录警告,而不是卡死主线程。

实测场景 表达式 0 3 1 1 * 起点 2026-01-01 03:01 命中 2027-01-01 03:00 推进次数 逐分钟遍历 525,599 朴素做法 字段级跳跃 123 本项目实现 快 4,273 倍 123 次推进的构成 58233011 分钟推进 58 小时跳跃 23 天跳跃 30 月跳跃 11 另有 1 次为最终命中判定 其他表达式实测 表达式 起点 字段级 逐分钟 倍数 0 3 1 1 * 2026-10-02 20:00 7 130,020 18,574× 0 9 * * 1-5 2026-10-02 20:00 16 3,660 229× 0 0 1 * * 2026-10-02 20:00 31 42,000 1,355×
图 9 两种 Cron 求解策略的推进次数对比(按源码逻辑复刻模拟所得,与逐分钟暴力遍历结果一致)

深潜二:把原生编辑器塞进声明式 UI

笔记模块需要真正好用的 Markdown 编辑器,声明式框架自带的文本组件能力太弱,必须用 AppKit 的原生文本视图——而两者桥接是出了名的坑区。 最常见的错误做法是双向绑定:视图存一份正文,用户输入后更新状态,状态变化再写回组件,结果就是中文输入法正在候选、 或程序化插入文本时,光标被重置到文首、输入被吞。

我的解法是彻底切断回写路径——这是整个模块最重要的一条架构决策:

// updateNSView 故意留空:绝不把视图状态写回文本组件 func updateNSView(_ nsView: MarkdownTextView, context: Context) { // 正文的生命周期完全留在 AppKit 闭环内 } // 切换笔记时不做增量 diff,而是整体重建编辑器 MarkdownEditorView(initialContent: session.content) .id(session.meta.id) // 换笔记 = 换视图身份

MarkdownEditorView.swift · 以整体重建替代状态同步

编辑过程中三条支路彼此独立:

输入变化 → 保存(防抖 500ms,独立串行队列) | 语法高亮(合并到下一帧) | 字数统计(节流 300ms)

另外三个细节,都是踩过坑之后才写对的:

  • 输入法判据。判断「是否正在输入法组字」不能看光标位置是否存在,而要看已标记文本长度是否为 0——程序化插入文本后系统仍会残留一个长度为 0 的标记区间,按位置判断会漏掉正在输入的内容。
  • 保存竞态。自动保存是异步的,若用户在落盘前把笔记删了,后台写入会让删掉的笔记「复活」。为此维护线程安全的已删除 ID 注册表:写盘前先校验,删除时先登记再删文件。
  • 高亮只改颜色,不改度量。语法着色只调整前景色与背景色,从不改变字号、字重或行距,因此只触发重绘、不触发重新排版,可以同步执行且打字零跳动。
收益

上线后再没出现过「光标跳走」或「输入被吞」。预览侧把 Markdown 解析成语法树、遍历生成 HTML 交给系统浏览器组件渲染,表格、代码块、Mermaid 图表都能得到原生级排版并支持全文选择复制;3.5 MB 的图表库只在笔记真的出现该语法时才加载。

笔记编辑器的编辑态与预览态
图 10 笔记编辑器的编辑态与预览态

深潜三:把 26 个信息源压成一份简报

这是算法密度最高的部分:用 Python 实现了一条完整数据管线,从 26 个异构源抓取内容,经去重、打分、聚类、筛选,输出一份「今日简报」。最近一轮的实际运行结果:

521
24 小时内抓取条目
479
合并后独立故事
20
最终精选简报
0
抓取失败的源

难点一:同一件事,十家媒体在写

朴素做法是「标题相同即去重」,但同一事件的标题几乎不会完全相同。方案是三层递进:

  • 精确去重。标题 + 归一化 URL 完全相同则合并,按「官方 > 媒体 > 社区」的权威度保留更可信来源。
  • 同源近似去重。同一信息源内、时间窗 6 小时内、标题相似度超过 0.9 视为重复。相似度不是单一指标,而是字符串相似度与词集合重合度的加权组合。
  • 跨源故事合并。跨源增量聚类:规范化 URL 相同直接归并;否则要求时间窗 12 小时内且标题相似度达到 0.75。

这里有个容易被忽略、也最危险的反例:不同厂商的新闻标题往往高度相似(只差一个厂商名),纯相似度聚类会把它们错误合并,导致漏看信息。 为此加了一道实体守卫:先从标题抽取厂商、产品、模型等实体集合,若两边都非空且完全不相交,则无论相似度多高都拒绝合并。

难点二:什么该被推到前面

排序若只按相关性,结果是每天被同一类内容淹没。最终重要性评分由四个因子加权构成,每个因子都可解释:

因子权重含义
相关性0.30与兴趣关键词的匹配程度;未命中不归零,保留基线分
来源权威度0.25官方 > 媒体 > 达人 > 社区
热度0.25报道该故事的独立信源数量与平台侧热度指标,取较大值
时效性0.20以 48 小时为半衰期的衰减函数,避免旧闻长期占据头部

最后是多样性约束:贪心选取前 20 条时,每选中一条就对该来源的后续条目施加惩罚,同时对已选故事做相似度抑制——让简报覆盖不同信源与话题,而不是变成某个网站的头条搬运。

难点三:抓取本身的健壮性

26 个源格式与稳定性各不相同,管线做了三件事:并发抓取并做逐源异常隔离,任何一个源挂掉都不影响其余; 每完成一个源就原子写一次状态文件,宿主应用轮询即可显示实时进度,无需设计流式协议; 以及时区错标纠正——部分平台会把时间标成错误时区,导致内容显示为「未来」或被时间窗过滤掉,需要识别并回正。

验证

这条管线配有 26 个单元测试,覆盖 URL 归一化、日期解析(含时区错标)、相似度计算、实体守卫正反例、去重择优、重要性归一化、归档与关注源筛选等边界场景,全部不依赖网络。

Engineering

05 工程实践

一个人长期维护的项目,最怕的不是功能不够,而是自己半年后不敢改。所以我把「可交付、可回归、可信任」也当成功能来做。

一条命令完成构建与交付

项目不用 Xcode 工程,而是纯 Swift Package Manager 加一个构建脚本。执行 ./build.sh 会自动完成: 编译 → 递增构建号 → 更新应用包信息 → 打包资源(含 Python 管线)→ 重新签名 → 关闭旧实例 → 启动新版本。

版本号策略上有两个刻意的设计:

  • 失败的构建不消耗版本号——构建号在编译成功之后才递增,避免版本号出现无意义空洞。
  • 首次运行以 Git 提交数为种子——保证全新环境克隆后版本号连续、不回退。

另有 --commit 模式可在构建成功后自动提交,但要求工作区必须干净——防止代码改动被悄悄混进「构建」提交。

把不可信输入当成默认前提

风险点处理方式
通知外发地址强制 HTTPS;域名白名单仅允许官方域名,防止任务内容被推送到任意第三方地址
网络抖动对限流与服务器错误做指数退避重试,并区分「请求失败」与「业务返回失败」
Python 解释器校验文件头的可执行文件魔数,拒绝脚本冒充解释器;探测时加超时,防止恶意程序挂起应用
Shell 环境捕获只允许白名单内的 shell,不接受任意环境变量指定的路径
笔记预览过滤危险协议链接,渲染前对全部文本做转义
日志与截图通知地址在界面与日志中均做脱敏,不完整落盘

测试与自我审查

测试方面保持诚实:Python 管线有 26 个单元测试(算法集中、纯函数、收益最高);Swift 侧目前依赖「纯逻辑函数 + 长期自用」, 尚未补上自动化测试——这是我明确记录的短板,不是被忽略的空白。

另一个值得一提的习惯是自驱式代码审查:提交历史里能看到我按 P0 到 P3 的优先级分批整改——凭证迁移、强制 HTTPS、线程隔离标注、 资源释放配对、强制解包清理、日志统一、为关键算法补测试。这些提交没有外部评审人,纯粹是「用了一段时间后回头看,觉得这里不对」。

成果之一是:整个 11,615 行代码库目前只留有 1 处待办标记,且带明确的优先级与改进方案。

Results

06 成果与验证

以下数据全部来自应用在本机的真实运行记录(读取本地数据文件统计),不是估算值。我认为「自己每天在用」本身就是最硬的验收标准。

指标实测值
代码规模Swift 11,615 行 / 57 个文件;Python 管线 2,277 行
第三方依赖仅 1 个开源 Markdown 解析库,其余全部自研
版本迭代v1.0.237(237 个构建版本),37 次提交,代码变更累计 +30,769 / −12,514 行
资讯采集26 个源在线(RSS 15 / JSON API 7 / 网页 3 / Atom 1);最近一轮抓取 521 条 → 合并为 479 个故事 → 精选 20 条,0 源失败
任务执行6 个任务在管(5 个启用),累计 27 个执行批次、462 条执行日志
系统事件累积记录 1,000 条(恰好触达设计上限,反向验证容量截断策略生效)
活跃统计连续记录 2 个月(2026-07-24 → 2026-09-27),覆盖 9 个应用
稳定性长期常驻运行无崩溃;异常退出后的状态自愈机制已就位

性能优化清单

  • 日志增量索引。用常数级索引结构替代每次写入时的全量扫描,日志量增长后写入耗时不随之劣化。
  • 全列表共享单一时间源。任务列表所有倒计时共用一个每秒心跳,而不是每张卡片各起一个定时器。
  • 编辑器高亮不触发重排。着色只改颜色属性,每次按键的处理成本恒定。
  • 元数据与正文分离加载。列表启动时只解析文件头部的元信息,正文在打开时才读取。
  • 动画零拷贝 + 高频写盘防抖。像素动画直接替换图层内容指针,关闭窗口后占用归零;日志与统计类数据 2 秒合并落盘。
Retrospective

07 复盘与演进

做减法比做加法更难

这个项目曾经有过「服务管理」和「待办事项」两个模块,功能都是完整的,后来我把它们删掉了。 原因很直接:它们不在我每天的真实动线上。服务管理更适合专业运维工具,待办事项我最终选择了更顺手的方案。 留着只会增加维护面、让定位变模糊。删掉近千行能跑的代码,是比写新功能更重要的决定。

已知短板(主动记录,不粉饰)

  • 没有持续集成。构建与版本管理已脚本化,但尚未接入自动化流水线,回归依赖手工执行。
  • Swift 侧缺少自动化测试。算法部分已按纯函数组织、具备补测条件,但尚未补齐。
  • 核心模型文件偏大。任务与日志的模型和存储逻辑集中在一个 682 行文件里,应拆为模型与存储两部分。
  • 部分适配器缺少测试。Python 侧的抓取适配器模块单元测试为空,是测试覆盖最薄弱的一环。
  • 凭证存储待加强。通知地址目前存于配置文件,应迁移到系统钥匙串。

下一步

补齐 Swift 单元测试 → 接入构建流水线 → 拆分核心模型 → 凭证迁移钥匙串 → 扩展更多通知渠道与脚本语言

长期方向是让它从「脚本调度器」逐步长成一个个人自动化中枢:不只是按时执行,而是能把多个环节编排成工作流,并对外提供可复用的能力。

Capability

08 能力标签

XRun 对我而言不只是一个 App。它证明了一件事:懂业务的人,可以把需求直接变成可运行的系统——中间不需要翻译,也不会有需求失真。

需求到产品
从真实业务摩擦点定义问题、划定边界(明确不做什么)、独立完成信息架构与交互设计,并依据自用反馈迭代 237 个版本。
原生开发
Swift / SwiftUI 主力,能下探到 AppKit 与系统框架层解决声明式 UI 覆盖不到的问题,理解 macOS 生命周期与权限模型。
并发模型
以「主线程持有状态、后台只传值」组织并发,处理子进程、文件 IO、系统事件回调等多来源异步,并显式设计退出时序与竞态防护。
算法与数据
手写 Cron 解析与调度求解、相似度与聚类去重、多因子加权排序、缓存与索引优化,关注复杂度而不只是「能跑」。
数据建模
设计跨语言数据契约(Python 与 Swift 之间的字段映射),坚持向后兼容:任何一版旧数据升级后都能正常打开。
工程化交付
构建、打包、签名、版本管理全流程脚本化;把安全与健壮性当作默认前提;主动记录并管理技术债。
AI 与自动化
用一条完整数据管线解决信息过载:多源采集、智能去重、兴趣建模、重要性排序——这正是 AI 辅助运营在「信息处理」环节的典型落地形态。
为什么讲这个案例

它回答了一个具体问题:当一个运营人说要「用自动化提效」,他能做到多深?我的答案是——深到可以自己写出工具本身。

Appendix

附录 技术清单

技术栈

  • 语言:Swift 5.9(按 Swift 6 并发语义编写)/ Python 3
  • 框架:SwiftUI、AppKit、WebKit、Network
  • 平台:macOS 14.0+,菜单栏应用(无 Dock 图标)
  • 包管理:Swift Package Manager;Python 侧 4 个依赖(feedparser / beautifulsoup4 / python-dateutil / requests)
  • 第三方依赖:仅 apple/swift-markdown 一个

数据存储

  • tasks.json 任务配置与调度计划
  • logs.json / triggers.json 执行日志与触发来源
  • system_events.json 系统事件日志
  • notes/*.md Markdown 笔记(带元数据)
  • news_sources.json / news_latest.json / news_archive.json 资讯源、简报与历史归档
  • channels.json / notification_logs.json 通知通道与发送记录
  • appActivity.json 应用活跃时间统计

全部数据存放在本地 ~/Library/Application Support/XRun/,格式为可读的 JSON 与 Markdown,不依赖任何云服务。

本文数据来源于项目代码库与本地运行记录(统计时间:2026 年 9 月)。