任务栏时钟"点开"那件事:Tauri 2 + Rust 多窗口架构复盘
任务栏时钟"点开"那件事:Tauri 2 + Rust 多窗口架构复盘
在做 Windows 桌面日历(日程 / 待办 / 提醒 / 桌面组件 + AI 秘书),技术栈 Tauri 2 + React + TypeScript + Rust + SQLite。后端
src-tauri/src下近 40 个模块,ai.rs/bridge.rs/taskbar.rs是三个大头。这篇不讲功能,讲架构选择和踩过的坑,附关键源码。配图只留能说明架构的几张,界面数据来自内置演示数据集(44 条日程 + 27 条待办),都是实机截图。
0. 先把地图摊开
| 层 | 职责 |
|---|---|
前端 UCalendar.Web |
React + TS。所有窗口共用一套页面,靠 URL query 区分角色:/?toast=1、/?menu=1、/?page=notes、/?widget_lock=kind |
网关 bridge.rs |
前端消息统一进 dispatch,回包统一 *_result;慢消息进 watchdog |
| 窗口层 | 每个窗口一个模块:taskbar / menuwin / capsulewin / widgetwin / ctxmenu / notify / notewin |
| 数据层 | db.rs(SQLite)+ files.rs(设置与数据目录,原子写) |
一条贯穿全项目的约束:任何窗口创建都必须走统一的 win_builder(lib.rs:76)。注释写得很直白——漏一处就会重演"二级窗口创建失败"。
多窗口共用一个前端页面的额外收益:主题、字体、字号全在 localStorage + CSS 变量里,新开一个窗口天然就是同一套外观,不需要任何跨窗口同步协议。

1. 任务栏时钟:只能上低级鼠标钩子
任务栏是 Explorer 的 COM 通知区域控件,第三方 App 挂不上左键/右键回调。唯一稳的路子是 WH_MOUSE_LL 全局钩子,再自己判断这一点是否落在时钟矩形里。
// src-tauri/src/taskbar.rs
fn install_hook() {
unsafe {
let Ok(hmod) = GetModuleHandleW(None) else { return };
match SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), Some(HINSTANCE(hmod.0)), 0) {
Ok(hook) => { /* 记 id */ }
Err(e) => log(&format!("hook: install failed: {e:?}")),
}
}
}
三个必须知道的坑,全是从日志里熬出来的:
① LL 钩子回调超时会被系统静默摘除。 钩子一掉,时钟点击直接失灵,没有异常、没有事件日志、没有报错。所以回调里只允许做一件事:把事件投进 channel。
fn hook_thread() {
unsafe {
install_hook();
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).as_bool() {
if msg.message == WM_REHOOK { uninstall_hook(); install_hook(); continue; }
if msg.message == WM_RERECT { update_clock_rect_cache(); continue; }
let _ = TranslateMessage(&msg);
let _ = DispatchMessageW(&msg);
}
// 钩子线程退出会连带摘掉 LL 钩子(钩子随线程死)→ 时钟从此失灵。记一笔。
log("hook: message loop exited (hook lost)");
}
}
消费端是独立线程:左键 → hotkey::toggle_panel,右键 → menuwin::show_at(x, y)。钩子线程只投递,不干活。
② 重活绝不能进钩子线程。 UIA 树遍历、写注册表、通知 Explorer 重绘都是慢操作,放在钩子线程上会被判定超时,钩子照样被摘。所以时钟矩形定位、UIA 诊断全部单开线程(spawn_rect_retry)。
③ DPI 缩放 ≠ 100% 时坐标会错位。 进程是 DPI-unaware(实测 awareness=0),GetWindowRect 返回的是虚拟化坐标。任务栏矩形改成走 UIA(uia_taskbar_rect),GetWindowRect 只留作诊断对账——注释里明写"不做兜底,缩放≠100% 时会错位"。
这一层做扎实的直接收益:任务栏挪到屏幕左侧、缩放到 150%,面板依然贴着时钟正上方弹出。
对应提交:99b5f0c fix: 节假日补零、世界时钟容错与整点语音预热、035417b fix: 右键框选、提示遮挡与组件缩放
2. 自绘菜单:把"菜单点不动"一次性绕开
系统原生菜单(TrackPopupMenuEx / muda)会带一串不可控问题:菜单线程、前台窗口所有权、EndMenu 取消态、#32768 弹窗卡死。踩过一轮之后改成自绘:常驻透明窗 + React 渲染(MenuPage.tsx),菜单模型由 Rust 侧下发,只有一个真源。
真实 bug 一例(提交 b2a1b11):
fix(ctxmenu): 常驻窗加载成错误页时右键弹空白 → 打开时自动重载
常驻窗的好处是打开快,坏处是它可能已经坏掉了而你还在复用。修法很土但有效:打开前检测内容是否为空,空了就重载。
布局用 1D 布局:默认开在卡片右侧,右边空间不够就翻到左侧,锚点始终贴屏幕可用区。自己算比让系统猜靠谱。
3. 组件层级与鼠标穿透

