Omarchy 桌面通知系统架构解析:内置 Notification Daemon、Toast 持久化与防注入的 --exec 点击命令
Omarchy(Beautiful, Modern & Opinionated Linux)没有引入 dunst 或 mako 这类独立的通知守护进程,而是让桌面 shell 直接充当通知服务器:org.freedesktop.Notifications 上的所有通知都落入 shell 渲染的右上角 Toast 卡片,并围绕"重启后不丢通知、点击命令绝不把数据当代码执行"这两条主线,设计了一套自洽的发送契约与持久化模型。阅读本文,你将理解 Omarchy 通知系统的完整实现骨架:Toast 的生命周期与磁盘映射、免打扰(DND)的穿透规则、唯一发送入口 omarchy-notification-send 的每个参数含义,以及它为何用 D-Bus 直呼调用 + argv 数组代替 notify-send 与 libnotify action 来杜绝注入。文中所有结论均可回溯到 docs/notifications.md、shell/plugins/notifications/Service.qml 与 shell/plugins/notifications/NotificationLogic.js 等仓库源码。
一、架构总览:shell 本身就是通知守护进程
Omarchy 的 shell 插件 notifications 是整个系统的核心。服务端实现在 shell/plugins/notifications/Service.qml,它内部托管了一个 Quickshell 提供的 NotificationServer:
NotificationServer {
id: server
keepOnReload: false
imageSupported: true
actionsSupported: true
bodyMarkupSupported: true
bodyHyperlinksSupported: true
persistenceSupported: true
onNotification: function(notification) {
service.handleNotification(notification)
}
}
该服务器在会话总线上声明 org.freedesktop.Notifications 名称,因此系统里不存在 dunst 或 mako——凡是遵守 freedesktop 通知协议的发送方(notify-send、基于 libnotify 的应用、Chromium 系 Web App)都会把通知直接送进 shell,由它渲染为堆叠在右上角的 Toast 卡片。插件清单位于 shell/plugins/notifications/manifest.json。
关键架构决策是决策逻辑与渲染分离:所有的纯判定逻辑(DND 穿透、正文清洗、argv 校验、文件名与历史行计算等)全部收敛在无 Qt 依赖的 shell/plugins/notifications/NotificationLogic.js 中,该文件通过 module.exports 导出(见其文件末尾),因此可以不经合成器、直接在 Node 环境中被加载——这正是 test/shell.d/notifications-test.sh 用 requireFromRoot('shell/plugins/notifications/NotificationLogic.js') 跑单元测试的前提。也就是说,最容易被攻破、最需要反复验证的判断逻辑可以在无显示器环境下被持续测试。
卡片 UI 则在 shell/plugins/notifications/components/NotificationCard.qml,它是纯粹的展示组件:不引用 service、Notification 或 ListModel,弹出层与历史面板复用同一份实现。
面向最终用户的可见部分(时间/电量/天气等热键通知的长相)记录在 manual/10-notices.md;本文聚焦它背后的系统形状。
二、Toast 生命周期:时长、悬停、更新与交互
每张 Toast 卡片由 Service.qml 中的倒计时逻辑驱动,其时长完全由严重级别决定。服务里定义了三个基准时长常量与封顶值:
readonly property int lowPopupDuration: 5000 // low:至少 5 秒
readonly property int normalPopupDuration: 8000 // normal:至少 8 秒
readonly property int maxPopupDuration: 30000 // 无论如何不超过 30 秒
durationFor(urgency, expireTimeout) 是换算规则:
- low:
min(30000, max(5000, 发送方请求的 expire_timeout)); - normal:
min(30000, max(8000, 发送方请求的 expire_timeout)); - critical:返回
0,即永不自动过期,一直显示直到用户处理。
requestedDuration 直接透传 freedesktop 规范(与 Quickshell 一致)中的毫秒值 expire_timeout。倒计时本身由每个卡片槽上的 50ms 重复 Timer 推进,两条交互细节值得注意:
- 悬停暂停倒计时:卡片上的
HoverHandler会把ticking置为 false,鼠标移开才继续; - 内容更新重置倒计时:通过
replaces_id原地更新的通知,只要 summary/body/image 任一变化,remainingLifetime即被重置为 1.0——"新文本值得完整看一遍"。
鼠标交互遵循直觉:左键触发默认动作,右键或悬停时浮现的关闭按钮则解散该条。这里需要说明一个模型层细节:实时 Notification 对象被刻意存放在一个普通 JS map(liveRefs)而非 ListModel 角色中,因为把 QObject 放进 model 角色会在服务器销毁该通知时留下悬垂 C++ 指针,下次读取即段错误;JS map 只持有包装引用,退化为可捕获的错误。弹出的卡片快照由 snapshotOf() 拷贝生成,模型与真实对象之间始终隔着一层快照。
2.1 弹出层 UI:常驻全屏 surface + 点击穿透
Toast 容器是一个覆盖整个屏幕的 PanelWindow(WlrLayershell.layer: Overlay),但用 mask: Region { item: popupColumn } 只保留 Toast 列可点击,其余区域对输入完全穿透(exclusionMode: Ignore、keyboardFocus: None),保证通知永远不会偷走焦点。surface 尺寸固定不随内容变化,因此计数变化时合成器不会出现旧缓冲被拉伸的闪烁。布局位置由 NotificationLogic.popupPlacement() 计算:无论顶栏/侧栏在哪个方位,Toast 列始终贴右上角,只在 bar 位于顶部或右侧时让出 barClearance,避免被栏本身遮住。
三、磁盘持久化与历史:让 Toast 挺过 shell 重启
Omarchy 更新流程(omarchy-update)会重启 shell,而这套系统承诺正在屏幕上的通知不会因此丢失。实现方式是一套"一个弹窗对应一个文件"的状态目录映射,集中在 ~/.local/state/omarchy/ 之下:
| 目录/文件 | 用途 |
|---|---|
~/.local/state/omarchy/notifications/ |
每个正在屏幕上的弹窗一个 JSON 文件,命名 <timestamp>-<id>.json |
~/.local/state/omarchy/notifications/history/ |
Toast 离开屏幕后文件移入此处,只保留最新 10 条 |
~/.local/state/omarchy/notifications/images/ |
弹窗引用的头像/图片副本 |
~/.local/state/omarchy/notifications.json |
仅存最后设置的 dnd 布尔偏好(格式 { "version": 3, "dnd": … }) |
关于目录为何放在 XDG state 而非 cache,Service.qml 中有一段清晰的注释:历史与 DND 是持久用户状态,不是 rm -rf ~/.cache 可以安全清掉的再生缓存。
文件生命周期即弹窗生命周期:Toast 上屏时写入其文件,过期、解散或被点击后文件移动到 history/ 子目录,移动动作附带修剪逻辑(trimHistoryScript:按毫秒时间戳数值排序后 head -n -limit 删除最旧的)。因此 history/ 目录就是历史本身——showHistory 回放的就是被移进这里的文件,历史行被规范化为不含原 expire timeout 的普通行,按紧急级别重新获得标准上屏时长。historyLimit 固定为 10。
3.1 图片为什么要复制
引用头像/图片不是存引用而是复制:Chromium 系发送方(Omarchy 的 Web App 们)在关闭时会删除自己作用域下的 /tmp 文件;而 image-data hint 会以进程内 image:// URL 呈现,服务器对象一死就失效。persistablePopup() 把文件型图片复制为 imagesDir/<timestamp>-<id>-<role> 命名的副本,并把条目中的值改写为 file:// 副本路径;死掉的 image:// URL 则降级为空串,让卡片回退到应用图标。清理时可以从 JSON 文件名直接反推其图片副本名。启动时还会经过文件队列执行一次孤儿图片清扫(sweepOrphanImages),清理"复制完成但 JSON 写入前进程被杀"留下的无主副本。
3.2 replaces_id:原地更新,绝无第二条通知信号
replaces_id 更新不会产生第二个 onNotification:服务器把新内容直接写到服务已持有的同一个 Notification 对象上。因此 Service.qml 对对象的 8 个属性信号(summaryChanged、bodyChanged、appNameChanged、appIconChanged、imageChanged、urgencyChanged、expireTimeoutChanged、hintsChanged)建立监听(watchForUpdates),收到变化后对模型行与文件做原地重写——文件名是首次持久化时的 <timestamp>-<id>,重写落在同一个文件上,于是重启后恢复的也是"最后一次显示"的版本。popupRowChanged() 会先比较角色是否有实质变化,避免一次更新触发文件被重复改写多次。
3.3 恢复行的身份:id 会轮回,时间戳不会
每条 shell 进程里服务器 id 都从 1 重新发放,所以从旧一代服务器恢复的行与新建通知撞 id 只是巧合。恢复的弹窗按文件名(时间戳+id)被标记在 restoredPopups 表中,任何按 id 的分发逻辑(removePopupsByOriginalId、dismissPopup、invokePopupDefault、liveRefs 查找)都会先识别并跳过这些行——否则一条全新的低优先级通知就可能误关掉一条刚恢复的 critical 告警。
另外,恢复算法 restorePopups() 不会按原时间戳死板判断是否过期:若 Toast 本该过期则直接归档;存活者则获得一次完整的全新寿命(理由:shell 重启很罕见,重启后完整再看一眼,胜过接着一个只剩 1 秒的时钟继续走)。重置后的寿命以绝对 deadline 形式写回持久化文件,避免第二次重启用早已过期的原始时间戳误杀仍在屏幕上的 Toast。
四、免打扰(DND)与穿透规则
DND 只是一个布尔值:持久化为 ~/.local/state/omarchy/notifications.json 的 dnd 键,运行时保存在 persisted.doNotDisturb 上,两者通过 PersistentProperties(处理进程内 QML 重载)+ FileView(原子写入 + 200ms 防抖保存,跨重启兜底)双向同步。历史遗留的 pending/past/entries 数组负载会被 parseSettings() 识别并在下次保存时清掉。
控制途径有三层:
- Shell IPC:
omarchy-shell notifications toggleDnd/setDnd/dndState(对应IpcHandler中target: "notifications"的处理器); - 包装命令:
bin/omarchy-toggle-notification-silencing封装 toggle 并刷新顶栏的omarchy.indicators组件; - 顶栏 Dnd 指示器:直接绑定服务的
doNotDisturb属性,实时反映状态。
4.1 哪些通知可以"打穿"DND
设计原则是:穿透必须刻意且稀少。只有两类:
app_name等于omarchy-action——Omarchy 自己的用户操作确认 Toast(例如"主题已更改")。用户刚做了某件事,他们的反馈理应立刻可见;- urgency=critical 且
app_name等于notify-send——裸 CLI 的紧急告警。这里的关键是 critical 单独不够:聊天应用滥用 critical 强行刷存在感,但它们会把app_name设成自己的品牌名(Discord/Slack 等),恰好绕不出这条规则。
对应实现 shouldBypassDnd() 位于 Service.qml(逻辑层同款函数在 NotificationLogic.js)。
被静默的通知则按"是否值得回看"分流:可能被人回看的直接写入历史——"静默期间我错过了什么"正是历史存在的意义;临时性的(freedesktop transient hint,或 app_name 为 notify-send/omarchy-action)则被彻底丢弃。isEphemeral() 负责这个判定。被静默写入历史的条目与归档弹窗同格式,回放层无法区分二者。
五、发送方契约:omarchy-notification-send 是唯一入口
Omarchy 内部代码发通知只有一个途径:bin/omarchy-notification-send,从不裸调 notify-send。它通过 busctl --user 直接调用 org.freedesktop.Notifications.Notify,因此每个值都是一个带类型的 D-Bus 参数——不存在一个 argv 层去把中继的标题重新解释成选项或 hint。
5.1 参数表
| Flag | 变为 | 含义 |
|---|---|---|
-g / --glyph |
hint omarchy-glyph |
无图片图标可解析时,图标槽显示的 Nerd Font 字形 |
--exec <program> [args…] |
hint omarchy-exec-argv |
点击命令;吞掉行内其余所有参数,因此必须放在最后。每个词是离散参数,shell 运行时不再重新解析(见下节) |
--image |
hint image-path |
标准 freedesktop 图片 hint |
-i / --icon |
app_icon |
Toast 的主题图标名 |
--app-name |
app_name |
默认 omarchy-action |
-u / --urgency |
hint urgency(字节) |
low/normal/critical,默认 low |
-t / --expire-time |
expire_timeout |
屏幕停留毫秒数;缺省用服务器默认 |
此外脚本还支持文档参数表之外的两个实用旗标:-r/--replace-id(数字 id,用于原地更新既有 Toast)和 -p/--print-id(打印 Notify 返回的 id 供调用方复用)。hints 在脚本中以 busctl 三元组(键、变体类型、值)组装,urgency 映射为字节:low=0、normal=1、critical=2。未知选项是硬错误而非静默透传——--exec 是通往点击命令的唯一门,且不存在任何通用选项透传可供绕过。
默认值即设计意图:不加任何修饰的
omarchy-notification-send "Done"
就是一条 low-urgency 的用户操作 Toast——它能穿透 DND(因为 app_name 默认 omarchy-action),被静默时又被当作临时噪音直接丢弃。
5.2 底层调用
脚本最终拼装的 busctl 调用对应 D-Bus 签名 susssasa{sv}i(app_name、replaces_id、app_icon、summary、body、空 actions 数组、hints、expire_timeout),且整个 busctl 调用以 -- 前置——这是给 busctl 自己 getopt 的"安全带",避免以 - 开头的标题/正文被误读为 busctl 选项。正文/标题本身永远只是字符串参数。--exec 的 argv 用 NUL 分隔经 jq -Rsc 序列化成 JSON 数组字符串放入 omarchy-exec-argv hint(NUL 分界保证参数里的换行、裸 -- 都能作为数据存活)。
六、点击命令是 argv,绝不是 shell 字符串
这一节是整个系统安全模型的精华,值得展开。
omarchy-notification-send "Download complete" "$title" --exec mpv -- "$file"
调用方 shell 已经把这些词切成了离散参数,带引号的 "$file" 即使含空格也仍是一个参数。在 shell 侧,点击命令通过 Util.execArgv 执行——它用参数作为位置参数调用 bash -lc 'exec "$@"',绝不把内容插值进脚本文本。bash 展开 "$@" 时不会重新分词、不会重新求值,因此攻击者可控的值——下载视频的标题、收到的文件名、崩溃进程的名字——永远只是单个参数,不可能被重新解析成命令。登录 shell 保留 GUI 点击目标(截图编辑器、mpv、xdg-open)所需的 PATH 与会话环境。
核心规则:切分必须发生在调用点,而不是工具内部。 如果传入单个带引号的字符串(--exec "mpv $title")让工具按空白切分,等于把参数边界交给控制字符串内容的人——标题里一个空格就能注入额外选项或程序。因此脚本对"只有一个词且含空白"的 --exec 参数直接拒绝并提示改用未加引号的形式:
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
echo "--exec takes the command as separate words, not one quoted string." >&2
exit 1
fi
不存在"接收命令字符串再净化它"的路径——那正是 yt-dlp 标题 RCE 所利用的转义陷阱(类比字符串拼接的 SQL 注入)。
失败关闭:恢复/执行前,parseExecArgv()(见 NotificationLogic.js)对 hint 做结构性校验——必须是字符串数组、程序非空且不以 - 开头(否则 argv 会把它当选项读)——任何畸形都返回 null 而不执行。需要明确边界:调用方仍可故意指定 shell 作为程序(--exec sh -c …),但那是因为开发者本人写了这行代码而执行,不是攻击者数据变成了命令——这是可审查的红旗(可 grep --exec sh/--exec bash),而非注入。防御原生同用户进程不在范围内(它本就有你的权限,无需借通知执行代码)。真正被彻底关闭的是不受信内容:Web 通知根本无法设置 exec hint,任何被中继的标题/文件名都被限制为惰性参数数据。
6.1 为什么不用 libnotify action
点击命令被刻意设计成 hint 而非 libnotify action,有三个理由:
- action 会让发送方阻塞等待
ActionInvoked,一旦 shell 在下面重启就永远无人应答——而安装程序 Toast 的第一件事恰恰是重启 shell; - 把命令作为 hint 携带意味着 shell 从它随弹窗保存的副本里自己执行点击(detach 运行,命令能活得比 shell 进程更久),持久化文件会保留该 argv,于是恢复的 Toast 也能照常点击,而一次性发送方可立即退出;
- 对第三方客户端,点击行为退化为:发送方存活时触发 libnotify
defaultaction;失败则通过omarchy-hyprland-focus-app按 class 聚焦发送方窗口——聊天应用很少注册 action,它们只期望"点了就跳过去"。
七、辅助命令与键盘绑定
围绕同一发送契约的一组薄封装,全部在 bin 下:
omarchy-notification-wait [timeout]:轮询直到 shell 应答 IPC 且已认领总线名(见 bin/omarchy-notification-wait:同时探测omarchy-shell notifications ping与GetServerInformation,每 0.1s 一次)。任何在会话启动或 shell 重启后立即发通知的代码都要先等它,否则 Toast 会发进虚空;omarchy-notification-dismiss <summary>:按摘要子串解散,供首次运行 Toast 在动作被点击后自清理(透传omarchy-shell -q notifications dismiss "$1");omarchy-notification-time/omarchy-notification-battery:热键通知,分别包装date与omarchy-battery-status的单行 low-urgency 字形 Toast;omarchy-notification-weather:名字有误导性,它并不是发送方——它切换omarchy.weathershell 面板。
热键集中在 default/hypr/bindings/utilities.lua(注意 xkbcommon 用小写 comma 命名该键):
| 热键 | 动作 | 调用 |
|---|---|---|
Super+逗号 |
解散最近通知 | omarchy-shell notifications dismissOne |
Super+Shift+逗号 |
解散全部通知 | omarchy-shell notifications dismissAll |
Super+Ctrl+逗号 |
切换免打扰 | bind_toggle notification-silencing |
Super+Alt+逗号 |
触发最近通知动作 | omarchy-shell notifications invokeLast |
Super+Shift+Alt+逗号 |
打开通知历史 | omarchy-shell notifications showHistory |
同文件还有一组与通知/提醒相关的一等公民热键:Super+Ctrl+Alt+T 显示时间、Super+Ctrl+Alt+B 显示电量、Super+Ctrl+Alt+W 切换天气、Super+Ctrl+R 设置提醒、Super+Ctrl+Alt+R 显示提醒、Super+Shift+Ctrl+R 清空提醒。
八、子系统如何接入:三个范例
因为一切都要走同一条发送契约,子系统接入代码都很小:
- 低电量——
omarchy-battery-low发送一条 critical Toast 并运行battery-lowhook; - 崩溃捕获——
omarchy-crash-watch跟随 systemd-coredump journal 流,把每分钟去重后的每个崩溃程序以 critical Toast 宣告,点击运行omarchy-agent-crash(经--exec,因此恶意进程名只能是离散参数)。它先等待服务器:shell 崩溃会连同通知服务器一起倒下,而那次崩溃恰恰最值得上报; - 待执行的迁移——
omarchy-migrate-notify(在其 user service 跟随graphical-session.target启动后)等待服务器,然后发送 critical Toast,点击打开运行omarchy-migrate的终端,移交失败则回退为在终端里打印。
九、提醒(Reminders):骑在通知上的轻量实现
Omarchy 的提醒不设独立守护进程,而是完整复用通知管线与 systemd。入口 bin/omarchy-reminder:
omarchy-reminder 5 # 5 分钟后提醒(默认文案)
omarchy-reminder 30 "Check the oven" # 带自定义消息
omarchy-reminder show [--json] # 查看(--json 输出给顶栏 Reminder 指示器轮询)
omarchy-reminder clear # 清空
omarchy-reminder -i # 调出两步式分钟/消息输入浮层
设定提醒时,systemd-run --user --quiet --collect --on-active=<分钟>m --unit=omarchy-reminder-<分钟>m-<epoch> 创建一个瞬时 systemd user timer;timer 的负载(一条 bash -c)负责发送提醒 Toast、删除消息文件、刷新顶栏指示器。自定义消息存放在 $XDG_RUNTIME_DIR/omarchy-reminders/<unit>.message——因为 unit 名无法携带任意文本。--collect 保证触发过的 timer 不留残余。
状态因此完全活在 systemd 里:show/clear 用 systemctl --user list-timers "omarchy-reminder-*.timer" 枚举——show 汇总成一条摘要 Toast,show --json 输出顶栏 Reminder 指示器轮询的 JSON(含 count/tooltip/每条剩余时间与触发时刻)。交互入口 -i 召唤 shell/plugins/reminders/ReminderFlow.qml 浮层,两步式的分钟/消息输入结束后再壳回 omarchy-reminder 真正落 timer。
十、可测试性与源码地图
由于决策逻辑与 Qt 世界解耦,NotificationLogic.js 可在 Node 中直接运行,配套测试 test/shell.d/notifications-test.sh 覆盖了包括弹窗文件名(popupFileName → <timestamp>-<id>.json)、parseExecArgv 失败关闭、历史截断、Chromium 正文清洗、Service.qml 关键结构在内的断言;test/shell.d/notification-send-test.sh 则验证发送脚本的参数解析行为。
梳理整套系统的源码地图:
- 服务端/持久化/DND/IPC/渲染容器:shell/plugins/notifications/Service.qml
- 纯决策逻辑(可被 Node 加载测试):shell/plugins/notifications/NotificationLogic.js
- 卡片视觉(弹窗与历史共用):shell/plugins/notifications/components/NotificationCard.qml
- 发送方契约与安全边界:bin/omarchy-notification-send
- 辅助命令:bin/omarchy-notification-wait、bin/omarchy-notification-dismiss、
omarchy-notification-time、omarchy-notification-battery、omarchy-notification-weather - 提醒实现:bin/omarchy-reminder 与 shell/plugins/reminders/ReminderFlow.qml
- 热键绑定:default/hypr/bindings/utilities.lua
- 测试:test/shell.d/notifications-test.sh、test/shell.d/notification-send-test.sh
结语
把通知守护进程内建于 shell,换来的是其他发行版要额外拼装多套组件才能获得的能力:所有通知与顶栏、菜单共享同一套主题/圆角/字体(Style.cornerRadius、Color.notifications.*);"弹窗即文件"的映射让更新重启无损;而"发送契约 + argv 执行 + 失败关闭"三位一体地封死了中继内容变成命令的全部路径。如果你要在 Omarchy 里新增任何会打扰用户的系统事件,正确做法是:写一个小脚本,等 omarchy-notification-wait 就绪,再经 omarchy-notification-send(点击动作务必用末尾的 --exec 裸参数形式)发出去——剩下的一切:穿透 DND、历史归档、重启存活,都由这套架构替你完成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00