Nebula 的同步任务(本地目录 ↔ 云端前缀的定时镜像/双向同步)一直是轮询驱动:应用打开时,前端每 分钟检查一遍已保存任务,到点就跑。这次把它换成了真正的文件系统监听——但做完之后,最大的收获不是 “接入了 notify crate”,而是想清楚了一件更基础的事:这个能力只能加速一半的任务,不是全部

先问能力边界,再谈设计

notify crate 能做的事很清楚:监听本地磁盘的变化(inotify / FSEvents / ReadDirectoryChangesW,跨平台细节交给这个 crate 处理)。但 Nebula 支持的同步任务有三种方向:

  • MirrorUp(本地 → 云端):本地变了才需要同步,监听本地目录完全说得通。
  • MirrorDown(云端 → 本地):要同步的是云端的变化,监听本地目录毫无意义——本地压根不是 变化发生的地方。
  • TwoWay(双向):本地变化值得监听,但云端变化依然发现不了。

问题是:云端有没有一个类似 notify 的东西?没有,而且不可能统一有。S3 兼容的事件通知(比如 AWS 的 Bucket Notifications)要配 SNS/SQS/Lambda,这是账号级别的基础设施,Nebula 作为一个客户端 工具,既没有权限、也不该替用户在他们的云账号里创建这些资源。七家云各自的事件通知机制形状还都不一样, 不存在"装一个 crate 就能跨云监听"这种好事。

所以结论从一开始就是确定的:这次改造只对 MirrorUp/TwoWay 有意义,MirrorDown 一行代码都不用 动,继续跑现有的按间隔轮询。这不是"这一版先做一半,以后再补",而是"另一半在现在的云 API 格局下 根本做不了"——先把这条边界画清楚,比先写代码更重要,不然很容易在设计阶段假装"实时监听"是个能覆盖 全部场景的功能,做到一半才发现有一类任务怎么接都接不上。

设计:不加开关,只是让已经选好的行为提速

既然只有一部分任务能从本地监听里受益,第二个问题是:这个能力要不要做成一个新开关(“启用实时监听”), 让用户自己选?

想了一圈之后放弃了这个想法。同步任务已经有一个字段决定"要不要自动跑":interval_mins,0 表示用户 明确选了"仅手动"。如果这时候再引入一个独立的"实时监听"开关,会出现两种奇怪的组合:interval_mins=0 但打开了实时监听(用户到底是要自动还是不要?),或者 interval_mins>0 但关掉了实时监听(那图什么, 反正轮询迟早会跑)。这类"两个开关表达同一件事、但可能互相矛盾"的设计,通常意味着少了一个开关才对。

最后的规则很简单:

  • interval_mins == 0(仅手动)的任务完全不受影响——不加监听,尊重用户已经做出的选择。
  • interval_mins > 0 且模式是 MirrorUp/TwoWay 且本地目录存在的任务,监听本地目录的变化; 监听到、且防抖(2 秒)稳定后,立刻触发一次同步,不用等到下一个整间隔。触发后照常写 last_run,所以原有的按间隔轮询天然继续作为兜底——如果一段时间没有本地变化,到点还是会跑一次, 这对双向同步尤其重要,因为它仍然是唯一能发现"云端有新变化但本地没动"的机制。

没有新数据库字段,没有新 UI。这不是偷懒,而是这个功能严格来说没有做任何用户没请求过的事——只是让 "已经打开的自动化"跑得更快一点。

/// 只有 interval_mins > 0(自动模式)、模式是 MirrorUp/TwoWay(本地变化能推动云端,
/// MirrorDown 反向,本地监听帮不上忙)、且本地目录当下确实存在,才值得监听。
pub fn watch_eligible(job: &SyncJob) -> bool {
    job.interval_mins > 0
        && matches!(job.spec.mode, SyncMode::MirrorUp | SyncMode::TwoWay)
        && Path::new(&job.spec.local_dir).is_dir()
}

调度权搬到 Rust:第一个长期后台任务

在这次改造之前,Nebula 的 Rust 后端没有任何长期运行的后台任务——所有"定时"都是前端 React 里的 setInterval,应用窗口不在前台或者还没挂载时不会跑。引入 notify 之后,继续让前端做主控会很别扭: 计时器在前端,文件系统事件在后端,两条信号要么在前端拼起来(应用刚启动、界面还没挂载时会漏掉后端的 信号),要么调度权干脆搬过去。

选了后者:在 Tauri 的 .setup() 里,用 tauri::async_runtime::spawn 起一个长期任务,里面同时做 两件事——

  1. 每 5 秒(比原来前端的 60 秒细,但只是查一次 SQLite,足够便宜)重新读一遍任务列表,和当前维护的 监听器集合做差异对比:新符合条件的补建监听,任务被删 / 间隔改成 0 / 模式改成 MirrorDown / 本地目录路径变了的移除或重建。
  2. tokio::select! 同时等两件事:计时器到点,或者某个监听器通过一个 mpsc channel 报告"某个 任务的本地目录变了"——命中任一个就触发对应的同步,复用现成的 sync_run
loop {
    reconcile_watchers(&core, &mut watchers, &tx);
    tokio::select! {
        _ = tokio::time::sleep(TICK) => trigger_due_jobs(&core, &app_handle, &running).await,
        Some(job_id) = rx.recv() => trigger_job_by_id(&core, &app_handle, &running, &job_id).await,
    }
}

这段代码本身不复杂,复杂的是"这是第一个"——没有先例可以照抄怎么管理这类任务的生命周期。这次选择了 最省心的答案:不做显式的优雅关闭,进程退出时操作系统自然回收监听器和内部状态。没有现成的"应用关闭时 清理后台任务"的机制要遵守,也没有必要为了给单个功能引入一整套关闭握手协议。

自动触发的运行,不该替用户做决定

双向同步遇到冲突(两侧自上次同步后都改了)时,平时是弹给用户选"保留本地"还是"保留云端"。但自动 触发的运行没有用户盯着屏幕——这时候冲突该怎么办?

答案是什么都不做:自动运行时把"决议表"传空,sync_run 对没有决议的冲突项直接跳过,不计入 本次要执行的动作。冲突不会被静默地按某个默认策略解决掉,也不会导致这次同步整体失败——用户下次手动 打开同步面板,走"预览 + 选择"的完整流程,仍然能看到这些冲突并处理。自动化应该负责把"显然该做的事" 做掉,不该在没人盯着的时候替用户猜一个可能是错的答案。

小结

这次工作量最大的部分不是"接入 notify 处理跨平台事件",而是想清楚两件更朴素的事:这个能力天生 只对一半场景有用(本地监听治不了云端没有推送接口这个根本问题),以及给已经选好的行为提速比新增 一个开关更克制。骨架搭对了,新功能反而不需要教用户学一个新概念。