//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。 //! //! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条) //! //! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 //! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed` //! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。 //! //! 拿到具名错误之后分两条路(判据表第 8 行 / 第 9 行): //! //! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。 //! 代价是子进程把启动流程**重跑一遍**(重新探测?不:`ULL_GPU_FALLBACK` 让它跳过探测; //! 但**字体栈、配置、presenter 都会重算**,实测首帧 531 ms)。这条代价只落在失败路径上。 //! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1 //! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。 //! //! 为什么必须是「换进程」而不是「在本进程里再 `run()` 一次」:Windows 实测直接 panic //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)—— //! winit 一个进程只能建一次事件循环。猜测式重试别再加回来。 use std::time::Instant; use iced::{window, Task}; use unlucky_design::color::SchemeFlavor; use crate::cli::Options; use crate::fonts; use crate::probe; use crate::renderer::{self, Decision, Kind}; use crate::theme::Families; use super::message::Message; use super::state::{App, STARTED}; use super::{MIN_SIZE, WINDOW_SIZE}; /// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。 pub(crate) fn run(options: Options) -> iced::Result { let _ = STARTED.set(Instant::now()); let decision = boot_probe(&options); let font_started = Instant::now(); let resolved = fonts::resolve(); eprintln!( "[font] 求值耗时 {:.0} ms", font_started.elapsed().as_secs_f64() * 1000.0 ); eprintln!("{}", resolved.describe()); if resolved.has_synthetic() { eprintln!( "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理" ); } let families = resolved.families(); // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。 // 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 if options.screenshot.is_some() && options.shot_flavor().is_none() { eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); } let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); // 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前 // 就打到同一个 stderr 上了。 match launch(&options, families, flavor, decision.primary) { Err(iced::Error::GraphicsCreationFailed(error)) => { renderer::report_failure(decision.primary, &error); match on_failure(decision, options.screenshot.as_ref()) { Some(OnFailure::Respawn(fallback)) => respawn(decision.primary, fallback), Some(OnFailure::Refuse) => { // 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的, // 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。 eprintln!( "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", options .screenshot .as_ref() .map_or_else(String::new, |path| path.display().to_string()) ); eprintln!( "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" ); Err(iced::Error::GraphicsCreationFailed(error)) } None => Err(iced::Error::GraphicsCreationFailed(error)), } } other => other, } } /// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OnFailure { /// 交互启动:安全网优先,换进程用这个后端重开。 Respawn(Kind), /// 取证启动:红着退出,**不出图**。 Refuse, } /// 纯判据:起步后端失败时走哪条路。 /// /// `evidence` 非空 = 本次是取证启动(带 `--screenshot`,含 `--screen=gallery`),路径只用于日志。 /// 返回 `None` = 没有兜底可用(`--renderer=cpu` / 未编 wgpu),原样把错误交回去。 /// /// 抽成纯函数只为一个理由:这条分支以前**没有任何测试**,`respawn` / `Refuse` 两条路可以被 /// 静默改反(复审 M97 r1 p2-1 就是这么测出来的)。 fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Option { let fallback = decision.fallback?; Some(match evidence { Some(_) => OnFailure::Refuse, None => OnFailure::Respawn(fallback), }) } /// 降级重开要传给子进程的环境变量(**两条缺一不可**)。 /// /// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。 /// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测 /// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。 /// /// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:这两条都是静默失效型 /// (漏 ① 会让降级重开又跑一次 wgpu、漏 ② 会无限重开),线上都极难看出来。 fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] { [ ("ICED_BACKEND", fallback.env_value()), (renderer::FALLBACK_ENV, fallback.env_value()), ] } /// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。 /// /// 为什么必须是新进程:winit 一个进程只能建一次事件循环 /// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 /// /// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图 /// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。 /// /// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门 /// /// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成 /// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、 /// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」), /// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。 fn respawn(failed: Kind, fallback: Kind) -> ! { let exe = std::env::current_exe().unwrap_or_else(|err| { eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); std::process::exit(exit_code(None)); }); eprintln!( "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ 下一次的日志从 `[probe] 本次是降级重开` 开始。", fallback.name(), failed.name() ); let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status(); // 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。 // 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开, // 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。 let code = match status { Ok(status) => exit_code(status.code()), Err(err) => { eprintln!("[gpu] 降级重开失败:{err}"); exit_code(None) } }; std::process::exit(code) } /// 子进程退出码 → 本进程退出码。 /// /// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。 /// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」, /// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。 fn exit_code(code: Option) -> i32 { /// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。 const RESPAWN_FAILED: i32 = 2; code.unwrap_or(RESPAWN_FAILED) } /// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。 /// /// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的就是让测试能喂一个假命令 /// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测了。 fn fallback_command( program: &std::path::Path, args: impl IntoIterator, fallback: Kind, ) -> std::process::Command { let mut command = std::process::Command::new(program); command.args(args).envs(fallback_env(fallback)); // 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用: // ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见; // ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**, // 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。 // 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明 // (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。 command .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()); command } /// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。 fn launch( options: &Options, families: Families, flavor: SchemeFlavor, backend: Kind, ) -> iced::Result { std::env::set_var("ICED_BACKEND", backend.env_value()); let boot_options = options.clone(); let application = iced::application( move || { ( App::new(boot_options.clone(), families, flavor), Task::done(Message::Boot), ) }, App::update, App::view, ) .title(App::title) .theme(App::theme) .subscription(App::subscription) .default_font(default_font(families)) .window(window::Settings { size: WINDOW_SIZE, min_size: Some(MIN_SIZE), decorations: false, resizable: true, // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**—— // ① iced 0.14 的 `window::Settings` 没有扩展样式字段; // ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true` // 走的是 DWM 那条路,与 `LWA_ALPHA` 无关; // ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在 // `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。 // 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后: // `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。 transparent: false, ..window::Settings::default() }); match options.scale_factor { Some(scale) => application.scale_factor(move |_| scale).run(), None => application.run(), } } /// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。 fn boot_probe(options: &Options) -> Decision { // 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。 // 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。 if let Some(kind) = renderer::forced_fallback() { let decision = renderer::fallback_round(kind); std::env::set_var("ICED_BACKEND", kind.env_value()); eprintln!( "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", renderer::FALLBACK_ENV, kind.env_value(), kind.env_value() ); eprintln!("[probe] {}", decision.describe()); return decision; } let probe_started = Instant::now(); // 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关, // 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。 let needs_probe = options.backend == crate::Backend::Auto; let probe = if needs_probe { probe::Probe::run() } else { probe::Probe::default() }; let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0; // `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。 let preflight = renderer::preflight::run_if_requested(); let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref()); if cfg!(feature = "gpu") { if needs_probe { eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)"); } else { eprintln!( "[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)", options.backend.name() ); } } decision } /// 默认字体(正文档解析出来的族)。 fn default_font(families: Families) -> iced::Font { iced::Font { family: iced::font::Family::Name(families.text), ..iced::Font::DEFAULT } } #[cfg(test)] #[path = "boot/tests.rs"] mod tests;