任务栏时钟"点开"那件事: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 全局钩子,再自己判断这一点是否落在时钟矩形里。

rust 复制代码
// 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。

rust 复制代码
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. 组件层级与鼠标穿透

rust 复制代码
// 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,
        );
    }
}

两条结论:

  1. set_always_on_top(true) 只是 tao 层记账 ,flag 没变化就不会 reposition。要"提到最前"必须直接 SetWindowPos;同层置顶窗口之间的先后只能靠调用顺序解决。
  2. WS_EX_TRANSPARENT 单独用不保证可见。穿透窗口如果不同时置顶,会被资源管理器这类同层不透明窗口画在上面。所以穿透 = 强制置顶,两件事必须绑在一起切。

"开穿透期间组件浮在最上层"这句描述背后是两处窗口层代码,不是一个开关。


4. 胶囊栏:单例透明窗

组件收起后汇总成一条胶囊栏,单窗、不抢焦点、可拖动排序:

rust 复制代码
// 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 是留给镜像源的。否则主源卡满超时,回退根本没有执行窗口。

rust 复制代码
// 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 线,很容易在几百毫秒内打出几十个请求。做法是最朴素的全局闸门:同一数据源的两个请求之间强制间隔。

rust 复制代码
/// 全局节流:同源请求至少间隔 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),否则切周期会命中上一组数据。

rust 复制代码
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 页面。所以解析之前先看首字符:

rust 复制代码
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 + 周期 + 复权),不匹配就丢弃:

ts 复制代码
// 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. 数据层:原子写、老库补列、回收站

原子写,三个细节值得抄:

rust 复制代码
// 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(())
}
  1. 临时名必须每次唯一 。固定成 settings.json.tmp 时,启动瞬间多处并发保存(面板定位 + 前端设置回写)会让后写者 rename 时源文件已被前一个改名走 → os error 2。
  2. sync_all 必须在 rename 之前,否则断电后可能留下"文件名在、内容空"的文件。
  3. rename 失败要把 tmp 删掉,别留垃圾。

老库补列:

rust 复制代码
// 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)。

  • 删除类永不豁免。不可恢复的操作,工作区内也要弹确认;其余文件工具按路径分级:区内放行,区外弹确认。
  • 工作区边界必须按路径段比较,不能按字符串前缀:
rust 复制代码
// 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. 源码