Merge branch 'feat/p6-macos-acceptance-one-shot-matrix-diag'

This commit is contained in:
AskaEth committed 2026-11-03 13:18:48 +08:00
commit 4b1471a60d
13 files changed
+1867 -226

No files matched your search

+115 -178
View File
@@ -1,76 +1,87 @@
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
//!
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
//! ## 三条纪律(每条都对应一次返工)
//!
//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是
//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`
//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//! 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`)。
//!
//! 拿到具名错误之后分两条路(判据表第 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 一个进程只能建一次事件循环。猜测式重试别再加回来。
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use std::time::Instant;
use std::time::{Duration, Instant};
use iced::{window, Task};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::{self, Decision, Kind};
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();
let decision = boot_probe(&options);
// ① 探测(判据取在哪、日志在 `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();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
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 字体/按档换族)要处理"
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
// ③ 打印(主题来源 + 字体回退链 + 启动耗时)
// 截图模式:`--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(),
);
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
// 就打到同一个 stderr 上了。
match launch(&options, families, flavor, decision.primary) {
// ④ 起 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),
Some(OnFailure::Respawn(fallback)) => {
respawn(decision.primary, fallback, &error.to_string())
}
Some(OnFailure::Refuse) => {
// 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的,
// 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
@@ -87,122 +98,24 @@ pub(crate) fn run(options: Options) -> iced::Result {
}
}
other => other,
}
}
};
let launch_elapsed = launch_started.elapsed();
/// 起步后端失败后的去向(判据表第 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<OnFailure> {
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()
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,
);
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>) -> 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<Item = std::ffi::OsString>,
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
result
}
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
@@ -210,12 +123,22 @@ fn launch(
options: &Options,
families: Families,
flavor: SchemeFlavor,
backend: Kind,
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 || {
(
@@ -230,22 +153,7 @@ fn launch(
.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()
});
.window(settings);
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
@@ -253,10 +161,20 @@ fn launch(
}
}
/// [`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) -> Decision {
// 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。
// 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。
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());
@@ -267,35 +185,54 @@ fn boot_probe(options: &Options) -> Decision {
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
return decision;
// 验收要 `[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();
// 只有 `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;
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") {
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
match probe_ms {
Some(elapsed) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => eprintln!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
);
),
}
}
decision
Boot {
decision,
probe,
probe_ms,
}
}
/// 默认字体(正文档解析出来的族)。
+110
View File
@@ -0,0 +1,110 @@
//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。
//!
//! ## 为什么单独一个文件(M98 r1 p2-1 的根因)
//!
//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在
//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异
//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与
//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。
//!
//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个
//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。
//!
//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`,
//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。
use std::time::Duration;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform as FontPlatform;
use crate::cli::Options;
use crate::probe::Probe;
use crate::renderer::{acceptance, Decision};
use super::MIN_SIZE;
use crate::app::APP_TITLE;
/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。
///
/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。
pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
}
}
/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。
pub(super) fn report_window(settings: &window::Settings, scale: Option<f32>, size_source: &str) {
let facts = acceptance::WindowFacts::from_settings(settings);
acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE));
acceptance::report_caps(
std::env::current_exe().ok().as_deref(),
facts.transparent,
facts.resizable,
facts.decorations,
);
}
/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。
pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) {
if acceptance::enabled() {
acceptance::announce();
}
acceptance::report_backend_plan(decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(probe, options.scale_factor);
}
}
/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。
///
/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。
pub(super) fn report_font(
platform: FontPlatform,
probe: Option<Duration>,
font: Duration,
to_run: Duration,
) {
if acceptance::enabled() {
acceptance::report_font_chain(platform);
acceptance::report_perf_boot(probe, font, to_run);
}
}
/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。
pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) {
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
}
/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。
///
/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。
/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个
/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。
pub(super) fn report_outcome(
decision: &Decision,
error: Option<String>,
via: &str,
mode: &str,
elapsed: Duration,
) {
acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed);
}
+117
View File
@@ -0,0 +1,117 @@
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。
//!
//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是
//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。
//!
//! ## 为什么必须换进程(别改回进程内重试)
//!
//! Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。
use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind};
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum OnFailure {
/// 交互启动:安全网优先,换进程用这个后端重开。
Respawn(Kind),
/// 取证启动:红着退出,**不出图**。
Refuse,
}
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
pub(super) fn on_failure(
decision: Decision,
evidence: Option<&std::path::PathBuf>,
) -> Option<OnFailure> {
let fallback = decision.fallback?;
Some(match evidence {
Some(_) => OnFailure::Refuse,
None => OnFailure::Respawn(fallback),
})
}
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
///
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
///
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
(renderer::FALLBACK_ENV, fallback.env_value()),
]
}
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
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()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
// 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。
acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
std::process::exit(code)
}
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
pub(super) fn exit_code(code: Option<i32>) -> 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()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
pub(super) fn fallback_command(
program: &std::path::Path,
args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind,
) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.args(args).envs(fallback_env(fallback));
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
command
}
+84
View File
@@ -18,9 +18,12 @@
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
use super::fallback::*;
use super::*;
use crate::renderer::acceptance::WindowFacts;
use crate::renderer::decision;
use crate::renderer::Kind;
use std::ffi::{OsStr, OsString};
use std::path::Path;
@@ -185,3 +188,84 @@ fn no_fallback_means_no_respawn() {
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
assert_eq!(on_failure(not_compiled, None), None);
}
/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。
///
/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent`
/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试
/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。
/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。
#[test]
fn window_settings_match_what_the_acceptance_logs_claim() {
let settings = diagnostics::window_settings((1180.0, 760.0));
assert!(
!settings.decorations,
"M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)"
);
assert!(
settings.resizable,
"验收要验「能否缩放」,窗口必须声明可缩放"
);
assert!(
!settings.transparent,
"整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它"
);
assert_eq!(
settings.min_size,
Some(MIN_SIZE),
"最小尺寸档(880×560)是矩阵的一格,不能被改动"
);
// 日志确实从上面这个 settings 取值(而不是另抄一份字面量)
let facts = WindowFacts::from_settings(&settings);
let line = crate::renderer::acceptance::window_text(
&facts,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(line.contains("resizable=true"), "{line}");
assert!(line.contains("decorations=false"), "{line}");
assert!(line.contains("transparent=false"), "{line}");
assert!(line.contains("size=1180x760"), "{line}");
assert!(line.contains("min=880x560"), "{line}");
// 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反
// (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓)
let odd = iced::window::Settings {
size: iced::Size::new(640.0, 480.0),
min_size: None,
decorations: true,
resizable: false,
transparent: true,
..iced::window::Settings::default()
};
assert_eq!(
WindowFacts::from_settings(&odd),
WindowFacts {
size: (640.0, 480.0),
min: None,
resizable: false,
decorations: true,
transparent: true,
},
"from_settings 必须逐字段搬运,不能在内部再写死一份常量"
);
// 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值)
let flipped = WindowFacts {
decorations: true,
..facts
};
let line = crate::renderer::acceptance::window_text(
&flipped,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(
line.contains("decorations=true"),
"日志必须跟着 settings 走:{line}"
);
}
+5
View File
@@ -41,6 +41,11 @@ fn main() -> ExitCode {
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
);
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
eprintln!(
"验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
);
ExitCode::from(2)
}
}
+69 -47
View File
@@ -1,24 +1,16 @@
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图(`examples/pages_demo.rs` 的入口)。
//!
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
//!
//! ## 用法
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//!
//! ## 为什么还要等 3 帧
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
//! --height=760 --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
@@ -36,9 +28,8 @@ use crate::theme::Ui;
use super::{app, demo_model, Model, Screen};
/// 截图落盘的帧号(见模块文档)。
/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
@@ -75,10 +66,7 @@ impl Default for Request {
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。
///
/// # Errors
/// 未知参数、缺值、数字非法时返回人话。
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
@@ -147,7 +135,7 @@ impl Request {
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
/// 出图文件名片段(`<screen>-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
@@ -155,12 +143,8 @@ impl Request {
} else {
"light"
};
format!(
"{}-{}-{flavor}-w{}",
self.screen.slug(),
class.name(),
self.width as u32
)
let width = self.width as u32;
format!("{}-{}-{flavor}-w{width}", self.screen.slug(), class.name())
}
}
@@ -196,19 +180,16 @@ fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
/// 启动(阻塞到出图或关窗)。
///
/// ## 取证通道的硬规则(M97)
///
/// **宁可红着退出,也不能悄悄换一种后端继续出图。** 以前这条路径在 wgpu 挂掉时会静默降级到
/// tiny-skia,然后照样落一张 PNG,而报告上写的是「GPU 渲染截图」——所有视觉结论的可信度都被
/// 悄悄打折,输出上看不出来。现在:① 决策被 [`renderer::strict`] 剥掉兜底;② 起不来就带着
/// **具名**错误返回;③ 每张图的日志里都带 `backend=`,图能自证来源。
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
///
/// # Errors
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
pub fn run(request: Request) -> Result<(), String> {
let started = Instant::now();
let needs_probe = request.backend == Backend::Auto;
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
let probe = if needs_probe {
Probe::run()
} else {
@@ -221,11 +202,26 @@ pub fn run(request: Request) -> Result<(), String> {
decision.primary.name(),
decision.reason
);
renderer::acceptance::report_backend_plan(&decision, request.backend);
let resolved = fonts::resolve();
eprintln!("{}", resolved.describe());
let families = resolved.families();
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
let settings = window::Settings {
size: iced::Size::new(request.width, request.height),
decorations: false,
resizable: true,
..window::Settings::default()
};
renderer::acceptance::report_window(
&renderer::acceptance::WindowFacts::from_settings(&settings),
request.scale_factor,
"pages_demo",
None,
);
let application = |backend: renderer::Kind| {
let boot = request.clone();
iced::application(
@@ -237,6 +233,8 @@ pub fn run(request: Request) -> Result<(), String> {
request: boot.clone(),
window: None,
frames: 0,
started,
first_frame: None,
backend: backend.name(),
},
Task::done(Message::Boot),
@@ -251,12 +249,7 @@ pub fn run(request: Request) -> Result<(), String> {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
})
.window(window::Settings {
size: iced::Size::new(request.width, request.height),
decorations: false,
resizable: true,
..window::Settings::default()
})
.window(settings)
};
eprintln!(
@@ -270,21 +263,35 @@ pub fn run(request: Request) -> Result<(), String> {
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
renderer::run_once(decision, |backend| {
let session_started = Instant::now();
let result = renderer::run_once(decision, |backend| {
std::env::set_var("ICED_BACKEND", backend.env_value());
let application = application(backend);
match request.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
})
.map_err(|error| match renderer::named_graphics_error(&error) {
});
let session = session_started.elapsed();
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
Some(named) => format!(
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
decision.primary.name()
),
None => format!("iced 启动失败:{error}"),
})?;
});
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
renderer::acceptance::report_outcome(
&decision,
outcome.as_ref().err().map(String::as_str),
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
"screenshot",
session,
);
outcome?;
eprintln!(
"[harness] 用时 {:.0} ms",
@@ -300,6 +307,10 @@ struct Harness {
model: Model,
window: Option<window::Id>,
frames: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
first_frame: Option<std::time::Duration>,
/// 出这张图的后端(`wgpu` / `tiny-skia`)。图要能**自证来源**——以前它证不了(M97)。
backend: &'static str,
}
@@ -323,6 +334,9 @@ impl Harness {
Task::none()
}
Message::Frame => {
if self.frames == 0 {
self.first_frame = Some(self.started.elapsed());
}
self.frames += 1;
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
return Task::none();
@@ -347,6 +361,14 @@ impl Harness {
}
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
renderer::acceptance::report_perf_scene(
&self.request.slug(),
self.frames,
self.first_frame,
self.started.elapsed(),
);
iced::exit()
}
Message::Page(message) => {
@@ -0,0 +1,246 @@
//! `[env]` 与 `[i18n]`:**本机是什么**(系统 / arch / 核数 / GPU / 时区)与**为什么是这个语言**。
//!
//! ## 语言链为什么在这里而不是 `platform/`
//!
//! `platform::locale::SystemLocale` 是 **Windows 注册表**那一路(`reg.exe`,零 FFI)。macOS 的
//! 真值源是 `defaults read -g AppleLanguages`,而 M98 的 scope 没有 `platform/**`,所以
//! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。
use unlucky_i18n::lang::{self, Detection, LocaleSource};
use crate::Probe;
// ---------------------------------------------------------------- [env]
/// `[env]`:系统 / arch / 核数 / 语言 / 时区 / 缩放请求 / GPU。
///
/// `gpu` 与 `hardware` **来自真实适配器枚举**([`Probe`]),不是请求值;枚举不到就写
/// `<无适配器>` 并把 `hardware=false` 一起打出来——「没有适配器」与「没枚举」是两件事。
///
/// `display=` 恒为 `needs-window`:显示器尺寸只有拿到窗口句柄才问得到,而这一行在开窗之前就打了。
/// 真值由验收脚本用 `system_profiler SPDisplaysDataType` 采进 `logs/00-env.txt`——**写个猜的值
/// 比留白更坏**(那会让「Monitor 4K / 无外接」这种结论变成猜测)。
#[must_use]
pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&str>) -> String {
format!(
"[env] os={} arch={} cores={} language={language} tz={} scale_requested={} display=needs-window \
adapters={} hardware={} gpu=\"{}\"",
std::env::consts::OS,
std::env::consts::ARCH,
std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get),
tz.unwrap_or("<未设置>"),
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
probe.adapters.len(),
probe.hardware,
gpu_summary(probe),
)
}
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要:`DiscreteGpu > IntegratedGpu > 其它`。
///
/// 为什么不是「第一个非 Cpu/Other」(M98 r1 p3-2):本机枚举顺序是核显在前,于是 `[env] gpu=`
/// 报的是 `AMD Radeon(TM) Graphics [IntegratedGpu]`,而 iced 实际选的是
/// `AMD Radeon RX 6600 XT [DiscreteGpu]`。一行「机器有什么显卡」的日志报错型号,比不报更坏。
/// 判据仍然只管**型号名称**:真正的「iced 选了谁」只有 `[gpu] …Selected: AdapterInfo` 那行有。
#[must_use]
pub fn gpu_summary(probe: &Probe) -> String {
probe
.adapters
.iter()
.max_by_key(|adapter| gpu_rank(adapter.device_type))
.map_or_else(
|| "<无适配器>".to_owned(),
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
)
}
/// 适配器的「该不该被当成这台机器的显卡」排序权重(越大越该)。
fn gpu_rank(device_type: &str) -> u8 {
match device_type {
"DiscreteGpu" => 3,
"IntegratedGpu" => 2,
"VirtualGpu" => 1,
"Cpu" | "Other" => 0,
_ => 1,
}
}
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
///
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
#[must_use]
pub fn tz() -> Option<String> {
std::env::var("TZ")
.ok()
.filter(|value| !value.trim().is_empty())
}
// ---------------------------------------------------------------- [i18n]
/// 语言决策链的四级(顺序即优先级:显式值 > 平台源 > 环境变量 > 英文)。
///
/// 每一级的**原始值**都留着,日志才能解释「为什么是英文」——只打结论的话,macOS 上「英文」
/// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chain {
/// `ui.language` 的显式值(当前没有读取点,恒 `None`)。
pub setting: Option<String>,
/// (源自称的名字, 原始 locale)。
pub platform: (&'static str, Option<String>),
/// (源自称的名字, 原始 locale)。
pub env: (&'static str, Option<String>),
/// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。
pub detection: Detection,
}
impl Chain {
/// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
#[must_use]
pub fn read(
setting: Option<&str>,
platform: &dyn LocaleSource,
env: &dyn LocaleSource,
) -> Self {
let platform = Captured {
label: platform.label(),
raw: platform.locale(),
};
let env = Captured {
label: env.label(),
raw: env.locale(),
};
// 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去,
// 不复制一遍 if/else(复制就会漂)。
let detection = lang::resolve(setting, &platform, &env);
Self {
setting: setting.map(str::to_owned),
platform: (platform.label, platform.raw),
env: (env.label, env.raw),
detection,
}
}
/// 本机(平台源 + 环境变量;配置未接线)。
#[must_use]
pub fn host() -> Self {
Self::read(None, platform_locale_source(), &lang::EnvLocale)
}
/// 结论语言标签(`zh-CN` / `en`)。
#[must_use]
pub fn language(&self) -> &'static str {
self.detection.lang.tag()
}
/// `[i18n]` 一行:四级链 + 结论。
///
/// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、
/// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话,
/// 日志会让人以为「语言已经是中文了」——那是新的假绿。
#[must_use]
pub fn line(&self) -> String {
format!(
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
// `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。
// 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。
self.setting.as_deref().unwrap_or("<未接线>"),
self.platform.0,
show(self.platform.1.as_deref()),
self.env.0,
show(self.env.1.as_deref()),
self.detection.describe()
)
}
}
fn show(value: Option<&str>) -> String {
value.map_or_else(|| "<未设置>".to_owned(), |raw| raw.to_owned())
}
/// 把值钉住的源(为了「读一次、按真优先级算」)。
struct Captured {
label: &'static str,
raw: Option<String>,
}
impl LocaleSource for Captured {
fn locale(&self) -> Option<String> {
self.raw.clone()
}
fn label(&self) -> &'static str {
self.label
}
}
/// 本平台的「系统语言」真值源。
///
/// - Windows:`reg.exe` 读注册表([`crate::platform::locale::SystemLocale`]);
/// - macOS:[`AppleLanguages`](`defaults read -g AppleLanguages`);
/// - 其它:没有平台源(`resolve` 会继续往环境变量走)。
///
/// 为什么 macOS 不复用 `SystemLocale`:它 `cfg!(windows)` 之外恒返回 `None`,在 Mac 上等于
/// 「没有平台源」——于是语言只剩环境变量一级,而 GUI 程序从 Finder 启动时 `LANG` 通常是空的。
#[must_use]
pub fn platform_locale_source() -> &'static dyn LocaleSource {
#[cfg(target_os = "macos")]
{
&AppleLanguages
}
#[cfg(not(target_os = "macos"))]
{
&crate::platform::locale::SystemLocale
}
}
/// macOS 的系统语言源:`defaults read -g AppleLanguages` 的第一项。
#[derive(Debug, Clone, Copy, Default)]
pub struct AppleLanguages;
impl LocaleSource for AppleLanguages {
fn locale(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("defaults")
.args(["read", "-g", "AppleLanguages"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_apple_languages(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
fn label(&self) -> &'static str {
"applelanguages"
}
}
/// 解析 `defaults` 的输出,取**第一条**语言(真机格式见测试夹具)。
///
/// ```text
/// (
/// "zh-Hans-CN",
/// "en-US"
/// )
/// ```
///
/// 首项是用户的**首选语言**;后面几项是「语言与地区」里的备选,与本 crate「只看主语言子标签」
/// 的口径无关。
#[must_use]
pub fn parse_apple_languages(text: &str) -> Option<String> {
let start = text.find('"')?;
let rest = &text[start + 1..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_owned())
}
#[cfg(test)]
#[path = "locale_tests.rs"]
mod tests;
@@ -0,0 +1,141 @@
//! `[env]` / `[i18n]` 的钉死测试(从 `tests.rs` 拆出来:那边撞到 400 行上限)。
//!
//! 这两类都靠「把真实输入喂进纯函数再逐字断言」,抓得住的是本项目的头号事故类型:
//! **在需要结果的地方打印请求或常量**。
use super::*;
use crate::probe::AdapterSummary;
use crate::Probe;
use unlucky_i18n::lang::LocaleSource;
fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
let adapters: Vec<AdapterSummary> = adapters
.iter()
.map(|(name, device_type)| AdapterSummary {
name: (*name).to_owned(),
device_type,
})
.collect();
let hardware = adapters
.iter()
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
Probe { adapters, hardware }
}
/// 抓得住:`[env]` 的 `gpu=` 取的是**硬件**适配器而不是枚举里的第一个(WARP 排在第一是本机实测
/// 的形状:`Microsoft Basic Render Driver [Cpu]`)。改回 `adapters.first()` 这条就红。
#[test]
fn env_line_prefers_the_hardware_adapter() {
let probe = probe_of(&[
("Microsoft Basic Render Driver", "Cpu"),
("AMD Radeon RX 6600 XT", "DiscreteGpu"),
]);
let line = env_line(&probe, "zh-CN", Some(1.0), Some("Asia/Shanghai"));
assert!(
line.contains("gpu=\"AMD Radeon RX 6600 XT [DiscreteGpu]\""),
"{line}"
);
assert!(line.contains("adapters=2"), "{line}");
assert!(line.contains("hardware=true"), "{line}");
assert!(line.contains("language=zh-CN"), "{line}");
assert!(line.contains("tz=Asia/Shanghai"), "{line}");
assert!(line.contains("scale_requested=1"), "{line}");
// 显示器只有开窗后才知道:这里必须留白并**自证**留白(写个猜的值比留白更坏)
assert!(line.contains("display=needs-window"), "{line}");
assert!(line.starts_with("[env] os="), "{line}");
}
/// 抓得住:「一个适配器都没枚举到」不许写成 `gpu=""` 或者省略(那会让人以为枚举到了)。
#[test]
fn env_line_says_so_when_there_is_no_adapter() {
let line = env_line(&Probe::default(), "en", None, None);
assert!(line.contains("gpu=\"<无适配器>\""), "{line}");
assert!(line.contains("adapters=0 hardware=false"), "{line}");
assert!(line.contains("scale_requested=system"), "{line}");
assert!(line.contains("tz=<未设置>"), "{line}");
}
/// 抓得住:`[i18n]` 必须把**每一级的原始值**打出来,且结论跟着真优先级走。
/// 抓不住:这台机器的 `defaults` / `reg.exe` 真的返回什么(那是真机验收)。
#[test]
fn i18n_line_shows_every_rung_and_the_resolved_language() {
struct Fixed(Option<&'static str>, Option<&'static str>);
impl LocaleSource for Fixed {
fn locale(&self) -> Option<String> {
self.0.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.1.unwrap_or("injected")
}
}
let chain = Chain::read(
None,
&Fixed(Some("zh-Hans-CN"), Some("applelanguages")),
&Fixed(None, Some("env")),
);
assert_eq!(chain.language(), "zh-CN");
let line = chain.line();
assert!(
line.contains("platform=applelanguages:zh-Hans-CN"),
"平台源原始值必须打出来:{line}"
);
assert!(line.contains("env=env:<未设置>"), "{line}");
assert!(
line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"),
"{line}"
);
assert!(line.contains("ui_text=pinned-en"), "{line}");
}
/// 抓得住 macOS 最典型的失败形态:**一个源都没有**(Finder 启动的 GUI 进程既没有 `LANG`
/// 也没有注册表)。这时必须是 `lang=en source=default`,而不是「假装读到了英文」。
#[test]
fn i18n_line_distinguishes_no_source_from_english_source() {
struct Nothing;
impl LocaleSource for Nothing {
fn locale(&self) -> Option<String> {
None
}
fn label(&self) -> &'static str {
"applelanguages"
}
}
let empty = Chain::read(None, &Nothing, &Nothing);
assert_eq!(empty.language(), "en");
assert!(empty.line().contains("source=default"), "{}", empty.line());
struct EnvEnglish;
impl LocaleSource for EnvEnglish {
fn locale(&self) -> Option<String> {
Some("en_US.UTF-8".to_owned())
}
fn label(&self) -> &'static str {
"env"
}
}
let env = Chain::read(None, &Nothing, &EnvEnglish);
assert!(env.line().contains("source=env"), "{}", env.line());
assert!(
env.line().contains("locale=en_US.UTF-8"),
"两种「英文」必须能从日志上分开:{}",
env.line()
);
}
/// 真机 `defaults read -g AppleLanguages` 的输出形状(多行、带引号、逗号)。
#[test]
fn apple_languages_takes_the_first_entry() {
let real = "(\n \"zh-Hans-CN\",\n \"en-US\"\n)\n";
assert_eq!(parse_apple_languages(real).as_deref(), Some("zh-Hans-CN"));
assert_eq!(
parse_apple_languages("(\n \"en-US\"\n)\n").as_deref(),
Some("en-US")
);
assert_eq!(parse_apple_languages("()"), None);
assert_eq!(parse_apple_languages(""), None);
// 空串(某些机器上 `defaults` 会回 `(\n)`)不许被当成语言
assert_eq!(parse_apple_languages("(\n \"\"\n)"), None);
}
@@ -0,0 +1,321 @@
//! 验收诊断(M98):把「借朋友一台 Mac、反复问 7 个问题」压成「一次运行、日志自己回答」。
//!
//! ## 八类日志的分工(M98 任务表)
//!
//! | 类 | 谁打 | 是不是真实结果 |
//! |---|---|---|
//! | `[env]` | [`locale`] | 是(`os/arch/cores` 来自编译目标;`gpu` 来自真适配器枚举;`language` 来自下面的语言链) |
//! | `[backend]` | 本模块(`app::boot` / `pages::harness` 调用) | 是(单项 `ICED_BACKEND` ⇒ 起步成功就是它;失败带具名错误) |
//! | `[window]` | 本模块 | **请求值**(`requested=`)。实际值由脚本从 `[shot] …scale=` 与 PNG 像素尺寸换算 |
//! | `[font]` | `fonts`(已有)+ [`visual`](`chain`) | 是:`platform=` 是实际落点,`chain=` 是令牌声明的回退链 |
//! | `[theme]` | 本模块 | 是(来源 = 哪个参数/系统决定;系统真值更晚,由已有 `[theme] 系统主题 = …` 打) |
//! | `[perf]` | 本模块 | 是(`Instant` 实测) |
//! | `[i18n]` | [`locale`] | 是(四级链的**每一级原始值**都打出来,结论由 `unlucky_i18n::lang::resolve` 给) |
//! | `[caps]` | [`visual`] | 半观测(`bundle` / `menu_bar_name` 从可执行文件路径读)、半**请求值**(`transparent` / `decorations`) |
//!
//! **反模式警告(本项目反复踩的那一类)**:在需要「结果」的地方打「请求」。所以本模块凡是请求值
//! 都给字段加后缀(`requested` / `…_requested`),凡是推断出来的都给 `_source`,拿不到就写
//! `<未设置>` / `pending` / `none`,**不猜**。
//!
//! ## 开关
//!
//! [`ACCEPTANCE_ENV`](`ULL_ACCEPTANCE=1`)打开「贵的那部分」:`[env]` 里的适配器枚举(本机
//! 0.65~1.2 s)在 `--renderer=gpu|cpu` 时本来会被跳过,验收要它 ⇒ 这个开关强制枚举一次。
//! 关掉它对产品行为没有任何影响:本模块只打日志,不改决策。
//!
//! 便宜的行(`[backend]` / `[window] requested` / `[theme]`)**没有开关**:报 bug 的人随手截的
//! 日志里就该有「实际用哪个后端」这件事。
use std::time::Duration;
use crate::renderer::{Decision, Kind};
use crate::{Backend, Probe};
pub mod locale;
pub mod visual;
pub mod window;
pub use locale::{env_line, tz, Chain};
pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain};
pub use window::{
parse_window_size, report_window, resolve_window_size, window_size, window_text, WindowFacts,
WINDOW_SIZE_ENV,
};
/// 打开「贵的诊断」的开关(见模块文档 §开关)。
pub const ACCEPTANCE_ENV: &str = "ULL_ACCEPTANCE";
/// `ULL_ACCEPTANCE` 是否打开(缺省关;`0` / `off` / `false` / `no` 也算关)。
#[must_use]
pub fn enabled() -> bool {
match std::env::var(ACCEPTANCE_ENV) {
Ok(value) => !matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "off" | "false" | "no"
),
Err(_) => false,
}
}
/// 第一行:告诉读日志的人「后面这些类是刻意打开的,不是噪声」。
pub fn announce() {
eprintln!(
"[acceptance] on({ACCEPTANCE_ENV})八类诊断已开:[env] [backend] [window] [font] [theme] \
[perf] [i18n] [caps]。关掉:{ACCEPTANCE_ENV}=0"
);
}
/// 启动那一段(只在不贵的时候由调用方决定打不打):`[env]` + `[i18n]`。
///
/// 两行都吃**同一次**适配器枚举与**同一次**语言链(`defaults` / `reg.exe` 是子进程,读两次就是
/// 两次开销,而且两次之间值可能变)。
pub fn report_startup(probe: &Probe, scale: Option<f32>) {
let chain = Chain::host();
eprintln!(
"{}",
env_line(probe, chain.language(), scale, tz().as_deref())
);
eprintln!("{}", chain.line());
}
// ---------------------------------------------------------------- [theme]
/// `[theme]`:本次主题**由谁决定**(来源)+ 语义值。
///
/// `follows_system=true` 时 `flavor=pending`:系统主题是异步到达的(`iced::system::theme()`),
/// 启动这一刻还没有值。真值随后由 `app/update.rs` 的 `[theme] 系统主题 = Dark` 给出——
/// **两行合起来才是完整答案**,缺一行就分不清「跟了系统」和「没跟」。
pub fn report_theme(source: &str, flavor: Option<&str>, follows_system: bool) {
eprintln!("{}", theme_text(source, flavor, follows_system));
}
/// 主题**由谁决定**(纯判据,好测):`(来源, 是否跟随系统)`。
///
/// 顺序与 `Options::capture_flavor` 一致:`--screenshot-theme` > `--theme` > 截图默认 Light >
/// 系统。**取值口只有一个**(`capture_flavor`),这里只回答「为什么是它」。
#[must_use]
pub fn theme_source(screenshot_theme: bool, theme: bool, screenshot: bool) -> (&'static str, bool) {
match (screenshot_theme, theme, screenshot) {
(true, _, _) => ("screenshot-theme", false),
(_, true, _) => ("theme", false),
(_, _, true) => ("default-light", false),
_ => ("system", true),
}
}
/// 见 [`report_theme`](单独返回字符串是为了让测试能逐字断言)。
#[must_use]
pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> String {
format!(
"[theme] source={source} flavor={} follows_system={follows_system}",
flavor.unwrap_or("pending")
)
}
// ---------------------------------------------------------------- [backend]
/// `[backend] plan`:这一次准备试**哪些**后端(单项 + 本 crate 的兜底,不是 iced 的候选回退)。
pub fn report_backend_plan(decision: &Decision, request: Backend) {
eprintln!("{}", backend_plan_text(decision, request));
}
/// 见 [`report_backend_plan`]。
#[must_use]
pub fn backend_plan_text(decision: &Decision, request: Backend) -> String {
format!(
"[backend] plan request={} primary={} fallback={} candidates=1 reason={}",
request.name(),
decision.primary.name(),
decision.fallback.map_or("none", Kind::name),
decision.reason,
)
}
/// `[backend] final`:起步的**结果**。
///
/// 判据是 M97 的「单项 `ICED_BACKEND`」:候选只有一项 ⇒ `run()` 能正常起来就必定是它
/// (没有静默回退的空间)。`evidence=probe-single-item` 就是这条推理的名字,别把它读成
/// 「我们查了渲染器内部」——那件事只有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` 那行能做。
pub fn report_backend_started(primary: Kind, elapsed: Duration) {
eprintln!("{}", backend_started_text(primary, elapsed));
}
/// 见 [`report_backend_started`]。
#[must_use]
pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String {
format!(
"[backend] final={} result=started elapsed={}ms evidence=probe-single-item",
primary.name(),
elapsed.as_millis()
)
}
/// `[backend] final`:起步失败(具名错误原样带在 `error=` 里,一行可 grep)。
///
/// `via` = 失败之后去了哪:
/// - `respawn`:交互启动换了进程用兜底后端重开(子进程自己再打一遍 plan/final);
/// - `refuse`:取证启动(`--screenshot`)拒绝降级出图,**没有图**;
/// - `none`:没有兜底(`--renderer=cpu` / 没编 wgpu),原样失败。
///
/// `final` 是**终态**:`refuse` / `none` 两条路什么都没跑起来 ⇒ `final=none`(不是兜底名)。
/// 第一版让 `final=` 与 `fallback=` 装同一个值,于是 `[backend] final=tiny-skia result=failed`
/// 会被 REPORT 的「后端实际选中」照抄成「这次跑的是 tiny-skia」——报告说谎(M98 r1 p3-4)。
pub fn report_backend_failed(
primary: Kind,
fallback: Option<Kind>,
final_kind: Option<Kind>,
via: &str,
error: &str,
) {
eprintln!(
"{}",
backend_failed_text(primary, fallback, final_kind, via, error)
);
}
/// 见 [`report_backend_failed`]。
#[must_use]
pub fn backend_failed_text(
primary: Kind,
fallback: Option<Kind>,
final_kind: Option<Kind>,
via: &str,
error: &str,
) -> String {
let fallback_name = fallback.map_or("none", Kind::name);
let final_name = final_kind.map_or("none", Kind::name);
// `error="…"` 必须是**格式良好的单个字段**:具名错误里常自带双引号(wgpu 的 Debug 输出),
// 原样塞进去会把这一行撕成两半,脚本按 `error="` 取值就只能取到前半截。
let error = error.replace('"', "'").replace(['\n', '\r'], " ");
format!(
"[backend] final={final_name} result=failed primary={} fallback={fallback_name} \
via={via} error=\"{error}\"",
primary.name(),
)
}
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::fallback::OnFailure` 同一套取值)。
///
/// 放在这里而不是 `app/boot/fallback.rs`:`mod boot` 在 `app/mod.rs` 里是私有的(那份文件不在
/// M98 的 scope),`pages::harness` 够不到它,而两个调用点必须用**同一个**判据。
#[must_use]
pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
match (has_fallback, evidence) {
(false, _) => "none",
(true, true) => "refuse",
(true, false) => "respawn",
}
}
/// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。
///
/// `final_kind` 的口径:`respawn` 时是**兜底**(进程即将带着它重开),`refuse` / `none` 时是
/// `None`(什么都没跑起来)——见 [`backend_failed_text`]。成功时恒为 `primary`。
pub fn report_outcome(
decision: &Decision,
error: Option<&str>,
via: &str,
mode: &str,
elapsed: Duration,
) {
let final_kind = match via {
"respawn" => decision.fallback,
_ => None,
};
match error {
None => report_backend_started(decision.primary, elapsed),
Some(message) => report_backend_failed(
decision.primary,
decision.fallback,
final_kind,
via,
message,
),
}
if enabled() {
report_perf_session(mode, elapsed, error.is_none());
}
}
/// `[perf] boot`:启动三段(探测 / 字体 / 到 `run()` 之前)的实测毫秒数。
///
/// `probe = None` ⇒ 打 `probe=skipped`。**不许拿 `0ms` 冒充一次实测**(M98 r1 p3-1:降级重开那一轮
/// 没计时却报了 0,而报告说「[perf] 全是 Instant 实测」)。
pub fn report_perf_boot(probe: Option<Duration>, font: Duration, to_run: Duration) {
eprintln!("{}", perf_boot_text(probe, font, to_run));
}
/// 见 [`report_perf_boot`]。
#[must_use]
pub fn perf_boot_text(probe: Option<Duration>, font: Duration, to_run: Duration) -> String {
format!(
"[perf] boot probe={} font={}ms to_run={}ms",
probe.map_or_else(
|| "skipped".to_owned(),
|value| format!("{}ms", value.as_millis())
),
font.as_millis(),
to_run.as_millis()
)
}
/// `[perf] session`:`iced::application().run()` 从进到出(或到出图)的总时长。
///
/// 取证启动(`--screenshot`)的出图在第 3 帧,所以这条约等于「进程启动 → 出图」——验收里它就是
/// 「这个场景要多久」。交互启动那次是「窗口开了多久」,所以带 `mode=` 区分,别混着读。
pub fn report_perf_session(mode: &str, elapsed: Duration, ok: bool) {
eprintln!("{}", perf_session_text(mode, elapsed, ok));
}
/// 见 [`report_perf_session`]。
#[must_use]
pub fn perf_session_text(mode: &str, elapsed: Duration, ok: bool) -> String {
format!(
"[perf] session mode={mode} elapsed={}ms result={}",
elapsed.as_millis(),
if ok { "ok" } else { "failed" }
)
}
/// `[perf] scene`:**取证场景**(`pages_demo` 通道)的帧数据。
///
/// `fps` 由 `frames / elapsed` 算——注意它是**取证窗口**(第 1 帧到第 N 帧)的吞吐,不是长时间
/// 稳态帧率:产品路径的长跑帧率看 `[tick]` 那串(`ULL_TICK_TRACE=1`,每秒一行、带累计帧数)。
/// 两个来源刻意不合并:一个是「出这张图花了多久」,一个是「放着不动烧不烧风扇」。
pub fn report_perf_scene(
scene: &str,
frames: u32,
first_frame: Option<Duration>,
elapsed: Duration,
) {
eprintln!("{}", perf_scene_text(scene, frames, first_frame, elapsed));
}
/// 见 [`report_perf_scene`]。
#[must_use]
pub fn perf_scene_text(
scene: &str,
frames: u32,
first_frame: Option<Duration>,
elapsed: Duration,
) -> String {
// fps 只从**首帧之后**的帧间隔算:整段起点到首帧那段是启动成本(字体扫描、适配器枚举),
// 把它算进帧率会让「8 fps」这种结论完全是启动慢的假象。少于 2 帧就没有间隔可测 ⇒ unknown。
let fps = first_frame.and_then(|first| {
let window = elapsed.checked_sub(first)?;
(frames >= 2 && !window.is_zero()).then(|| f64::from(frames - 1) / window.as_secs_f64())
});
format!(
"[perf] scene={scene} frames={frames} first_frame={}ms elapsed={}ms fps={}",
first_frame.map_or_else(
|| "unknown".to_owned(),
|value| value.as_millis().to_string()
),
elapsed.as_millis(),
fps.map_or_else(|| "unknown".to_owned(), |value| format!("{value:.1}")),
)
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
@@ -0,0 +1,395 @@
//! `[env]` / `[i18n]` / `[window]` / `[caps]` / `[backend]` / `[perf]` 六类行的钉死测试。
//!
//! **这些测试只钉「给定真实输入 → 打出什么」**:格式化函数是纯的,所以能断言逐字字符串。
//! 它们抓不住「输入本身是不是真的」(那要靠验收脚本在真机上 grep),抓得住的是这一整类事故:
//! **在需要结果的地方打印请求 / 常量**——每一格都有一个能改坏的断言。
use std::path::Path;
use std::time::Duration;
use super::*;
use crate::probe::AdapterSummary;
use crate::renderer::decision::{decide, Kind};
use crate::Probe;
use unlucky_design::typography::Platform as FontPlatform;
fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
let adapters: Vec<AdapterSummary> = adapters
.iter()
.map(|(name, device_type)| AdapterSummary {
name: (*name).to_owned(),
device_type,
})
.collect();
let hardware = adapters
.iter()
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
Probe { adapters, hardware }
}
/// 抓得住:`[window]` 打的是**请求**,字段名必须自证(`requested` / `scale_requested`)。
/// 抓不住:窗口真的开成了多大(由验收脚本从 `[shot] … scale=` 与 PNG 像素换算)。
#[test]
fn window_line_is_labelled_as_a_request() {
let facts = WindowFacts {
size: (1180.0, 760.0),
min: Some((880.0, 560.0)),
resizable: true,
decorations: false,
transparent: false,
};
let line = window_text(&facts, None, "default", Some("Unlucky Launcher"));
assert!(
line.starts_with("[window] requested size=1180x760"),
"{line}"
);
assert!(line.contains("min=880x560"), "{line}");
assert!(line.contains("resizable=true"), "{line}");
assert!(line.contains("decorations=false"), "{line}");
assert!(line.contains("transparent=false"), "{line}");
assert!(line.contains("scale_requested=system"), "{line}");
assert!(line.contains("size_source=default"), "{line}");
assert!(line.contains("title=\"Unlucky Launcher\""), "{line}");
// `pages_demo` 那条通道没有最小尺寸 / 自定标题:必须写 none / <默认>,不许编 0x0
let harness = window_text(
&WindowFacts { min: None, ..facts },
Some(1.0),
"pages_demo",
None,
);
assert!(harness.contains("min=none"), "{harness}");
assert!(harness.contains("title=<默认>"), "{harness}");
assert!(harness.contains("scale_requested=1"), "{harness}");
}
/// `ULL_WINDOW_SIZE` 的三种分隔符都要认;非法值必须返回 `None`(调用方打警告,不许静默吞)。
#[test]
fn window_size_override_accepts_the_three_separators() {
for raw in ["1520x900", "1520X900", "1520×900", " 1180 x 760 "] {
assert!(parse_window_size(raw).is_some(), "{raw:?}");
}
assert_eq!(parse_window_size("1520x900"), Some((1520.0, 900.0)));
for raw in [
"",
"1520",
"1520x",
"x900",
"0x900",
"-1x900",
"axb",
"1520x900x1",
] {
assert_eq!(parse_window_size(raw), None, "{raw:?}");
}
}
/// 抓得住:`size_source=` 必须能区分「默认」「覆盖生效」「覆盖被夹到最小」「覆盖是垃圾」——
/// 四种情况打出一样的两张图,是最典型的假绿(脚本以为在测宽屏,其实测的是默认)。
#[test]
fn window_size_source_distinguishes_the_four_cases() {
let default = (1180.0, 760.0);
let min = (880.0, 560.0);
assert_eq!(
resolve_window_size(None, default, min),
(default, "default")
);
assert_eq!(
resolve_window_size(Some("1520x900"), default, min),
((1520.0, 900.0), "ULL_WINDOW_SIZE")
);
// 最小尺寸档:正好等于 min,不该被标成 clamped
assert_eq!(
resolve_window_size(Some("880x560"), default, min),
((880.0, 560.0), "ULL_WINDOW_SIZE")
);
assert_eq!(
resolve_window_size(Some("640x480"), default, min),
(min, "ULL_WINDOW_SIZE:clamped-to-min")
);
assert_eq!(
resolve_window_size(Some("wide-ish"), default, min),
(default, "ULL_WINDOW_SIZE:invalid")
);
}
/// 抓得住:macOS 上「菜单栏显示什么名字」从可执行文件路径就能读出来——bundle 与非 bundle
/// 是两条不同的答案,且 `controls=self-drawn` 由 `decorations=false` 推出来。
#[test]
fn caps_line_reads_the_menu_bar_name_from_the_executable_path() {
let plain = caps_line(
Some(Path::new(
"/Users/x/ull/target/release/unlucky-launcher-iced",
)),
false,
true,
false,
);
assert!(plain.contains("bundle=no"), "{plain}");
assert!(
plain.contains("menu_bar_name=\"unlucky-launcher-iced\""),
"{plain}"
);
assert!(plain.contains("menu_bar_source=exe-stem"), "{plain}");
assert!(plain.contains("controls=self-drawn"), "{plain}");
let bundled = caps_line(
Some(Path::new(
"/Applications/Unlucky Launcher.app/Contents/MacOS/unlucky-launcher-iced",
)),
false,
true,
false,
);
assert!(bundled.contains("bundle=yes"), "{bundled}");
assert!(
bundled.contains("menu_bar_name=\"Unlucky Launcher\""),
"{bundled}"
);
assert!(bundled.contains("menu_bar_source=bundle"), "{bundled}");
let native = caps_line(Some(Path::new("/tmp/ull")), false, true, true);
assert!(native.contains("controls=native"), "{native}");
}
/// 抓得住:`bundle=` 与 `menu_bar_name=` 拿不到路径时不猜(`<未知>`)。
#[test]
fn caps_line_does_not_guess_without_a_path() {
let line = caps_line(None, false, true, false);
assert!(line.contains("bundle=no"), "{line}");
assert!(line.contains("menu_bar_name=\"<未知>\""), "{line}");
}
/// 抓得住:`[backend] plan` 打的是**判据产物**(primary / fallback / reason),不是请求。
/// 改回 `request=` 当 primary 用、或把兜底写成候选列表,这条就红。
#[test]
fn backend_plan_prints_the_decision_not_the_request() {
let probe = probe_of(&[("Apple M1", "IntegratedGpu")]);
let decision = decide(crate::Backend::Auto, &probe, true);
let line = backend_plan_text(&decision, crate::Backend::Auto);
assert!(line.contains("request=auto"), "{line}");
assert!(line.contains("primary=wgpu"), "{line}");
assert!(line.contains("fallback=tiny-skia"), "{line}");
assert!(line.contains("candidates=1"), "{line}");
assert!(line.contains("reason=auto-hardware-adapter"), "{line}");
}
/// 抓得住:`[backend] final` 的两条路都要能一眼读出「到底跑起来的是谁」。
#[test]
fn backend_final_lines_separate_started_from_failed() {
let started = backend_started_text(Kind::Wgpu, Duration::from_millis(37));
assert!(
started.contains("final=wgpu result=started elapsed=37ms"),
"{started}"
);
assert!(started.contains("evidence=probe-single-item"), "{started}");
// refuse:**什么都没跑起来** ⇒ final=none(不是兜底名,否则报告会把「这次用的是 tiny-skia」当真)
let refused = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
None,
"refuse",
"metal support not compiled in",
);
assert!(refused.contains("final=none result=failed"), "{refused}");
assert!(refused.contains("fallback=tiny-skia"), "{refused}");
assert!(refused.contains("primary=wgpu"), "{refused}");
assert!(refused.contains("via=refuse"), "{refused}");
assert!(
refused.contains("error=\"metal support not compiled in\""),
"具名错误必须原样出现在同一行里:{refused}"
);
let none = backend_failed_text(Kind::TinySkia, None, None, "none", "boom");
assert!(none.contains("final=none result=failed"), "{none}");
// respawn:进程即将带着兜底重开 ⇒ final=<兜底>
let respawned = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
Some(Kind::TinySkia),
"respawn",
"x",
);
assert!(respawned.contains("final=tiny-skia"), "{respawned}");
assert!(respawned.contains("via=respawn"), "{respawned}");
// 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截)
let quoted = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
None,
"refuse",
"GraphicsAdapterNotFound { backend: \"wgpu\" }",
);
assert!(
quoted.contains("error=\"GraphicsAdapterNotFound { backend: 'wgpu' }\""),
"{quoted}"
);
assert_eq!(
quoted.matches('"').count(),
2,
"整行只该有 error= 那一对引号:{quoted}"
);
}
/// 抓得住:`[perf]` 打的是实测毫秒数,不是「预计」;场景行的 fps 由帧数/时长算。
#[test]
fn perf_lines_carry_measured_millis() {
let boot = perf_boot_text(
Some(Duration::from_millis(650)),
Duration::from_millis(120),
Duration::from_millis(780),
);
assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms");
// 没探测 ⇒ skipped,不是 0ms(M98 r1 p3-1:拿 0 冒充实测)
let skipped = perf_boot_text(None, Duration::from_millis(120), Duration::from_millis(780));
assert!(skipped.contains("probe=skipped"), "{skipped}");
let session = perf_session_text("screenshot", Duration::from_millis(1234), true);
assert!(session.contains("mode=screenshot elapsed=1234ms result=ok"));
let scene = perf_scene_text(
"instances-minimal-w880",
3,
Some(Duration::from_millis(210)),
Duration::from_millis(300),
);
assert!(
scene.contains("frames=3 first_frame=210ms elapsed=300ms"),
"{scene}"
);
// 只算首帧之后的帧间隔:(3-1) 帧 / (0.30-0.21) s ≈ 22.2 fps(启动那段不算进帧率)
assert!(scene.contains("fps=22.2"), "{scene}");
// 少于 2 帧 / 没有首帧时间 ⇒ 没有间隔可测,必须是 unknown 而不是编一个数
let one = perf_scene_text(
"x",
1,
Some(Duration::from_millis(5)),
Duration::from_millis(9),
);
assert!(one.contains("fps=unknown"), "{one}");
let no_first = perf_scene_text("x", 5, None, Duration::from_millis(9));
assert!(no_first.contains("fps=unknown"), "{no_first}");
}
/// 抓得住:`[font] chain` 来自令牌的三条栈(顺序 = 首选 → 兜底),不是某个常量字符串。
#[test]
fn font_chain_lists_the_token_stacks_in_order() {
let line = font_chain_line(FontPlatform::MacOs);
assert!(line.starts_with("[font] chain platform=macos"), "{line}");
// macOS 的中文兜底族必须在链里(M98 要验的就是它有没有被用上)
assert!(line.contains("PingFang SC"), "{line}");
assert!(line.contains("mono="), "{line}");
// 顺序:首选族在前,通用族名在最后
let text = line.split("text=").nth(1).expect("text 栈");
let first = text.split('>').next().unwrap_or_default();
assert!(!first.contains("sans-serif"), "栈头不该是通用族名:{line}");
}
/// 抓得住:`[theme]` 的来源判定(哪个参数赢)与「跟随系统 = pending」这条不变量。
#[test]
fn theme_source_follows_the_capture_flavor_order() {
// --screenshot-theme 压过 --theme,也压过「这是截图模式」的默认值
assert_eq!(theme_source(true, true, true), ("screenshot-theme", false));
assert_eq!(theme_source(false, true, true), ("theme", false));
// 截图模式没给主题 ⇒ 默认 Light(不是「等系统」)
assert_eq!(theme_source(false, false, true), ("default-light", false));
// 交互模式没给主题 ⇒ 跟随系统(异步,真值晚到)
assert_eq!(theme_source(false, false, false), ("system", true));
let system = theme_text("system", None, true);
assert!(system.contains("flavor=pending"), "{system}");
}
/// 抓得住:`[theme]` 的 `follows_system=true` 必须同时是 `flavor=pending`
/// (系统主题异步到达;写死一个值就是猜)。
#[test]
fn theme_line_marks_the_async_system_case_as_pending() {
let system = theme_text("system", None, true);
assert!(system.contains("source=system"), "{system}");
assert!(system.contains("flavor=pending"), "{system}");
assert!(system.contains("follows_system=true"), "{system}");
let pinned = theme_text("screenshot-theme", Some("dark"), false);
assert!(pinned.contains("flavor=dark"), "{pinned}");
assert!(pinned.contains("follows_system=false"), "{pinned}");
}
/// 抓得住:开关默认关(产品启动不该多花 1 s 去枚举适配器),`0/off/false/no` 也算关。
#[test]
fn acceptance_switch_is_off_by_default() {
// 这个测试不读进程环境(会与其它测试抢 `set_var`),只钉常量名:
// 名字改了验收脚本就失效,所以它必须是测试锁住的东西。
assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE");
assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE");
}
/// 抓得住:`theme_source()`(日志用)与 `Options::capture_flavor()`(真取值口)**两处优先级漂了**——
/// 漂了就会打出错的 `source=`,而图上的主题才是真的(M98 r1 p3-5:两份实现,没有交叉测试)。
/// 抓不住:系统主题异步到达之后的值(那要 `[theme] 系统主题 = …` 那一行)。
#[test]
fn theme_source_agrees_with_the_only_flavor_gate() {
use crate::cli::Options;
let cases: [&[&str]; 5] = [
&[],
&["--screenshot", "out.png"],
&["--screenshot", "out.png", "--theme=dark"],
&[
"--screenshot",
"out.png",
"--theme=dark",
"--screenshot-theme=light",
],
&["--theme=dark"],
];
for args in cases {
let options = Options::parse(args.iter().map(|arg| (*arg).to_owned())).expect("参数合法");
let (source, follows) = theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let flavor = options.capture_flavor();
assert_eq!(
follows,
flavor.is_none(),
"{}:follows_system 与 capture_flavor().is_none() 必须一致",
args.join(" ")
);
// 非「跟随系统」时,来源标签必须能解释 capture_flavor 给出的那个值
match source {
"screenshot-theme" => assert_eq!(flavor, options.screenshot_theme),
"theme" => assert_eq!(flavor, options.theme),
"default-light" => assert_eq!(flavor, Some(unlucky_design::color::SchemeFlavor::Light)),
"system" => assert_eq!(flavor, None),
other => panic!("没料到的来源 {other}"),
}
}
}
/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ `refuse`,**不是** `respawn`)。
#[test]
fn failure_via_matches_the_two_paths_of_the_criteria_table() {
assert_eq!(
failure_via(true, false),
"respawn",
"交互启动 + 有兜底 ⇒ 换进程重开"
);
assert_eq!(
failure_via(true, true),
"refuse",
"取证启动 + 有兜底 ⇒ 拒绝出图"
);
assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败");
assert_eq!(
failure_via(false, true),
"none",
"没兜底时也不该冒出 refuse 的提示"
);
}
@@ -0,0 +1,103 @@
//! `[font]` 的回退链与 `[caps]` 的平台能力——两件「看一眼就知道对不对」的事。
//!
//! 这里的两个函数都是**纯格式化**(除了 `[caps]` 读一次 `current_exe()`,那是调用方传进来的):
use std::path::Path;
use unlucky_design::typography::{FontFamily, Platform as FontPlatform};
// ---------------------------------------------------------------- [font]
/// `[font] chain`:令牌里三条栈的**声明**顺序(首选 → 兜底 → 通用族名)。
///
/// 与 `fonts` 打的 `[font] platform=…` 互补:那行是「实际落到哪个族」,这行是「本来想落哪些」。
/// 缺字体(方块字)时要看的是**两者的差**:链里有 `"PingFang SC"` 但 `text=` 落到别的族,
/// 就是这台机器没有苹方。
#[must_use]
pub fn font_chain_line(platform: FontPlatform) -> String {
let stacks = [
("display", FontFamily::Display),
("text", FontFamily::Text),
("mono", FontFamily::Mono),
]
.map(|(name, family)| {
let chain = platform
.font_stack(family)
.iter()
.map(|face| format!("\"{}\"", face.family))
.collect::<Vec<_>>()
.join(">");
format!("{name}={chain}")
})
.join(" ");
format!(
"[font] chain platform={} {stacks}(令牌声明的回退链;实际落点见上面 [font] platform=…)",
platform.name()
)
}
/// 打一行 [`font_chain_line`](调用点只留一句)。
pub fn report_font_chain(platform: FontPlatform) {
eprintln!("{}", font_chain_line(platform));
}
// ---------------------------------------------------------------- [caps]
/// `[caps]`:平台能力里**本进程能自己回答**的那几条。
///
/// 半观测半请求,字段名照实说:
/// - `bundle` / `menu_bar_name`:观测(从可执行文件路径读);
/// - `transparent` / `decorations` / `resizable`:本进程给窗口的设置(**请求值**,即终端用户
/// 实际跑的那个形态;`decorations=false` ⇒ 三键是自绘的);
/// - `controls=self-drawn`:由 `decorations=false` 推出来的结论(自绘三键在**右上角**,
/// 与 macOS 惯例的左上相反——这条正是「三键位置怪不怪」的机器答案,位置本身由脚本在截图上
/// 做像素定位)。
#[must_use]
pub fn caps_line(
exe: Option<&Path>,
transparent: bool,
resizable: bool,
decorations: bool,
) -> String {
let (is_bundle, name) = bundle_of(exe);
format!(
"[caps] bundle={} menu_bar_name=\"{name}\" menu_bar_source={} controls={} \
transparent={transparent} resizable={resizable} decorations={decorations}",
if is_bundle { "yes" } else { "no" },
if is_bundle { "bundle" } else { "exe-stem" },
if decorations { "native" } else { "self-drawn" },
)
}
/// 可执行文件 → (是不是 `.app` 包, 菜单栏会显示的名字)。
///
/// macOS 上非 bundle 的二进制,菜单栏 App 名就是**可执行文件名**;打成 `.app` 时是
/// `Foo.app` 里的 `Foo`。这条从路径读得出来,所以「菜单栏名字对不对」**不需要问人**。
#[must_use]
pub fn bundle_of(exe: Option<&Path>) -> (bool, String) {
let Some(exe) = exe else {
return (false, "<未知>".to_owned());
};
// `Foo.app/Contents/MacOS/foo` 里的 `Foo`:路径里出现以 `.app` 结尾的一段就是 bundle。
for component in exe
.components()
.filter_map(|part| part.as_os_str().to_str())
{
if let Some(name) = component.strip_suffix(".app") {
return (true, name.to_owned());
}
}
let stem = exe
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("<未知>");
(false, stem.to_owned())
}
/// 打一行 [`caps_line`](调用点只留一句)。
pub fn report_caps(exe: Option<&Path>, transparent: bool, resizable: bool, decorations: bool) {
eprintln!("{}", caps_line(exe, transparent, resizable, decorations));
}
@@ -0,0 +1,142 @@
//! `[window]`:**本进程给窗口的那些设置**(不是我们从别处猜的)。
//!
//! 唯一产地是 `app/boot/diagnostics.rs::window_settings`(`pages_demo` 通道自己也有一份,
//! 它没有最小尺寸与自定标题)。这里只做「从 `window::Settings` 抽字段 → 排版」。
//!
//! **别再把这些字段写进格式化串**:M98 r1 p2-1 就是这么错的——日志说 `decorations=false`,
//! 而真实 `settings` 可以改成 `true` 而不惊动任何断言(踩坑记录 U1)。
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
///
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
// ---------------------------------------------------------------- [window]
/// 窗口事实:**从真实的 `window::Settings` 抽出来**的那几个字段(唯一产地见
/// `app/boot/diagnostics.rs` 的 `window_settings`)。
///
/// 为什么要有这个结构:M98 r1 p2-1 —— 第一版把 `resizable=true decorations=false transparent=false`
/// 写在格式化串里,与真实 `settings` 无绑定;定向变异把 `settings.decorations` 改成 `true`,
/// 371 个测试全绿,而日志会与 `[caps]` 互相矛盾(踩坑记录 U1)。现在这些字段只能从
/// [`WindowFacts::from_settings`] 来,没有手抄的入口。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WindowFacts {
/// 窗口尺寸(逻辑像素)。
pub size: (f32, f32),
/// 最小尺寸(`None` = 没设)。
pub min: Option<(f32, f32)>,
/// 可缩放。
pub resizable: bool,
/// 系统装饰(`false` ⇒ 自绘三键)。
pub decorations: bool,
/// 整窗透明。
pub transparent: bool,
}
impl WindowFacts {
/// 从**真正交给 iced/winit 的那个** `window::Settings` 抽字段。
#[must_use]
pub fn from_settings(settings: &iced::window::Settings) -> Self {
Self {
size: (settings.size.width, settings.size.height),
min: settings.min_size.map(|size| (size.width, size.height)),
resizable: settings.resizable,
decorations: settings.decorations,
transparent: settings.transparent,
}
}
}
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
///
/// `title` 是 `Option`:`pages_demo` 那条通道没有自定标题,打 `<默认>` —— 编一个空串会让
/// 「标题是什么」变成猜的。
pub fn report_window(
facts: &WindowFacts,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) {
eprintln!("{}", window_text(facts, scale, size_source, title));
}
/// 见 [`report_window`]。
#[must_use]
pub fn window_text(
facts: &WindowFacts,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) -> String {
let min = facts
.min
.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\""));
format!(
"[window] requested size={}x{} min={min} resizable={} decorations={} transparent={} scale_requested={} size_source={size_source} title={title}",
facts.size.0,
facts.size.1,
facts.resizable,
facts.decorations,
facts.transparent,
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
)
}
/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。
///
/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。
#[must_use]
pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> {
let trimmed = raw.trim();
let (width, height) = trimmed
.split_once(['x', 'X', '×', '*'])
.map(|(w, h)| (w.trim(), h.trim()))?;
let width: f32 = width.parse().ok()?;
let height: f32 = height.parse().ok()?;
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some((width, height))
}
/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。
///
/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。
/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住,
/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。
#[must_use]
pub fn resolve_window_size(
raw: Option<&str>,
default: (f32, f32),
min: (f32, f32),
) -> ((f32, f32), &'static str) {
let Some(raw) = raw else {
return (default, "default");
};
let Some((width, height)) = parse_window_size(raw) else {
return (default, "ULL_WINDOW_SIZE:invalid");
};
let clamped = (width.max(min.0), height.max(min.1));
if clamped == (width, height) {
(clamped, "ULL_WINDOW_SIZE")
} else {
(clamped, "ULL_WINDOW_SIZE:clamped-to-min")
}
}
/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。
#[must_use]
pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) {
let raw = std::env::var(WINDOW_SIZE_ENV).ok();
let resolved = resolve_window_size(raw.as_deref(), default, min);
if resolved.1.ends_with("invalid") {
eprintln!(
"[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑",
raw.unwrap_or_default()
);
}
resolved
}
+19 -1
View File
@@ -64,12 +64,29 @@
//! `iced::Error::GraphicsCreationFailed` 里那串具名错误(哪个 backend、reason 是什么),
//! 由 `app/boot.rs` 打印后决定「换进程降级」还是「拒绝出图」。
//!
//! ## 三段基建
//! ## 四段基建
//!
//! | 模块 | 作用 | 默认 |
//! |---|---|---|
//! | [`preflight`] | 在 iced 之前自己跑一遍 wgpu 的「实例→适配器→设备」,把失败原因拿在手里 | **关**(`ULL_PREFLIGHT=1` 打开;会重复创建实例/设备,别放默认路径) |
//! | [`log_bridge`] | 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(`Selected: AdapterInfo{…}` 这行就是 GPU 生效的实证) | 开(`ULL_GPU_LOG=off` 可关) |
//! | [`acceptance`] | **验收诊断(M98)**:`[env]` `[backend]` `[window]` `[font] chain` `[theme]` `[perf]` `[i18n]` `[caps]` 八类里本 crate 能观测的那部分,固定前缀、单行可 grep | 便宜的行恒开;贵的(适配器枚举)由 `ULL_ACCEPTANCE=1` 打开 |
//!
//! ### 「单项 ⇒ 没有静默回退」这条推理的依据(M98 r1 复审挖到源码层)
//!
//! `iced_renderer` 的 `fallback::Compositor` 拿到 `ICED_BACKEND` 后**仍会把每个候选依次拿去试
//! wgpu 和 tiny-skia**,两个子渲染器收同一个 backend 字符串,所以「单项」本身并不阻止它去试
//! tiny-skia。真正挡住它的是 `iced_tiny_skia` 的 `with_backend`:它只接受
//! `None | "tiny-skia" | "tiny_skia"`,`Some("wgpu")` 直接返回 `GraphicsAdapterNotFound`。
//! 于是 `ICED_BACKEND=wgpu` 这条路**不可能**落到 tiny-skia:起不来就两项都失败、返回
//! `GraphicsCreationFailed`(判据表第 8/9 行接住它)。
//!
//! 这条依赖 iced 的实现细节,所以:① `iced = "=0.14.0"` 精确钉版本;② 验收脚本另有
//! `[gpu] …Selected: AdapterInfo` 这条**不依赖本 crate 推理**的实证断言。升级 iced 时这两条都要重看。
//!
//! **本模块自己只回答「判据」**;「结果」由三处分别取证,别混:① `[backend] final=`(本 crate 的
//! 起步结果,判据是单项 `ICED_BACKEND`);② `[gpu] iced_wgpu: …Selected:`(iced 自己打的,
//! GPU 真生效的实证);③ `[shot] … renderer=`(这张图是哪个后端出的)。
//!
//! ## 真机验收(macOS Metal 路径 —— **需真机复核**,Windows 验不了这一段)
//!
@@ -121,6 +138,7 @@
//! → 具名错误 → 拒绝出图 → exit 1);macOS 上要确认打印的是 `dx12 support not compiled in` 那类
//! wgpu 原话,而不是我们自己的话。
pub mod acceptance;
pub mod decision;
pub mod log_bridge;
pub mod preflight;