// src-tauri/src/widgetwin.rs
pub fn apply_clickthrough(window: &WebviewWindow, on: bool) -> Result<(), String> {
let topmost = /* widget 配置里的图钉状态 */;
window.set_ignore_cursor_events(on)?;
// 组件默认置顶;穿透必须置顶才能看见。真正层级走 SetWindowPos,
// 因为 tauri 的 always_on_top 只是 tao 记账,flag 没变时不会 reposition。
let effective = topmost || on;
window.set_always_on_top(effective)?;
set_top_band(window, effective);
Ok(())
}
pub(crate) fn set_top_band(window: &WebviewWindow, topmost: bool) {
let Ok(hwnd) = window.hwnd() else { return };
unsafe {
let _ = SetWindowPos(
hwnd,
Some(if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST }),
0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
);
}
}
两条结论:
set_always_on_top(true)只是 tao 层记账,flag 没变化就不会 reposition。要"提到最前"必须直接SetWindowPos;同层置顶窗口之间的先后只能靠调用顺序解决。WS_EX_TRANSPARENT单独用不保证可见。穿透窗口如果不同时置顶,会被资源管理器这类同层不透明窗口画在上面。所以穿透 = 强制置顶,两件事必须绑在一起切。
"开穿透期间组件浮在最上层"这句描述背后是两处窗口层代码,不是一个开关。
4. 胶囊栏:单例透明窗

