Unlucky Launcher v0.2.3
CI / runtime smoke (macos arm64, probe) (push) Skipped
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
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

This commit is contained in:
AskaEth committed 2026-11-08 00:15:58 +00:00
commit d616d65ea1
1219 files changed
+237663

No files matched your search

@@ -0,0 +1,294 @@
//! `[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 unlucky_presenter::settings_bridge::SettingsBridge;
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 {
/// `ULL_LANG` 的显式覆盖(截图矩阵的注入值);`None` = 没设。
///
/// 它与 [`Chain::setting`] 是**两个不同的源**:一个是我们为了出图钉的值,一个是用户配置里存的。
/// 判定顺序里前者压后者(见 [`Chain::read`]),日志里分开打,读的人不用猜。
pub forced: Option<String>,
/// `ui.language` 的显式值(`None` = 文件里**没写过** = 跟随系统)。
///
/// 与 `App` 启动判定同一个读法(`SettingsBridge::load_ui_language`:只认显式值,不走 schema
/// 默认值),所以「没写过」在这里与 `ui::lang` 里是同一个意思。
pub setting: Option<String>,
/// (源自称的名字, 原始 locale)。
pub platform: (&'static str, Option<String>),
/// (源自称的名字, 原始 locale)。
pub env: (&'static str, Option<String>),
/// 只按**平台源 + 环境变量**算出来的「这台机器说什么语言」(**不看** `ui.language`)。
///
/// `[env] language=` 用的是它:那一行回答的是「本机是什么」,用户手选英文不该改写机器的事实。
pub machine: Detection,
/// 最终结论:**与 `App` 启动日志的 `[i18n] choice=…` 用同一个判据函数**
/// (`ui::lang::choose_choice` + `LangChoice::resolve`),所以两行不可能说的是两种语言。
pub detection: Detection,
/// 这一次是不是**截图通道**(D30):`true` 时配置那一级按设计被忽略,日志里打出来,
/// 否则「配置写了英文、结论是中文」看起来像 bug。
pub screenshot: bool,
}
impl Chain {
/// 用全部四个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
///
/// 优先级**不在这里重写**:档位由 [`crate::ui::lang::choose_choice`] 出(与 `App` 启动判定
/// 同一个函数),结论由 `LangChoice::resolve` 出(与 `App` 同一个 `resolve`)。
/// 顺序即 **`ULL_LANG` > 配置(`screenshot == false` 才算) > 平台源 > 环境变量 > 英文**。
#[must_use]
pub fn read(
forced: Option<&str>,
setting: Option<&str>,
screenshot: bool,
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(),
};
// 「机器说什么语言」:与 `ui.language` 无关,`[env] language=` 用它。
let machine = lang::resolve(None, &platform, &env);
// 「这一次界面用什么语言」:**调产品路径那个函数**,不在这里复制一遍优先级表。
let detection =
crate::ui::lang::choose_choice(forced, setting, screenshot).resolve(&platform, &env);
Self {
forced: forced.map(str::to_owned),
setting: setting.map(str::to_owned),
platform: (platform.label, platform.raw),
env: (env.label, env.raw),
machine,
detection,
screenshot,
}
}
/// 本机链路(`ULL_LANG` + 配置 + 平台源 + 环境变量)。
///
/// `screenshot` 由调用方从 `Options` 递进来(`report_boot` 手上有):**截图通道按 D30 忽略配置**,
/// 这一级判断必须与产品路径同源,否则又会出现「一行说 zh-CN、下一行说 effective=en」。
///
/// 诚实边界:这里 `SettingsBridge::open()` 走**默认数据根**;同一次运行若带了 `--data-dir`,
/// 配置可能来自另一份文件(此时以 `App` 那行 `[i18n] choice=…` 为准,它用的是 App 自己的桥)。
#[must_use]
pub fn host(screenshot: bool) -> Self {
let forced = std::env::var(crate::ui::lang::LANG_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
let setting = SettingsBridge::open().load_ui_language();
Self::read(
forced.as_deref(),
setting.as_deref(),
screenshot,
platform_locale_source(),
&lang::EnvLocale,
)
}
/// **机器的**语言标签(`zh-CN` / `en`)——不含 `ui.language` 的手选。
#[must_use]
pub fn language(&self) -> &'static str {
self.machine.lang.tag()
}
/// `[i18n]` 一行:四个源 + 结论。
///
/// 顺序即 `ULL_LANG` > 配置(`shot=false` 才参与) > 平台源 > 环境变量 > 英文;`->` 后面那一段
/// 就是**最终生效语言 + 它来自哪一级**,与 `App` 的 `[i18n] choice=…` **用同一个判据函数**算出来。
///
/// 为什么把 `forced=` 与 `shot=` 也打出来:`shot=true` 时配置按 D30 被忽略,不写这一格的话
/// 「配置里明明写了 en_us,结论却是 zh-CN」看起来像 bug(r4 复审 p2-1 的两组复现就是这两种)。
///
/// 曾经这里钉着 `ui_text=pinned-en`(那会儿 `pages/mod.rs::render` 真的把下载页写死成英文)。
#[must_use]
pub fn line(&self) -> String {
format!(
"[i18n] forced={} setting={} platform={}:{} env={}:{} shot={} -> {}",
self.forced.as_deref().unwrap_or("<未设置>"),
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.screenshot,
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,247 @@
//! `[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,
None,
false,
&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("forced=<未设置>"), "{line}");
assert!(line.contains("setting=<未设置=跟随系统>"), "{line}");
assert!(line.contains("shot=false"), "{line}");
assert!(
line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"),
"{line}"
);
// **回归绊线(M106 r1 p2-1)**:这行曾经钉着 `ui_text=pinned-en`,在文案真的接了
// `model.lang` 之后变成一句假话(「界面钉在英文」)。它不许回来。
assert!(
!line.contains("pinned-en"),
"`ui_text=pinned-en` 是 M106 之前的旧事实,现在会与 App 的 [i18n] 行自相矛盾:{line}"
);
}
/// 配置的手选必须**同时**体现在结论与 `[env]` 的分工上(M106 r1 p2-1 的修法证据)。
///
/// 抓得住:①`ui.language` 这一级被丢掉(那行 `->` 的结论会退回系统语言,与 `App` 的
/// `[i18n] choice=…` 矛盾);②把「机器语言」也改成手选值(`[env] language=` 答的是「本机是什么」,
/// 用户手选英文不该改写机器的事实)。
#[test]
fn i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language() {
struct Fixed(Option<&'static str>);
impl LocaleSource for Fixed {
fn locale(&self) -> Option<String> {
self.0.map(str::to_owned)
}
fn label(&self) -> &'static str {
"registry"
}
}
let chain = Chain::read(
None,
Some("en_us"),
false,
&Fixed(Some("zh-CN")),
&Fixed(None),
);
assert_eq!(chain.language(), "zh-CN", "[env] 报的是机器语言");
assert_eq!(chain.detection.lang, unlucky_i18n::Lang::En, "手选压过系统");
let line = chain.line();
assert!(line.contains("setting=en_us"), "{line}");
assert!(line.contains("source=setting"), "{line}");
assert!(line.contains("lang=en"), "{line}");
}
/// **两个 `[i18n]` 行不许打架**(r4 复审 p2-1 的回归绊线)。
///
/// 复审实测的两组复现,现在必须是同一个结论:
/// - ① 只设 `ULL_LANG=en_us`、配置干净 ⇒ 结论 = en(`forced` 压过系统中文);
/// - ② 配置 `ui.language=en_us` + **截图通道** ⇒ 结论 = zh-CN(D30:截图不读用户配置);
/// - ③ 配置 `ui.language=en_us` + 交互通道 ⇒ 结论 = en(配置这一级只在非截图时参与)。
///
/// 抓得住:①`ULL_LANG` 这一级被丢掉;②截图通道又去读配置(「一行说 zh-CN、下一行说 effective=en」
/// 就是这么来的);③acceptance 侧自己复制了一份优先级表 —— 每条都用
/// `crate::ui::lang::choose_choice`(产品路径那张表)算出**期望结论**再与 `Chain` 比,谁复制谁红。
/// 抓不住:真机上 `reg.exe` / `defaults` 读到什么(那是真机验收),以及 `App` 那行日志本身
/// (它由 `ui/lang.rs` 的单测守)。
#[test]
fn i18n_line_and_the_app_agree_on_every_channel_and_override() {
use unlucky_i18n::Lang;
struct Fixed(Option<&'static str>);
impl LocaleSource for Fixed {
fn locale(&self) -> Option<String> {
self.0.map(str::to_owned)
}
fn label(&self) -> &'static str {
"registry"
}
}
let registry_zh = Fixed(Some("zh-CN"));
let no_env = Fixed(None);
/// 一行真值表:`(ULL_LANG, 配置, 截图通道) -> (期望语言, 期望来源)`。
type Case<'a> = (Option<&'a str>, Option<&'a str>, bool, Lang, &'a str);
let table: &[Case<'_>] = &[
(Some("en_us"), None, false, Lang::En, "setting"),
// 复审组合 ①:`ULL_LANG` 在截图通道里同样压过系统中文
(Some("en_us"), None, true, Lang::En, "setting"),
// 复审组合 ②:截图通道**忽略配置**,于是退回系统语言
(None, Some("en_us"), true, Lang::ZhCn, "registry"),
// 组合 ③:非截图通道,配置这一级生效
(None, Some("en_us"), false, Lang::En, "setting"),
(None, None, false, Lang::ZhCn, "registry"),
];
for (forced, setting, screenshot, lang, origin) in table {
let chain = Chain::read(*forced, *setting, *screenshot, &registry_zh, &no_env);
let line = chain.line();
assert_eq!(
(chain.detection.lang, chain.detection.origin),
(*lang, *origin),
"forced={forced:?} setting={setting:?} shot={screenshot} 结论不对:{line}"
);
// `[env] language=` 只报机器语言,任何手选/覆盖都不该改写它。
assert_eq!(chain.language(), "zh-CN", "{line}");
// **同一张表**的结构性钉子:期望结论取自产品路径那个函数,谁在 acceptance 侧另写一份就红。
let expected = crate::ui::lang::choose_choice(*forced, *setting, *screenshot)
.resolve(&registry_zh, &no_env);
assert_eq!(chain.detection, expected, "两侧的判据表漂了:{line}");
assert!(
line.contains(&format!("shot={screenshot}")),
"截图通道必须在行里写出来:{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, None, false, &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, None, false, &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,323 @@
//! 验收诊断(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>, screenshot: bool) {
// `screenshot` 一路递到 `Chain`:截图通道按 D30 忽略配置,判定必须与产品路径同源
// (两行日志用的都是 `ui::lang::choose_choice` 那一张表,见 `locale::Chain::read`)。
let chain = Chain::host(screenshot);
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
}
+331
View File
@@ -0,0 +1,331 @@
//! 判据的**纯函数**部分:`请求 × 探测 × 是否编入 wgpu` → 起步后端 + 兜底后端。
//!
//! 表本身(什么条件选什么、日志该打什么、谁能验证)在 [`super`] 的模块文档顶部。
//! 这里只放可测的东西:判据一旦掺进运行期副作用(写环境变量、看日志、开窗),
//! 「降级矩阵」就再也写不成表驱动测试了——M97 的教训。
use crate::{Backend, Probe};
/// 一个具体渲染后端(`ICED_BACKEND` 的单项取值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// `ICED_BACKEND=wgpu`。
Wgpu,
/// `ICED_BACKEND=tiny-skia`。
TinySkia,
}
impl Kind {
/// 写进 `ICED_BACKEND` 的取值(也是 `iced_renderer` 认的名字)。
#[must_use]
pub fn env_value(self) -> &'static str {
match self {
Kind::Wgpu => "wgpu",
Kind::TinySkia => "tiny-skia",
}
}
/// 日志里的名字。
#[must_use]
pub fn name(self) -> &'static str {
self.env_value()
}
/// 对应的「档位」(D36 的 CPU 档 / GPU 档判据用)。
#[must_use]
pub fn tier(self) -> Backend {
match self {
Kind::Wgpu => Backend::Gpu,
Kind::TinySkia => Backend::Cpu,
}
}
}
/// 判据产物:起步后端 + **本 crate 自己**的兜底(不是 iced 的候选回退)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Decision {
/// 写进 `ICED_BACKEND` 的唯一一项。
pub primary: Kind,
/// 起步失败后重开的后端;`None` = 没有兜底(强制 CPU / 没编 wgpu)。
pub fallback: Option<Kind>,
/// 为什么这么判(进日志,验收就靠它)。
pub reason: &'static str,
}
impl Decision {
/// 一行给人看的决策描述。
#[must_use]
pub fn describe(&self) -> String {
match self.fallback {
Some(fallback) => format!(
"renderer = {} reason={} 兜底={}(失败会先打印具名错误)",
self.primary.name(),
self.reason,
fallback.name()
),
None => format!(
"renderer = {} reason={} 兜底=none(候选表唯一项)",
self.primary.name(),
self.reason
),
}
}
}
/// 判据纯函数:请求 × 探测 × 是否编入 wgpu → 决策。
///
/// **必须是纯函数**:M97 的教训是判据一旦掺了运行期副作用(写环境变量、看日志),
/// 就再也写不出「降级矩阵」的表驱动测试了。
#[must_use]
pub fn decide(request: Backend, probe: &Probe, gpu_compiled: bool) -> Decision {
match (request, gpu_compiled) {
// 1. 用户点名 CPU:只有 CPU,且**不给兜底**(强制就是强制)。
(Backend::Cpu, _) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "forced-cpu",
},
// 2. 点名 GPU 但没编 wgpu:只能 CPU,没得兜。
(Backend::Gpu, false) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "gpu-not-compiled",
},
// 3. 点名 GPU 且编了:GPU 起步,CPU 兜底(绝不硬失败)。
(Backend::Gpu, true) => Decision {
primary: Kind::Wgpu,
fallback: Some(Kind::TinySkia),
reason: "forced-gpu",
},
// 4. auto + 没编 wgpu。
(Backend::Auto, false) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "auto-gpu-not-compiled",
},
// 5. auto + 只有 WARP/llvmpipe 这类软光栅:CPU 起步,wgpu 留作最后兜底。
(Backend::Auto, true) if !probe.hardware => Decision {
primary: Kind::TinySkia,
fallback: Some(Kind::Wgpu),
reason: "auto-no-hardware-adapter",
},
// 6. auto + 有硬件适配器:GPU 起步,CPU 兜底。
(Backend::Auto, true) => Decision {
primary: Kind::Wgpu,
fallback: Some(Kind::TinySkia),
reason: "auto-hardware-adapter",
},
}
}
/// 预检失败时把 wgpu 降下来(判据表第 7 行)。
///
/// 只在 `ULL_PREFLIGHT=1` 且预检真的失败时调用:这时候**别去开那个窗**——重试一次要闪两下窗,
/// 而且原因已经拿在手里了。
#[must_use]
pub fn demote_after_preflight(decision: Decision) -> Decision {
if decision.primary != Kind::Wgpu {
return decision;
}
Decision {
primary: Kind::TinySkia,
fallback: Some(Kind::Wgpu),
reason: "preflight-failed",
}
}
/// 降级重开那一次的决策(判据表第 8 行的**子进程侧**,`boot_probe` 的第一分支用它)。
///
/// **`fallback` 必须是 `None`**:这一步的兜底就是「不再兜底」。若它非空,子进程再失败时
/// 还会 respawn ⇒ **无限递归重开**(每次重开都像「刚刚启动」,这种自杀式循环极难诊断)。
/// 所以这条不变量只在这里定义一次,并由 `decision::tests` 钉住,别在调用点另拼一个 `Decision`。
#[must_use]
pub fn fallback_round(kind: Kind) -> Decision {
Decision {
primary: kind,
fallback: None,
reason: "fallback-after-failure",
}
}
/// 取证通道(`--screen=gallery`)用:**剥掉兜底**。
///
/// 硬规则(M97 定):**取证工具宁可红着退出,也不能悄悄换一种后端继续出图**。
/// 以前 gallery 在 wgpu 挂掉时会静默出 CPU 截图,而报告上写的是「GPU 渲染截图」——
/// 所有视觉结论的可信度都被这一条悄悄打折,而且从输出上看不出来。
#[must_use]
pub fn strict(decision: Decision) -> Decision {
Decision {
fallback: None,
reason: "gallery-strict",
..decision
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(types: &[&'static str]) -> Probe {
Probe {
adapters: types
.iter()
.map(|device_type| crate::probe::AdapterSummary {
name: "adapter".to_owned(),
device_type,
})
.collect(),
hardware: types
.iter()
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
}
}
/// 判据表的一行:`请求, 探测结论, 是否编入 wgpu, 期望起步, 期望兜底, 期望 reason`。
type Case = (Backend, Probe, bool, Kind, Option<Kind>, &'static str);
/// 判据表第 1~6 行逐行钉死(表驱动)。谁动了 `decide` 的矩阵,这条先红。
#[test]
fn criterion_table_rows_1_to_6() {
let hardware = || probe(&["IntegratedGpu"]);
let software = || probe(&["Cpu"]);
let cases: &[Case] = &[
// 1 强制 CPU:不给兜底
(
Backend::Cpu,
hardware(),
true,
Kind::TinySkia,
None,
"forced-cpu",
),
(
Backend::Cpu,
software(),
false,
Kind::TinySkia,
None,
"forced-cpu",
),
// 2 点名 GPU 但没编
(
Backend::Gpu,
hardware(),
false,
Kind::TinySkia,
None,
"gpu-not-compiled",
),
// 3 点名 GPU 且编了
(
Backend::Gpu,
hardware(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"forced-gpu",
),
(
Backend::Gpu,
software(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"forced-gpu",
),
// 4 auto + 没编
(
Backend::Auto,
hardware(),
false,
Kind::TinySkia,
None,
"auto-gpu-not-compiled",
),
// 5 auto + 只有软光栅
(
Backend::Auto,
software(),
true,
Kind::TinySkia,
Some(Kind::Wgpu),
"auto-no-hardware-adapter",
),
// 6 auto + 有硬件(macOS M1 走这条)
(
Backend::Auto,
hardware(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"auto-hardware-adapter",
),
];
for (request, probe, gpu_compiled, primary, fallback, reason) in cases {
let got = decide(*request, probe, *gpu_compiled);
assert_eq!(got.primary, *primary, "request={request:?} reason={reason}");
assert_eq!(
got.fallback, *fallback,
"request={request:?} reason={reason}"
);
assert_eq!(got.reason, *reason);
}
}
/// **安全网守门**:除了「强制 CPU」和「没编 wgpu」,任何一条判据都必须留兜底。
///
/// 抓不住:兜底那次重开本身失败(那是 iced 的事,见判据表第 8 行)。
#[test]
fn every_decision_keeps_an_exit() {
for request in [Backend::Auto, Backend::Gpu, Backend::Cpu] {
for gpu_compiled in [true, false] {
for types in [&[][..], &["Cpu"][..], &["IntegratedGpu"][..]] {
let decision = decide(request, &probe(types), gpu_compiled);
let forced = request == Backend::Cpu || !gpu_compiled;
assert_eq!(
decision.fallback.is_none(),
forced,
"{decision:?} 必须留兜底(request={request:?} gpu_compiled={gpu_compiled})"
);
// 兜底不能和起步项相同,否则重试等于原地打转。
assert_ne!(decision.fallback, Some(decision.primary), "{decision:?}");
}
}
}
}
/// 预检失败只降 wgpu 起步的那些行;`--renderer=cpu` 一行都不许动。
#[test]
fn preflight_demotes_only_wgpu_decisions() {
let hardware = probe(&["IntegratedGpu"]);
let auto = decide(Backend::Auto, &hardware, true);
let demoted = demote_after_preflight(auto);
assert_eq!(demoted.primary, Kind::TinySkia);
assert_eq!(demoted.reason, "preflight-failed");
assert_eq!(demoted.fallback, Some(Kind::Wgpu), "wgpu 还能当最后兜底");
let cpu = decide(Backend::Cpu, &hardware, true);
assert_eq!(demote_after_preflight(cpu), cpu, "强制 CPU 不许被预检改写");
}
/// 取证通道的决策必须**剥掉兜底**(判据表第 9 行):`strict` 若忘了清 `fallback`,
/// gallery 就能在 wgpu 挂掉时悄悄出 CPU 图——那是 M97 要杀的第九种「假绿」。
#[test]
fn strict_strips_the_fallback() {
let gpu = decide(Backend::Gpu, &Probe::default(), true);
assert_eq!(gpu.fallback, Some(Kind::TinySkia), "前提:普通决策有兜底");
let strict = strict(gpu);
assert_eq!(strict.fallback, None, "取证通道不许有兜底");
assert_eq!(strict.primary, Kind::Wgpu, "剥的是兜底,不是起步项");
assert_eq!(strict.reason, "gallery-strict");
}
#[test]
fn describe_names_the_backend_and_the_reason() {
let hardware = probe(&["DiscreteGpu"]);
let text = decide(Backend::Auto, &hardware, true).describe();
assert!(text.contains("renderer = wgpu"), "{text}");
assert!(text.contains("reason=auto-hardware-adapter"), "{text}");
assert!(text.contains("兜底=tiny-skia"), "{text}");
}
}
+151
View File
@@ -0,0 +1,151 @@
//! 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(判据表第 8 行的取证通道)。
//!
//! ## 为什么需要它
//!
//! `iced_wgpu` 在**真的**创建设备时会自己打一行:
//!
//! ```text
//! Selected: AdapterInfo { name: "Apple M1", backend: Metal, device_type: IntegratedGpu, … }
//! ```
//!
//! 这行只有「wgpu 走到适配器那一步」才会出现,所以它是**GPU 真生效的实证**——不是我们的推断。
//! 但它走 `log` crate,而本进程从来没装过 logger,于是 M1 那份报告里这行根本不存在:
//! 「看不到」被当成了「没有」。装一个只放行 GPU 相关 target 的 logger,成本 60 行,
//! 换来的是以后每次验收都能直接看见 wgpu 说了什么。
//!
//! ## 过滤
//!
//! 按 **target 分级**,不是一刀切:`wgpu_hal` / `wgpu_core` 在 Info 级别会刷出上百行 Vulkan
//! loader 噪声(M97 实测:启动时 90 行 registry 扫描),把真正有用的
//! `iced_wgpu: Selected: AdapterInfo{…}` 埋掉。
//!
//! | `ULL_GPU_LOG` | `iced_wgpu*` | `wgpu*`(hal / core) | 用途 |
//! |---|---|---|---|
//! | 未设 / `info` | Info | **Warn** | 默认:看得见选中的适配器/格式 + wgpu 的告警 |
//! | `warn` | Warn | Warn | 只留告警(启动日志最干净) |
//! | `debug` | Debug | Debug | 全量(诊断用;含 Vulkan loader 噪声) |
//! | `off` | — | — | 不装 logger(回到 M97 之前,仅用于对照实验) |
//!
//! 其它 target(`winit`、`iced_winit` …)一律不放行:它们只会把启动日志淹掉。
use std::sync::Once;
/// 装 logger(进程内只生效一次;重复调用无害)。
pub fn install() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let Some(level) = level_from_env() else {
return;
};
// `set_boxed_logger` 只能成功一次;已被别人装过就安静让位,不 panic。
if log::set_boxed_logger(Box::new(Bridge { level })).is_ok() {
log::set_max_level(level);
}
});
}
/// `ULL_GPU_LOG` → 过滤级别;`None` = 别装(`off`)。
fn level_from_env() -> Option<log::LevelFilter> {
parse_level(&std::env::var("ULL_GPU_LOG").unwrap_or_default())
}
/// 取值解释(纯函数,测试直接喂字符串,不碰进程环境)。
fn parse_level(value: &str) -> Option<log::LevelFilter> {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "0" | "no" | "false" => None,
"warn" | "warning" => Some(log::LevelFilter::Warn),
"debug" => Some(log::LevelFilter::Debug),
"trace" => Some(log::LevelFilter::Trace),
_ => Some(log::LevelFilter::Info),
}
}
/// 某个 target 在这个总级别下实际放行到哪一级。
///
/// `wgpu_hal`/`wgpu_core` 在 `info` 档被压到 `warn`:它们的 info 是 Vulkan loader 的原文,
/// 对「到底用了哪个后端」没有信息量,只有噪声。
fn target_level(level: log::LevelFilter, target: &str) -> log::LevelFilter {
if level == log::LevelFilter::Info && !target.starts_with("iced_wgpu") {
return log::LevelFilter::Warn;
}
level
}
/// 只放行渲染后端相关 target 的 logger。
struct Bridge {
level: log::LevelFilter,
}
impl log::Log for Bridge {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
let target = metadata.target();
is_gpu_target(target) && metadata.level() <= target_level(self.level, target)
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// iced_wgpu 用 `{:#?}` 打多行结构体;压成一行才好 grep / 贴验收报告。
let message = record.args().to_string().replace('\n', " ");
eprintln!("[gpu] {}: {}", record.target(), message.trim_end());
}
fn flush(&self) {}
}
/// `iced_wgpu::window::compositor` / `wgpu_core::…` / `wgpu_hal::…` 都算。
fn is_gpu_target(target: &str) -> bool {
["iced_wgpu", "wgpu"]
.iter()
.any(|prefix| target.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_gpu_targets_pass() {
assert!(is_gpu_target("iced_wgpu::window::compositor"));
assert!(is_gpu_target("wgpu_core::instance"));
assert!(is_gpu_target("wgpu_hal::metal"));
assert!(!is_gpu_target("iced_winit::lib"));
assert!(!is_gpu_target("winit::event_loop"));
}
#[test]
fn level_env_values_are_interpreted() {
assert_eq!(parse_level(""), Some(log::LevelFilter::Info));
assert_eq!(parse_level("info"), Some(log::LevelFilter::Info));
assert_eq!(parse_level(" WARN "), Some(log::LevelFilter::Warn));
assert_eq!(parse_level("off"), None);
assert_eq!(parse_level("0"), None);
}
/// 默认档(`info`)下 `wgpu_hal`/`wgpu_core` 被压到 Warn:它们是 Vulkan loader 原文,
/// M97 实测一屏 90 行噪声,会把 `Selected: AdapterInfo` 埋掉。
#[test]
fn default_level_mutes_wgpu_noise_but_keeps_adapter_line() {
use log::LevelFilter;
assert_eq!(
target_level(LevelFilter::Info, "iced_wgpu::window::compositor"),
LevelFilter::Info,
"选中适配器那行必须留"
);
assert_eq!(
target_level(LevelFilter::Info, "wgpu_hal::vulkan::instance"),
LevelFilter::Warn,
"Vulkan loader 噪声必须压掉"
);
assert_eq!(
target_level(LevelFilter::Info, "wgpu_core::instance"),
LevelFilter::Warn
);
// 显式要 debug 时全放行(诊断用)。
assert_eq!(
target_level(LevelFilter::Debug, "wgpu_hal::vulkan::instance"),
LevelFilter::Debug
);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 渲染后端的**判据表**(M97)+ 三段可观测性基建。
//!
//! 本模块回答一个问题:**这个进程到底跑在哪个渲染器上,凭什么这么断定。**
//!
//! ## 判据表(验收直接对照这张表)
//!
//! `ICED_BACKEND` **永远是单项**(不是候选列表)。理由见下节「为什么不再用候选列表」:
//! 单项 ⇒ 「跑起来了 ⇒ 就是它」,不存在需要事后猜的空间。`兜底` 列是**本 crate 自己的重试**,
//! 不是 iced 的回退,所以它一定伴随一行具名错误日志。
//!
//! | # | 条件 | 选哪个 | 日志该打印什么 | 谁能验证 |
//! |---|---|---|---|---|
//! | 1 | `--renderer=cpu` / `--software-renderer` | `tiny-skia` | `renderer = tiny-skia reason=forced-cpu` | 三平台(CI 只用这条) |
//! | 2 | `--renderer=gpu` 但本构建没编 wgpu | `tiny-skia` | `reason=gpu-not-compiled` + `;本构建未编入 wgpu` | 三平台(`--no-default-features`) |
//! | 3 | `--renderer=gpu` 且编了 wgpu | `wgpu`,兜底 `tiny-skia` | `renderer = wgpu reason=forced-gpu 兜底=tiny-skia` | Windows / Linux 本机;macOS 需真机 |
//! | 4 | `--renderer=auto` 但没编 wgpu | `tiny-skia` | `reason=auto-gpu-not-compiled` | 三平台(`--no-default-features`) |
//! | 5 | `auto` + 枚举不到硬件适配器(只有 WARP / llvmpipe) | `tiny-skia`,兜底 `wgpu` | `reason=auto-no-hardware-adapter` | Windows 本机(软渲染 VM) |
//! | 6 | `auto` + 有硬件适配器 | `wgpu`,兜底 `tiny-skia` | `reason=auto-hardware-adapter` | Windows 本机 **√**;macOS 需真机 |
//! | 7 | 同上但 `ULL_PREFLIGHT=1` 预检失败 | `tiny-skia`,兜底 `wgpu` | `[gpu-preflight] FAILED stage=… error=…` + `reason=preflight-failed` | 三平台(可用 `WGPU_BACKEND=gl` 造) |
//! | 8 | 任何一行选了 wgpu,但 iced 真初始化失败,且**交互启动**(无 `--screenshot`) | 具名错误 → **换一个进程**重开 `tiny-skia` | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 降级 → tiny-skia(…换一个进程重开…)` → 子进程 `[probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia)` | **Windows 已验**(`WGPU_BACKEND=metal` 造);macOS 需真机 |
//! | 9 | 同上行,但**取证启动**(带 `--screenshot`,含 `--screen=gallery`) | 具名错误 → **不出图**,退出码 1 | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 拒绝降级出图:本次是取证启动(--screenshot …)` | **Windows 已验**(同造法);macOS 需真机 |
//!
//! **第 8/9 行的硬规则(M97 定)**:
//!
//! - 取证工具(`--screenshot`)宁可红着退出,也不能悄悄换一种后端继续出图。以前 gallery 在
//! wgpu 挂掉时会静默降级到 tiny-skia 照样落 PNG,而报告上写的是「GPU 渲染截图」——所有视觉
//! 结论的可信度都被悄悄打折,而且从输出上看不出来。现在每张图的日志里都带
//! **实际**后端名(`[shot] … renderer=wgpu`),图能自证来源。
//! - 交互启动保留安全网:真无 GPU / wgpu 起不来时降级到 tiny-skia,不崩。
//!
//! **降级为什么是换进程**:第一版写的是「打印错误 → 在本进程里再 `run()` 一次」,Windows 实测
//! 直接 panic(`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)——
//! winit 不允许一个进程建两次事件循环。所以安全网是 `respawn`:起一个子进程、只换
//! `ICED_BACKEND` + `ULL_GPU_FALLBACK`,然后带着子进程的退出码退出。子进程看到
//! `ULL_GPU_FALLBACK` 就跳过探测(否则会得到同一个结论、无限重开)。
//!
//! `ULL_PREFLIGHT` 开关的代价(**实测**,不是估计):Windows RX 6600 XT / Vulkan 上
//! `took=717ms`(首次要扫一遍 Vulkan loader),不可用时 `took=0ms`。打开它等于启动多 ~0.7 s,
//! 所以默认关。它测不了 `create_surface`(那时还没有窗口),代替不了真跑一次,只用来「开窗前拿原因」。
//! 注意:`ULL_PREFLIGHT=1` + 预检失败 + `--screenshot` 时**仍会**降级出图(`reason=preflight-failed`,
//! 图上是 `renderer=tiny-skia`)——那条路径不是静默的,但图确实是 CPU 的,读图前先读日志。
//!
//! ## 怎么从日志判断「有没有静默降级」
//!
//! | 你看到 | 结论 |
//! |---|---|
//! | `[probe] renderer = wgpu` 且之后有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` | **真在走 GPU**(这行是 iced_wgpu 自己在创建设备时打的,不是我们的猜测) |
//! | `[gpu] wgpu 初始化失败(具名):…` | wgpu 起不来,**原因就在这行里**;交互启动已换进程降级 CPU,取证启动已拒绝出图 |
//! | `[gpu] 拒绝降级出图:…` | 这张图**没有**产出(取证启动 + wgpu 起不来)。想用 CPU 出图请显式 `--renderer=cpu` |
//! | `[gpu] 降级 → tiny-skia(…换一个进程重开…)` | 交互启动降级了;后面必然跟一行 `[probe] 本次是降级重开` |
//! | `[probe] renderer = tiny-skia reason=…`(`reason` 不是 `forced-cpu`) | 探测判定不该上 GPU,**原因在 `reason`** |
//! | `[shot] … renderer=wgpu` / `renderer=tiny-skia` | 这张图**实际**由哪个后端产出(M97 之前这里打的是请求值 `auto`,等于没打) |
//! | 只有 `[probe] renderer = …` 一行、没有 `[gpu] iced_wgpu:` 也没有取证行 | 那是 M97 之前的旧日志——**它从来没观测过实际后端**,不可采信 |
//!
//! ## 为什么不再用候选列表(M97 的根因订正)
//!
//! M97 之前:`auto` + 有硬件 → **不设** `ICED_BACKEND`,把决策交给 iced 的
//! `fallback::Compositor`;那条路径里 wgpu 的失败被 `errors` 向量装起来、tiny-skia 一成功就整体丢弃
//! (`iced_renderer-0.14.0/src/fallback.rs`,我们改不了)。日志只剩 `renderer = auto (wgpu -> tiny-skia)`
//! ——而这行是 `probe.rs` 里按 `env == None` 打出来的**常量字符串**,跟实际后端无关。
//! 于是 macOS M1 上「枚举到 Apple M1」与「最终是不是真在用 GPU」彻底脱钩:**看起来正常但可能在跑 CPU**。
//!
//! 现在的唯一判据:`ICED_BACKEND` 只写一项 ⇒ 能跑起来就必定是它;起不来就必然是
//! `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 验不了这一段)
//!
//! 判据表第 3/6/8/9 行的 macOS 分支只能真机验。下面每段都可直接粘贴;`# 期望` 后面是**必须能在
//! stderr 里 grep 到的原文**(M98 的验收包会直接把这些字符串编成断言)。
//!
//! ```bash
//! # 0) 一次
//! cargo build -p unlucky-app-iced
//!
//! # 1) 交互启动:这才是判「M1 到底有没有在用 GPU」的那次。看完关窗即可。
//! ./target/debug/unlucky-launcher-iced --renderer=auto
//! # 期望(这两行同一次运行里都要有):
//! # [probe] renderer = wgpu reason=auto-hardware-adapter 兜底=tiny-skia
//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1",
//! # 上面第二行里的 backend 必须是 Metal(同一行靠后)。
//! # 若出现下面任一行 ⇒ 这次**不是** GPU,把那段原文(含上一行)发回来:
//! # [gpu] wgpu 初始化失败(具名):
//! # [gpu] 降级 → tiny-skia
//! # [probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia)
//!
//! # 2) 开窗前拿原因(不建窗;多花 ~0.7 s)
//! ULL_PREFLIGHT=1 ./target/debug/unlucky-launcher-iced --renderer=auto
//! # 期望:[gpu-preflight] ok adapter="Apple M1" type=IntegratedGpu backend=Metal took=
//!
//! # 3) 取证出图(D30 通道;这一张图自证来源)
//! ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/gallery.png
//! # 期望(三行都该有):
//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1",
//! # [shot] 1180x760 scale=1 -> /tmp/gallery.png
//! # [shot] screen=gallery theme=light renderer=wgpu
//! # 若 wgpu 起不来 ⇒ 没有 /tmp/gallery.png、退出码 1、且打印 [gpu] 拒绝降级出图:
//!
//! # 4) 强制 CPU 对照(必须与 M97 之前逐字一致)
//! ./target/debug/unlucky-launcher-iced --renderer=cpu --screenshot /tmp/cpu.png
//! # 期望:
//! # [probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia
//! # [probe] renderer = tiny-skia reason=forced-cpu 兜底=none(候选表唯一项)
//! # [shot] screen=shell theme=light renderer=tiny-skia
//!
//! # 5) 负例:取证启动遇到 wgpu 不可用时必须红着退出(Mac 上用一个不存在的后端来造)
//! WGPU_BACKEND=dx12 ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/bad.png
//! # 期望:**没有** /tmp/bad.png、退出码 1、并且打印:
//! # [gpu] wgpu 初始化失败(具名):multiple errors occurred: [GraphicsAdapterNotFound { backend: "wgpu", reason: RequestFailed("no adapter was found for the options requested:
//! # [gpu] 拒绝降级出图:本次是取证启动(--screenshot /tmp/bad.png),换后端出的图会撒谎。
//! ```
//!
//! 第 5 段在 Windows 上已用同构的 `WGPU_BACKEND=metal` 验过(`metal support not compiled in`
//! → 具名错误 → 拒绝出图 → exit 1);macOS 上要确认打印的是 `dx12 support not compiled in` 那类
//! wgpu 原话,而不是我们自己的话。
pub mod acceptance;
pub mod decision;
pub mod log_bridge;
pub mod preflight;
pub use decision::{decide, demote_after_preflight, fallback_round, strict, Decision, Kind};
/// 把 `iced::Error` 里的**具名**图形错误掏出来(`Display` 那句笼统的话不算原因)。
///
/// `iced::Error::GraphicsCreationFailed(inner)` 的 `Display` 只有
/// 「the application graphics context could not be created」,**没有** `{0}`;
/// 真正的 backend 名与 `Reason` 在内层 `graphics::Error` 里。所以这里同时给 Display 与 Debug。
#[must_use]
pub fn named_graphics_error(error: &iced::Error) -> Option<String> {
match error {
iced::Error::GraphicsCreationFailed(inner) => Some(format!("{inner}|详情 {inner:?}")),
_ => None,
}
}
/// 跑一次 iced;起步后端起不来时把**具名**原因交给调用方(不在这里重试)。
///
/// ## 为什么不能在本进程里重试(M97 实测)
///
/// 第一版这里是「打印错误 + 再 `run()` 一次」。Windows 实测直接 panic:
///
/// ```text
/// thread 'main' panicked at iced_winit-0.14.1/src/lib.rs:81:
/// Create event loop: RecreationAttempt
/// ```
///
/// winit 不允许一个进程建两次事件循环,所以**进程内重试在架构上就不成立**。
/// 安全网因此改成**换一个进程重开**(`app/boot.rs` 的 `respawn`),
/// 猜测的写法别再加回来。
pub fn run_once(decision: Decision, attempt: impl FnOnce(Kind) -> iced::Result) -> iced::Result {
attempt(decision.primary)
}
/// 降级重开用的环境变量:子进程看到它就跳过探测、直接用它指向的后端(见 `app::boot::boot_probe`)。
///
/// ⚠️ 这是**防无限重开**的唯一闸门:子进程若不跳过探测,会算出同一个结论(auto + 有硬件 → wgpu)、
/// 再失败、再 respawn,变成自杀式递归。子进程侧的判据在 `boot_probe` 的第一个分支。
///
/// 它也是**进程间**的交接约定,不是给用户设的开关(用户入口仍是 `--renderer`)。
pub const FALLBACK_ENV: &str = "ULL_GPU_FALLBACK";
/// 读 [`FALLBACK_ENV`]:本进程是不是「上一步失败后重开」的那一次。
#[must_use]
pub fn forced_fallback() -> Option<Kind> {
match std::env::var(FALLBACK_ENV).ok()?.trim() {
"tiny-skia" => Some(Kind::TinySkia),
"wgpu" => Some(Kind::Wgpu),
_ => None,
}
}
/// 打印**具名**失败原因(三行以内,`stage`/`backend`/`reason` 都可 grep)。
///
/// `iced::Error::GraphicsCreationFailed` 的 `Display` **不包含**内部错误
/// (`#[error("the application graphics context could not be created")]`,没有 `{0}`),
/// 所以这里必须显式把内层错误打出来,否则又变回「只知道失败,不知道为什么」。
pub fn report_failure(backend: Kind, error: &(impl std::fmt::Display + std::fmt::Debug)) {
eprintln!("[gpu] {} 初始化失败(具名):{error}", backend.name());
eprintln!("[gpu] 失败详情(具名):{error:?}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Backend, Probe};
/// `run_once` **必须**把决策里的起步后端传下去。
///
/// 抓得住:`run_once` 被改成传死值(例如恒 `Kind::Wgpu`)——那会让「强制 CPU」也去试 GPU。
/// 抓不住:真 iced 启动成功/失败(那条只能真跑,见判据表第 6/8/9 行的实测记录)。
#[test]
fn run_once_passes_the_decision_primary() {
for request in [Backend::Cpu, Backend::Gpu] {
let decision = decide(request, &Probe::default(), true);
let mut seen = None;
let result = run_once(decision, |kind| {
seen = Some(kind);
Ok(())
});
assert!(result.is_ok(), "闭包返回 Ok 时 run_once 必须原样返回");
assert_eq!(
seen,
Some(decision.primary),
"run_once 传给 iced 的后端必须是决策里的起步项"
);
}
}
/// `run_once` 只跑一次:失败也**不**在进程内重试(winit 只允许建一次事件循环)。
///
/// 抓得住:谁把「进程内重试」加回 `run_once`(会 panic:`RecreationAttempt`)。
/// 抓不住:`respawn` 那条换进程的路(见 `app::boot::tests`)。
#[test]
fn run_once_never_retries_in_process() {
let mut calls = 0;
let _ = run_once(decide(Backend::Gpu, &Probe::default(), true), |_| {
calls += 1;
Err(iced::Error::WindowCreationFailed(Box::new(
std::io::Error::other("测试用"),
)))
});
assert_eq!(calls, 1, "进程内只许跑一次;重试只能换进程");
}
}
+244
View File
@@ -0,0 +1,244 @@
//! wgpu 自检(判据表第 7 行):在把控制权交给 iced **之前**自己跑一遍
//! 「实例 → 适配器 → 设备」,把 wgpu 的具名错误拿在手里。
//!
//! ## 为什么默认关
//!
//! 它会**重复** iced 马上要做的活(再建一个实例、再要一个设备),macOS 上一个 Metal 设备
//! 不是免费的;而且它测不了 `create_surface`(那时还没有窗口),所以它**代替不了**真跑一次。
//! 它的价值只有一个:让「不上 GPU」这个结论在**开窗之前**就有原因,验收时不用盯着一个闪窗猜。
//!
//! 开关:`ULL_PREFLIGHT=1`(判据表里登记过,不是隐藏口子)。
//!
//! ## 它镜像的是谁
//!
//! 逐字对着 `iced_wgpu-0.14.0/src/window/compositor.rs` 的 `Compositor::request()` 抄的:
//! `Backends::from_env().unwrap_or(all)`、`PowerPreference::from_env().unwrap_or(HighPerformance)`、
//! `Limits { max_bind_groups: 2, max_non_sampler_bindings: 2048, ..default }`。
//! 差一处都会让预检和真实路径说不同的话,那时预检就不是证据而是噪声了。
/// 预检开关的环境变量名(`1` / `true` 打开)。
pub const ENV_SWITCH: &str = "ULL_PREFLIGHT";
/// 预检成功:拿到的适配器摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
/// 适配器名(`Apple M1` / `AMD Radeon RX 6600 XT` / `Microsoft Basic Render Driver`)。
pub adapter: String,
/// `device_type` 的字符串形式。
pub device_type: &'static str,
/// 命中哪个 wgpu 后端(`Metal` / `Vulkan` / `Dx12` / `Gl`)。
pub backend: String,
}
impl Verdict {
/// 一行日志。
#[must_use]
pub fn describe(&self) -> String {
format!(
"ok adapter={:?} type={} backend={}",
self.adapter, self.device_type, self.backend
)
}
}
/// 预检失败:卡在哪一步 + wgpu 自己的错误文本。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
/// 失败阶段(日志里 `stage=` 的取值)。
pub stage: &'static str,
/// wgpu 具名错误(`Display`,原样保留,不翻译成「失败」)。
pub detail: String,
}
impl Failure {
/// 一行日志(`stage=` 可直接 grep)。
#[must_use]
pub fn describe(&self) -> String {
format!("FAILED stage={} error={}", self.stage, self.detail)
}
}
/// 用户是否要求预检。
#[must_use]
pub fn requested() -> bool {
std::env::var(ENV_SWITCH).is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
}
/// 按 `ULL_PREFLIGHT` 决定跑不跑;跑了就顺手把结论打进 stderr(带耗时,别让开关成本变成传说)。
///
/// 返回 `None` = 没要求预检(调用方按判据表 1~6 行原样走)。
#[must_use]
pub fn run_if_requested() -> Option<Result<Verdict, Failure>> {
if !requested() {
return None;
}
let started = std::time::Instant::now();
let verdict = run();
let took = started.elapsed().as_secs_f64() * 1000.0;
match &verdict {
Ok(ok) => eprintln!("[gpu-preflight] {} took={took:.0}ms", ok.describe()),
Err(failure) => {
eprintln!("[gpu-preflight] {} took={took:.0}ms", failure.describe());
}
}
Some(verdict)
}
#[cfg(feature = "gpu")]
/// 真跑一遍 wgpu 初始化(不开窗、不建 surface)。
///
/// # Errors
/// 无适配器 / 设备请求被拒时返回 [`Failure`],`detail` 是 wgpu 的原话。
pub fn run() -> Result<Verdict, Failure> {
use iced::wgpu::{
Backends, ExperimentalFeatures, Features, Instance, InstanceDescriptor, Limits,
MemoryHints, PowerPreference, RequestAdapterOptions, Trace,
};
let backends = Backends::from_env().unwrap_or_else(Backends::all);
let instance = Instance::new(&InstanceDescriptor {
backends,
..Default::default()
});
let options = RequestAdapterOptions {
power_preference: PowerPreference::from_env().unwrap_or(PowerPreference::HighPerformance),
// 没有窗口,测不了 surface 兼容性——这一条只能靠真跑一次(判据表第 8 行)。
compatible_surface: None,
force_fallback_adapter: false,
};
let adapter = block_on(instance.request_adapter(&options)).map_err(|error| Failure {
stage: "request-adapter",
detail: format!("{options:?}: {error}"),
})?;
let info = adapter.get_info();
let device_type = device_type_name(info.device_type);
// iced 的 `request_device` 失败时不打日志、只把错误塞进 `RequestDeviceFailed`,
// 所以我们在这里先请求一次,把它的话原样抄下来。
let _ = block_on(adapter.request_device(&iced::wgpu::DeviceDescriptor {
label: Some("unlucky-launcher gpu preflight"),
required_features: Features::empty(),
required_limits: Limits {
max_bind_groups: 2,
max_non_sampler_bindings: 2048,
..Limits::default()
},
memory_hints: MemoryHints::MemoryUsage,
trace: Trace::Off,
experimental_features: ExperimentalFeatures::disabled(),
}))
.map_err(|error| Failure {
stage: "request-device",
detail: error.to_string(),
})?;
Ok(Verdict {
adapter: info.name,
device_type,
backend: format!("{:?}", info.backend),
})
}
#[cfg(not(feature = "gpu"))]
/// 本构建没编 wgpu:预检必然是「不可用」,但这不是错误——判据表第 4 行已经说明原因。
///
/// # Errors
/// 恒返回 [`Failure`](`stage=not-compiled`)。
pub fn run() -> Result<Verdict, Failure> {
Err(Failure {
stage: "not-compiled",
detail: "本构建未编入 wgpu(--no-default-features)".to_owned(),
})
}
#[cfg(feature = "gpu")]
/// `DeviceType` → 日志里的字符串(探测与预检**共用**这一处,别各写一份 `match`)。
#[must_use]
pub fn device_type_name(device_type: iced::wgpu::DeviceType) -> &'static str {
use iced::wgpu::DeviceType;
match device_type {
DeviceType::DiscreteGpu => "DiscreteGpu",
DeviceType::IntegratedGpu => "IntegratedGpu",
DeviceType::VirtualGpu => "VirtualGpu",
DeviceType::Cpu => "Cpu",
DeviceType::Other => "Other",
}
}
/// 把 wgpu 的 future 跑到底。
///
/// 只用 `std`:`Waker` 由 `std::task::Wake` 提供(安全 API),运行期负责 `park`。
/// 不引入 futures 执行器——这里只有一个 future,而且它在 wgpu 的回调线程上唤醒。
#[cfg(feature = "gpu")]
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Unpark(std::thread::Thread);
impl Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(Unpark(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failure_line_is_greppable() {
let failure = Failure {
stage: "request-device",
detail: "no device request succeeded".to_owned(),
};
let text = failure.describe();
assert!(
text.starts_with("FAILED stage=request-device error="),
"{text}"
);
assert!(text.contains("no device request succeeded"), "{text}");
}
#[test]
fn verdict_line_lists_the_three_facts() {
let verdict = Verdict {
adapter: "Apple M1".to_owned(),
device_type: "IntegratedGpu",
backend: "Metal".to_owned(),
};
let text = verdict.describe();
assert!(text.contains("adapter=\"Apple M1\""), "{text}");
assert!(text.contains("type=IntegratedGpu"), "{text}");
assert!(text.contains("backend=Metal"), "{text}");
}
/// 没编 wgpu 的构建:预检必须说清原因,而不是一句「失败」。
#[cfg(not(feature = "gpu"))]
#[test]
fn unbuilt_gpu_names_the_reason() {
let failure = run().unwrap_err();
assert_eq!(failure.stage, "not-compiled");
assert!(failure.detail.contains("wgpu"), "{}", failure.detail);
}
}