第 3 章:启动链路——从 main 到可用应用
桌面应用启动不是一个函数,而是一组有依赖、有并行、有降级的阶段。本章追踪 Rust 入口、Tauri setup、配置初始化、窗口创建和 Mihomo 启动,解释哪些步骤必须串行,哪些可以并行,以及为什么 setup panic 被局部隔离。
一、入口先控制 Tokio,而不是直接 app_lib::run()
src-tauri/src/main.rs 首先创建多线程 Tokio runtime:
let worker_limit = min(available_parallelism, 8);
let blocking_limit = 2 * worker_limit;
tokio::runtime::Builder::new_multi_thread()
.worker_threads(worker_limit)
.max_blocking_threads(blocking_limit)
.enable_all()
.build()?;随后把 handle 交给 tauri::async_runtime::set,最后调用 app_lib::run()。
为什么不让 Tauri 使用默认 runtime?项目同时存在网络 I/O、文件读写、WebSocket、sidecar 日志和若干 spawn_blocking 任务。显式限制 worker 和 blocking 线程能避免桌面客户端在高核机器上无节制创建线程,也保证所有 Tauri async command 共享同一 runtime。
二、run() 之前的最早防线
进入 src-tauri/src/lib.rs::run() 后,真正创建 Tauri Builder 之前完成三件事:
- macOS 发布构建执行 launch guard,必要时提前退出;
- 解析 portable 模式,决定应用数据目录;
- 启动单例检查,若已有主实例则把请求交给它并退出。
这三件事必须足够早。目录模式若晚确定,日志和配置可能写错地方;单例若晚检查,两个实例可能同时启动核心和修改系统代理。
三、插件层先建立能力,再进入业务初始化
setup_plugins() 注册了四类插件:
- 系统能力:notification、clipboard、fs、dialog、shell、process;
- 桌面能力:global-shortcut、deep-link、window-state、updater;
- 网络能力:http;
- Mihomo 能力:
tauri-plugin-mihomo,通过本地 socket 连接核心。
其中 Mihomo 插件的 socket path 来自 IClashTemp::guard_external_controller_ipc()。这意味着前后端访问 Mihomo API 不必暴露一个任意 TCP 控制端口,生产路径可以走 Unix socket 或 Windows named pipe。
四、Tauri setup 被拆成两个可降级阶段
setup 闭包内部使用两次 catch_unwind。
4.1 pre-init
第一阶段负责:
- 写入全局
APP_HANDLE; - 初始化工作目录与 logger;
- 注册 autostart;
- 注册 deep link;
- 注册 window-state 插件。
4.2 window-core
第二阶段无论第一阶段是否 panic,都会尝试:
resolve_setup_async():主业务异步启动;resolve_setup_sync():scheme 与嵌入服务器;init_signal():退出信号处理。
为什么要分两次捕获?如果 logger 或某个插件初始化 panic,窗口与核心仍可能以降级模式启动。若把全部 setup 包在一次捕获里,pre-init 的一个问题会跳过后续所有启动工作,用户只看到应用消失或白屏。
降级不等于忽略错误
捕获 panic 后会同时写 stderr 和应用日志。设计目标是“尽量给用户一个可恢复界面”,不是把错误吞掉。
五、resolve_setup_async() 的依赖图
异步初始化的前半段严格串行:
记录版本
↓
macOS Dock 策略
↓
启动脚本
↓
探测 Service 状态
↓
读取 Verge/Profile 配置并同步语言
↓
创建窗口
↓
复制资源 + 初始化 DNS 文件
↓
生成并校验 Runtime 配置
↓
二次确认 Runtime 已生成顺序的原因:
- Service 状态会影响后续核心启动决策;
- 配置决定窗口是否静默启动、语言和主题;
- Runtime 配置必须先生成,CoreManager 才能启动 Mihomo;
- 资源和 DNS 文件是增强流程的输入。
六、核心启动与外围能力并行
前置依赖完成后,系统并行初始化:
join!(
init_core_manager(),
init_tray(),
init_timer(),
init_hotkey(),
init_auto_lightweight_boot(),
init_auto_backup(),
init_silent_updater(),
);这些任务没有严格的先后依赖,串行会直接拉长冷启动。join! 结束后才:
- 向前端发送 Clash 配置刷新;
- 更新托盘菜单;
- 标记 resolve 完成。
这里的设计原则是:先完成“生成可运行状态”的串行主链,再并发启动互相独立的长期能力。
七、Runtime 配置初始化的容错
Config::init_runtime_config() 不是简单调用 enhance():
- 检查 Mixed Port 是否被外部占用;
- 必要时寻找候选端口并跨层持久化;
- 生成运行时 Mapping;
- 写入检查文件;
- 调用 Mihomo 校验;
- 校验失败则切换到最小默认配置;
- 提交 runtime Draft;
- 清理孤儿 Profile 文件。
启动阶段的目标不是“配置必须完美”,而是“尽可能让核心用安全最小配置启动,并明确通知用户原配置的问题”。
八、CoreManager 启动还有端口重试环
CoreManager::init() 调用 start_core()。若失败且当前仍是 NotRunning,它最多三次尝试 Mixed Port fallback:
start_core 失败
├─ 核心其实已运行 → 保留运行状态并返回错误
└─ 核心未运行
↓
再扫描可用端口
├─ 找到并应用 → 重试 start_core
├─ 无需 fallback → 返回原错误
└─ fallback 自身失败 → 阻断本次启动“核心已运行却返回错误”这个分支很重要。错误可能发生在启动后的代理恢复阶段,不能因为函数返回 Err 就假设没有核心,再启动第二个实例。
九、窗口和核心是相对独立的
窗口先于核心完全就绪被创建。这允许:
- 展示 Service 需要安装/修复的对话框;
- 在核心失败时仍提供设置和诊断入口;
- 静默启动时只保留托盘和核心;
- 轻量模式销毁窗口但保持后台代理运行。
因此“应用是否运行”“窗口是否存在”“核心是否运行”是三个不同状态,不能用一个布尔值代替。
十、运行事件与退出
Tauri RunEvent 处理四类关键事件:
Ready/Resumed:标记命令服务器 ready;- macOS
Reopen:退出轻量模式或重新显示窗口; ExitRequested:通常阻止默认退出,转入产品级清理;- 主窗口
CloseRequested:隐藏窗口而不是结束核心。
真正退出会调用 feat::quit(),其责任包括:
- 设置 exiting latch,避免重复退出;
- 停止代理守卫和核心;
- 恢复系统代理/DNS;
- 提交并保存 Draft;
- 关闭嵌入服务器;
- 最终让 Tauri 退出。
操作系统 session ending 可能绕过正常 ExitRequested,代码因此还有 best-effort 清理分支。这是桌面应用与普通 Web 服务不同的地方:退出协议本身也可能不可靠。
十一、启动设计的四条原则
- 越早决定全局环境越好:portable、单例和日志目录在 Builder 前完成。
- 依赖链串行,外围能力并行:不是所有初始化都塞进一个顺序列表。
- 窗口是恢复面:核心失败不应自动导致 UI 消失。
- 每个 start 都必须有 settled:PAC 等临时关闭的能力要在所有返回路径恢复到与 RunningMode 一致。
本章源码索引
src-tauri/src/main.rs:Tokio runtime 建立src-tauri/src/lib.rs::run:Builder、setup、RunEvent 与退出src-tauri/src/lib.rs::app_init:插件、deep link、window statesrc-tauri/src/utils/resolve/mod.rs::resolve_setup_async:业务启动总序src-tauri/src/utils/init.rs:目录、配置文件、资源与平台 schemesrc-tauri/src/config/config.rs::init_runtime_config:启动配置生成src-tauri/src/core/manager/mod.rs::CoreManager::init:核心启动与端口重试src-tauri/src/feat/mod.rs:退出与清理用例导出