组件收起后汇总成一条胶囊栏,单窗、不抢焦点、可拖动排序:
// src-tauri/src/capsulewin.rs
let built = crate::win_builder(app, LABEL, tauri::WebviewUrl::App(URL.into()))
.decorations(false)
.transparent(true)
.shadow(false)
.skip_taskbar(true)
.focused(false)
.focusable(false) // 点了不激活整个 app
.visible(false)
.always_on_top(true)
.build();
坑:建窗必须互斥。CREATING.swap(true) 挡住并发建窗,否则两个线程同时判定"缺失",各建一个同 label 窗口,屏幕上出现两条胶囊栏。同类问题在组件窗那边用 SYNC_LOCK 解决(widgetwin::sync)。
提交:613436a feat: 增加收起组件胶囊栏
5. 股票组件:多源回退的预算管理
桌面股票组件的需求很朴素:常驻窗口,自选最多 20 只,实时行情 + K 线,10 秒级刷新。
麻烦不在画图,在外部行情接口不可靠:会限流、会 5xx、被限流时还会回一个 HTML 错误页而不是 JSON、节假日回空数据。桌面常驻组件一天要拉几千次,任何一种都会变成"数据不动 / 白屏"。
所以拆成三层降级:
| 层 | 行为 |
|---|---|
| 主源 | 行情超时 4s;K 线单次预算 4s |
| 镜像源 | 只在主源失败时启用;超时 5s;不支持复权 |
| stale | 两个都失败 → 返回上一次缓存,stale = true,前端明确标注 |
几个具体决定:
预算要给回退留出时间。 K 线不是"每个请求都 7s 超时",而是主源只给 4s、整体 7s 封顶——剩下的 3s 是留给镜像源的。否则主源卡满超时,回退根本没有执行窗口。
// src-tauri/src/stock.rs(节选,源名以别名代替)
let primary_budget = if mirror_supports_kline { PRIMARY_KLINE_BUDGET } else { TOTAL_BUDGET };
let primary = primary_kline(secid, klt, lmt, fqt, primary_budget);
// 剩余时间才给镜像源
let left = TOTAL_BUDGET.saturating_sub(started.elapsed());
节流不是优化,是必需的。 20 只自选 × 10s 刷新,叠加 K 线,很容易在几百毫秒内打出几十个请求。做法是最朴素的全局闸门:同一数据源的两个请求之间强制间隔。
/// 全局节流:同源请求至少间隔 gap_ms,避免触发限流
fn throttle(slot: &'static Mutex<Option<Instant>>, gap_ms: u64) {
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
if let Some(prev) = *guard {
if let Some(left) = Duration::from_millis(gap_ms).checked_sub(prev.elapsed()) {
if !left.is_zero() {
std::thread::sleep(left);
}
}
}
*guard = Some(Instant::now());
}
三级 TTL 缓存,key 必须带足参数。 行情 5s、K 线 30min、搜索 5min。K 线的 key 要带周期和复权参数(secid|klt|lmt|fqt),否则切周期会命中上一组数据。
struct Cached<T> {
payload: Payload<T>,
at: Instant,
}
fn is_fresh<T>(cached: &Cached<T>, ttl: Duration) -> bool {
cached.at.elapsed() < ttl
}
复权要诚实。 镜像源不支持复权,那就不能假装支持:返回 fqtApplied = false,界面直接标"当前数据源未复权"。宁可告诉用户"这组 K 线是未复权的",也不要让人拿未复权价当真实成本。
被限流时要认得出来。 行情接口限流经常不返回状态码,而是回一个 HTML 页面。所以解析之前先看首字符:
fn parse_body(body: &str) -> Result<Value, String> {
let trimmed = body.trim_start();
if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
return Err(format!("接口返回非 JSON(可能被限流): {head}"));
}
serde_json::from_str(trimmed).map_err(|e| format!("JSON 解析失败: {e}"))
}
HTTP 连接必须复用。 每次 K 线都新建 client = 每次重新握手,切周期时肉眼可见地慢。改成进程级单例 OnceLock<Client> 之后,切周期才跟得上手速(提交 0720dc9)。同一阶段还补了加载限时和重试(2da67b4)。
并发返回要能丢弃过期结果。 快速切周期时,先发的请求可能后到,把新周期的图覆盖成旧数据——表现就是"K 线串图"。修法不是加锁阻塞,而是回包时校验它是否仍然匹配当前图表(secid + 周期 + 复权),不匹配就丢弃:
// UCalendar.Web/src/lib/stock.ts
export function klineMatches(msg, chart) {
return msg.secid === chart.item.quoteId
&& periodSpec(chart.period).klt === msg.klt
&& chart.fqt === msg.fqt;
}
错误不许升级成全局弹窗。 一个行情接口挂了不该让整个 app 弹错误 toast。所以消息统一 stock_*_result,错误放在 error 字段里,由组件局部展示。
自选和偏好落 stock.json(原子写、去重、上限 20 只)。另一个刻意的决定在文档层:发布说明里不点名第三方数据源(2abac32 docs: 更新 v2.8.0 发布说明(移除第三方数据源名称))——实现可以换源,但别把某个第三方接口写成对外承诺。
对应提交:414282c feat: 增加项目进度/股票/音乐桌面组件、0720dc9 perf: 复用股票 HTTP 连接降低 K 线延迟、2da67b4 fix: 限制股票 K 线加载时间并支持重试、ac21e7a feat: 设置页搜索与股票 K 线串图修复
6. WebView2 生命周期:几条零散但真实的结论
- 二级窗口统一走
win_builder(lib.rs:76)。绕过一次就会撞上"二级窗口创建失败"。 show/focus是 fire-and-forget,WebView2 首次加载慢时表现为"点了没反应"。修法不是加延时,是加就绪标记:消息先攒着,页面 ready 后 flush(toast 就是这么做的)。- 不能静默失败:
0051dea fix: WebView2 安装改为用户确认并显示安装界面。缺运行时是硬错误,得让用户看见。 - 网络请求必须有限时和重试:
2da67b4 fix: 限制股票 K 线加载时间并支持重试、0720dc9 perf: 复用股票 HTTP 连接降低 K 线延迟。
顺带一个体积账:选 Tauri 而不是 Electron,不打包 Chromium、复用系统 WebView2,整个单体 exe 压在 10MB 以内。代价就是要自己处理上面这些 WebView2 边角行为——拿胶水代码换安装包体积。
7. 数据层:原子写、老库补列、回收站
原子写,三个细节值得抄:
// src-tauri/src/files.rs
pub(crate) fn write_atomic_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
let tmp = tmp_sibling(path, "tmp"); // 完整文件名 + pid + 序号,每次唯一
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
// 先落盘再 rename:否则断电后可能出现"文件名在、内容空"的半截状态
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path)?; // Windows = MoveFileEx REPLACE_EXISTING
Ok(())
}
- 临时名必须每次唯一。固定成
settings.json.tmp时,启动瞬间多处并发保存(面板定位 + 前端设置回写)会让后写者 rename 时源文件已被前一个改名走 →os error 2。 sync_all必须在 rename 之前,否则断电后可能留下"文件名在、内容空"的文件。- rename 失败要把 tmp 删掉,别留垃圾。
老库补列:
// src-tauri/src/db.rs
/// 表里没有该列就补上(幂等;老库升级新字段的统一入口)。
fn ensure_column(conn: &Connection, table: &str, column: &str, decl: &str) -> Result<(), String> {
// PRAGMA table_info 查列 → 没有就 ALTER TABLE ADD COLUMN
}
原因很朴素:CREATE TABLE IF NOT EXISTS 不会给已存在的表加字段。老用户升级后 SELECT 直接 no such column,应用起不来。建表之后补一轮幂等列检查,能省掉绝大部分"升级后打不开"的工单。
并发:89b798d fix: 修复待办勾选 database is locked 并补回归测试。SQLite 锁冲突在快速连点场景是真会出现的,只能收窄写入路径 + 重试,并且必须留回归测试。
回收站是同一层的事:删除不是 DELETE,而是把整行塞进 Trash(Id, Kind, EntityId, Title, Payload, DeletedAt)(UNIQUE(Kind, EntityId)),恢复时用原 Id 回写。所以恢复之后,指向它的日程、待办、附件引用全都还成立——这条不做,恢复功能只是半个摆设。
数据落 %LOCALAPPDATA%\UCalendar;exe 同目录存在 data\ 时改用便携目录,拷走文件夹就是带走全部数据。这条不是功能,是"本地优先"的必然结果:既然不依赖账号体系,数据目录就得自己说了算。
8. 重复日程:直接判断,不展开实例
RRULE 只实现子集:FREQ / INTERVAL / BYDAY / BYMONTHDAY / CN=WORKDAY|HOLIDAY / COUNT / UNTIL。不展开成实例记录,每次按规则直接算,O(1) 判定。日历视图一次要看几十条,展开式实现在月视图里会退化成几千次循环。
两个本地化特殊性:
- "每个工作日"不是
FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR。调休会把周六变成工作日、把工作日变成假期,所以工作日/节假日单独编码,数据走data/cn-holidays.json(由tools/gen-cn-holidays.mjs生成),未知年份回退到"周一到周五=工作日、周六周日=假期"。 - 月末锚点。1/31 的下一次是 2/28 还是 3/31,要按"锚点日"推,不能按"上次实际日期"推,否则边界日会一路漂移。
黄历是同一思路:1900–2100 的宜忌整包离线,不联网也能查;选中日子直接写进日程或项目阶段,中间不经过任何服务端。
对应提交:99b5f0c fix: 节假日补零、世界时钟容错与整点语音预热
9. 项目阶段:把"一条待办"拆成有阶段的推进
项目的模型是三层:项目 → 阶段 → 待办。完成率不是手填百分比,而是"阶段位置 + 当前阶段待办完成度"算出来的;没有待办时不会凭空算出一个假进度。
时间轴上直接拖阶段起止改期,不用翻表单。这块的复杂度不在渲染,在归属:日程按时间轴独立安排,待办能挂到项目和阶段,项目页要能直接看到阶段内待办——三者 id 关系一旦不清晰,后面全是补丁。
对应提交:414282c feat: 增加项目进度/股票/音乐桌面组件
10. AI 秘书:审批门与工作区边界
分两层:哪些工具需要确认(tool_need_approval),以及路径到底算不算工作区内(path_within)。
- 删除类永不豁免。不可恢复的操作,工作区内也要弹确认;其余文件工具按路径分级:区内放行,区外弹确认。
- 工作区边界必须按路径段比较,不能按字符串前缀:
// src-tauri/src/ai.rs
/// 必须按路径段比,不能按字符串前缀比:字符串前缀会把"共享前缀的兄弟目录"误判成区内
/// (工作区 `C:\ws` vs `C:\ws2` → 词法前缀成立 → 区外的文件被当成区内 → 免审批门)。
fn path_within(root: &std::path::Path, p: &std::path::Path) -> bool {
let segs = |s: &str| -> Vec<String> {
norm(s).split('\\').filter(|x| !x.is_empty()).map(str::to_string).collect()
};
let r = segs(&root.to_string_lossy());
let q = segs(&p.to_string_lossy());
q.len() >= r.len() && q[..r.len()] == r[..]
}
C:\ws 和 C:\ws2 共享字符串前缀。用 starts_with 判断,C:\ws2\secret 会被判成"工作区内",审批门直接被绕过。这个坑有专门的回归测试:path_within_compares_segments_not_string_prefix。
- 命令执行:只读白名单 + 参数级 fail-closed,区外路径仍走审批;白名单匹配失败一律按"危险"处理。
panic = "abort" 的 release 配置下,任何 panic 都是直接杀进程。所以 AI / 网络 / 数据库这些附加能力一律返回 Result + 落 debug log,不许 expect。
提交:4cfaa1d fix: 审计修复、设置补丁合并与设置页自检
11. 微信机器人:把桌面能力挂到 IM 上
工程上两个约束:
- token 只存本机,桌面端一关就停服。不做云端常驻,权限面收回本机。
- 陌生微信先发
/pair 配对码。未配对只回引导,不执行任何工具——把 IM 这条不可信链路挡在工具层外面。
提交:ffd79c4 feat: 外观主题系统与微信机器人
12. 外观:主题是 CSS 变量的单一真源
多套主题只维护一套语义 token:--p-fg-2 / --p-hover / --p-accent-soft / --p-success / --p-warn / --p-on-accent,具体色值由 color-mix + var() 派生。新增主题只追加数据,不动样式。
一个反面细节:--p-on-accent 不能写死 #fff。浅色 accent(比如淡蓝)上白字读不出来,得单独给一层"accent 上的前景色"。主题数量一多,token 缺一层就会被放大 N 倍——主题多不是问题,token 不完整才是。
顺带一个相关提交:370cd53 refactor: 提示统一为 <Tip>,删除全局 title 接管。原生 title 属性是系统白框,不跟主题走,只能全部换自绘气泡。
13. 同一套窗口 / 数据 / 主题层最后装下了什么
前面那些窗口层、数据层、主题层的活儿,被复用成了这些能力位:
| 能力 | 复用到的层 |
|---|---|
| 任务栏时钟面板 / 自绘菜单 | taskbar + menuwin + 统一 win_builder |
| 桌面组件 + 胶囊栏 | widgetwin 单例窗 + capsulewin 汇总窗 + 吸附/穿透/层级 |
| 桌面日历三视图 | 组件窗 + 宽度自适应 |
| 股票行情 / K 线 | stock.rs 多源回退 + 分级 TTL 缓存 + stale 快照 |
| 项目 / 阶段 / 时间轴 | projects.rs + Todos 归属关系 |
| 黄历 / 吉日 | 离线数据包 + 日程写入路径 |
| AI 秘书 / 微信机器人 | ai.rs 工具注册表 + 审批门 + bridge.rs 网关 |
| 统一回收站(30 天) | trash.rs 软删除,恢复走原 Id |
| 主题 / 字体 / 字号 | CSS 变量单一真源 + 跨窗口 storage |
14. 踩坑清单
| 模块 | 踩点 | 结论 |
|---|---|---|
| 鼠标钩子 | LL 回调超时被系统静默摘钩,无报错 | 回调里只投递,重活单开线程 |
| 鼠标钩子 | DPI-unaware 下 GetWindowRect 返回虚拟化坐标 |
走 UIA rect,不拿它兜底 |
| 原生菜单 | 菜单线程 / 前台所有权 / EndMenu 取消态 | 直接自绘,模型单源 |
| 窗口层级 | set_always_on_top 只是记账,flag 未变不 reposition |
一律 SetWindowPos |
| 鼠标穿透 | 穿透窗不同时置顶会被盖住 | 穿透 = 强制置顶 |
| 建窗 | 并发 sync 各建一个同 label 窗 | 互斥锁 + 单例 label |
| WebView2 | show/focus fire-and-forget |
就绪标记 + 攒消息 flush |
| 数据 | IF NOT EXISTS 不给老表补列 |
幂等 ensure_column |
| 数据 | 固定 tmp 名并发写 → os error 2 |
tmp 名含 pid + 序号 |
| SQLite | 快连点触发 database is locked |
收窄写路径 + 重试 + 回归测试 |
| 重复日程 | 调休破坏"工作日"语义 | 工作日/节假日独立编码 |
| AI 边界 | 字符串前缀判定被 C:\ws2 绕过 |
按路径段比较 |
| 回收站 | 恢复要保住引用关系 | 存整行 + 原 Id 回写 |
15. 源码
-
后端:
src-tauri/src/(taskbar.rs/widgetwin.rs/capsulewin.rs/menuwin.rs/ai.rs/db.rs/trash.rs) -
前端:
UCalendar.Web/src/(bridge.ts/MenuPage.tsx/pro/widgets.tsx) -
下载:https://github.com/tobaba/UCalendar/releases/latest (单文件 exe,双击即用)
-
蓝奏云:https://wwawo.lanzoub.com/b00ctihkwf 密码:19t5

浙公网安备 33010602011771号