Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
//!
//! ## 三条纪律(每条都对应一次返工)
//!
//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动
//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。
//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志
//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进
//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。
//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。
//!
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use std::time::{Duration, Instant};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::{self, acceptance, Decision};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics;
mod fallback;
use fallback::{on_failure, respawn, OnFailure};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
let boot = boot_probe(&options);
let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision);
// ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里)
let font_started = Instant::now();
let resolved = fonts::resolve();
let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.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);
diagnostics::report_theme(&options, flavor);
diagnostics::report_font(
resolved.platform,
boot.probe_ms,
font_ms,
boot_started.elapsed(),
);
// ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了)
let launch_started = Instant::now();
let result = 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, &error.to_string())
}
Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
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,
};
let launch_elapsed = launch_started.elapsed();
diagnostics::report_outcome(
&decision,
result.as_ref().err().map(|error| {
renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string())
}),
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
);
result
}
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
fn launch(
options: &Options,
families: Families,
flavor: SchemeFlavor,
backend: renderer::Kind,
) -> iced::Result {
std::env::set_var("ICED_BACKEND", backend.env_value());
let boot_options = options.clone();
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
let (size, size_source) = acceptance::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source);
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(settings);
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。
///
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
struct Boot {
decision: Decision,
probe: probe::Probe,
probe_ms: Option<Duration>,
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
fn boot_probe(options: &Options) -> Boot {
// 「上一步失败后重开」的那一次:别再探(会得到同一个结论,然后无限重开)。兜底必须是 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());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() {
let started = Instant::now();
let probe = probe::Probe::run();
(probe, Some(started.elapsed()))
} else {
(probe::Probe::default(), None)
};
return Boot {
decision,
probe,
probe_ms,
};
}
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
let probe_started = Instant::now();
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = needs_probe.then(|| probe_started.elapsed());
// `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") {
match probe_ms {
Some(elapsed) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => eprintln!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
),
}
}
Boot {
decision,
probe,
probe_ms,
}
}
/// 默认字体(正文档解析出来的族)。
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;