diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index ed33e8f..d8b5d0b 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -157,6 +157,13 @@ impl App { fn on_boot(&mut self) -> Task { let mut tasks = vec![window::latest().map(Message::WindowReady)]; + // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。 + // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; + // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, + // 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨 + // 「没配置」与「配了但登录失败」。 + eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line()); + // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 self.apply_config_theme(); diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index f09222f..c2689a6 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -125,8 +125,11 @@ accessToken 一律脱敏(输出里出现的是 )。", 账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据) **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 凭据永不打印:list 只输出名称/轨道/UUID。 -add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码), -需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。 +add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。 +它需要一个 Azure 公共客户端 id —— 登录链路是完整的,缺的只是这一项配置;id 不进仓库: + ULL_MSA_CLIENT_ID= %prog% accounts add msa 每次运行时给(环境变量优先) + ULL_MSA_CLIENT_ID= cargo build 构建时注入,二进制自带 +两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。 authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", ), "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index ffc4f69..b422684 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -82,9 +82,14 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError { Some("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string()) } AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()), - AuthError::Unsupported(_) => { - Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string()) - } + // M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路: + // 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。 + AuthError::Unsupported(_) => Some(format!( + "先配 Azure 公共客户端 id(id 不进仓库):\ +`{env}= %prog% accounts add msa`;\ +想让二进制自带就构建时注入 `{env}= cargo build`。环境变量优先于构建期注入。", + env = unlucky_core::auth::microsoft::CLIENT_ID_ENV + )), AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()), AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()), AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => { @@ -114,6 +119,18 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError { } } +/// GUI / CLI 共用的**启动日志一行**:`[auth] msa client_id=configured|missing source=env|build|none`。 +/// +/// M117:判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,这里只做出面—— +/// `app-iced` 不直接依赖 `unlucky-core`,而 `unlucky-cli` 已经是它的依赖(同一个二进制既跑 +/// GUI 也跑 `ull` 子命令)。所以「谁打印」不在 UI 侧重算一遍判据,免得两份口径漂。 +/// +/// 用途:日后看启动日志能一眼分辨「没配置」与「配了但登录失败」,不用再猜。 +#[must_use] +pub fn msa_client_id_log_line() -> String { + unlucky_core::auth::microsoft::client_id_status().log_line() +} + /// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。 fn with_ctx(globals: &Globals, action: F) -> Result where @@ -174,4 +191,34 @@ mod tests { assert!(!hint.contains("代理"), "{hint}"); assert!(hint.contains("login_with_xbox_403"), "{hint}"); } + + #[test] + fn startup_log_line_is_one_machine_readable_line() { + // M117:这行是「没配置 vs 配了但登录失败」的唯一机读判据,形状不能漂。 + let line = super::msa_client_id_log_line(); + assert!(line.starts_with("[auth] msa client_id="), "{line}"); + assert!(line.contains(" source="), "{line}"); + assert!(!line.contains('\n'), "必须正好一行:{line}"); + let source = line.rsplit("source=").next().unwrap_or_default(); + assert!(matches!(source, "env" | "build" | "none"), "{line}"); + } + + #[test] + fn missing_client_id_hint_carries_a_runnable_command() { + // M117:用户实跑看到的「本期未支持」是错的表述,而且 hint 只丢了变量名。 + // 判据:文案说「未配置」、不说「未支持/未实现」,并给出能照抄的命令(含变量名)。 + let cli = map_auth_error(AuthError::Unsupported( + unlucky_core::auth::microsoft::MISSING_CLIENT_ID_MESSAGE.to_string(), + )); + let message = cli.message(); + assert_eq!(cli.code(), "unsupported", "机读码保持兼容"); + assert!(message.contains("客户端 id"), "{message}"); + assert!(!message.contains("未支持"), "{message}"); + assert!(!message.contains("未实现"), "{message}"); + + let hint = cli.hint().expect("缺配置要有 hint").to_string(); + assert!(hint.contains("ULL_MSA_CLIENT_ID="), "{hint}"); + assert!(hint.contains("cargo build"), "{hint}"); + assert!(hint.contains("accounts add msa"), "{hint}"); + } } diff --git a/crates/core/src/auth/error.rs b/crates/core/src/auth/error.rs index a9fd332..cad3632 100644 --- a/crates/core/src/auth/error.rs +++ b/crates/core/src/auth/error.rs @@ -79,7 +79,13 @@ pub enum AuthError { /// 冲突的账户名。 account: String, }, - /// 该轨/该能力本期未实现(如 CLI 上的 authlib 密码登录)。 + /// 该轨/该能力当前不可用:**未配置**(如缺 Azure client id)或本期未实现 + /// (如 CLI 上的 authlib 密码登录)。 + /// + /// 载荷必须**自带完整的人读句子**(「未配置 + 怎么配」/「未实现 + 为什么」), + /// 本层不再统一拼「本期未支持:」这类前缀——同一层里两种情况完全不同的事, + /// 硬拼一个前缀会让「没配置」被读成「没实现」(M117 用户实跑踩到)。 + /// 机读码仍是 `unsupported`([`Self::code`]),调用方分流不受影响。 Unsupported(String), } @@ -170,7 +176,7 @@ impl AuthError { Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"), Self::NotFound { account } => format!("找不到账户 `{account}`"), Self::Conflict { account } => format!("账户 `{account}` 已存在"), - Self::Unsupported(detail) => format!("本期未支持:{detail}"), + Self::Unsupported(detail) => detail.clone(), } } } diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs new file mode 100644 index 0000000..d7194fb --- /dev/null +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -0,0 +1,248 @@ +//! Azure 公共客户端 id 的**配置状态**:从哪来、配没配、启动日志那一行长什么样。 +//! +//! ## 为什么单独一块(M117) +//! +//! 这个 id 不进仓库(用户定案),所以它可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`], +//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])。**两处都没有**时登录是「没配置」, +//! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清: +//! +//! | 现象 | 该说什么 | +//! |---|---| +//! | 两处都没配 | [`MISSING_CLIENT_ID_MESSAGE`]:未配置 + 怎么配(含可照抄的命令) | +//! | 配了但登录失败 | 走 `AuthError` 的正常分流(网络/拒绝/无拥有权…),**不许**再提「未实现」 | +//! +//! [`ClientIdStatus::log_line`] 是给启动日志的那一行:`[auth] msa client_id=… source=…`。 +//! 有了它,「未配置」与「配了但登录失败」在日志里一眼可分,不用再猜。 +//! +//! 本模块**零网络**:只读环境变量与编译期常量,连 HTTP 客户端都不建。 + +/// 运行期环境变量名(配 client id)。 +pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; + +/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID= cargo build`)。 +pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); + +/// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。 +/// +/// 措辞纪律(M117):说「未配置」,不说「未支持」——登录链路是完整的,缺的只是这一项; +/// 并且句子里带**可照抄的命令**,因为失败横幅往往是用户唯一会看到的那段字。 +pub const MISSING_CLIENT_ID_MESSAGE: &str = + "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。\ +构建时注入:`ULL_MSA_CLIENT_ID= cargo build`;或启动前设置同名环境变量 `ULL_MSA_CLIENT_ID=`。"; + +/// client id 从哪来(启动日志与排障用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientIdSource { + /// 运行期环境变量 `ULL_MSA_CLIENT_ID`(**优先于**构建期注入)。 + Env, + /// 构建期 `option_env!` 注入。 + Build, +} + +impl ClientIdSource { + /// 启动日志里的机读标签(`env` / `build`)。 + #[must_use] + pub const fn tag(self) -> &'static str { + match self { + Self::Env => "env", + Self::Build => "build", + } + } +} + +/// client id 的配置状态:配没配 + 从哪来(`None` = 两处都没有)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientIdStatus { + source: Option, +} + +impl ClientIdStatus { + /// 拿到 id 了没(`false` = 需要配置,不是功能缺失)。 + #[must_use] + pub const fn is_configured(self) -> bool { + self.source.is_some() + } + + /// 来源(`None` = 两处都没有)。 + #[must_use] + pub const fn source(self) -> Option { + self.source + } + + /// 启动日志那一行:`[auth] msa client_id=configured|missing source=env|build|none`。 + /// + /// 判据(M117):日后看日志能直接分辨「未配置」与「配置了但登录失败」。 + #[must_use] + pub fn log_line(self) -> String { + format!( + "[auth] msa client_id={} source={}", + if self.source.is_some() { + "configured" + } else { + "missing" + }, + self.source.map_or("none", ClientIdSource::tag) + ) + } +} + +/// 当前进程看到的 client id 与来源(**只读环境/编译期常量,不联网**)。 +/// +/// 优先级:环境变量 > 构建期注入。两者都存在时**不告警**(M117):环境变量就是显式覆盖, +/// 来源如实报 [`ClientIdSource::Env`],日志里看得到。 +#[must_use] +pub fn resolve_client_id() -> Option<(String, ClientIdSource)> { + let from_env = std::env::var(CLIENT_ID_ENV) + .ok() + .filter(|value| !value.trim().is_empty()); + let from_build = CLIENT_ID_BUILD.filter(|value| !value.trim().is_empty()); + match (from_env, from_build) { + (Some(value), _) => Some((value, ClientIdSource::Env)), + (None, Some(value)) => Some((value.to_string(), ClientIdSource::Build)), + (None, None) => None, + } +} + +/// 只问「配没配 / 从哪来」——启动日志与 GUI 启动行用它(不建 HTTP 客户端、不联网)。 +#[must_use] +pub fn client_id_status() -> ClientIdStatus { + ClientIdStatus { + source: resolve_client_id().map(|(_, source)| source), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::{ + client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV, + MISSING_CLIENT_ID_MESSAGE, + }; + use crate::auth::error::AuthError; + use crate::auth::microsoft::MicrosoftAuth; + + /// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。 + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。 + struct EnvGuard { + saved: Option, + } + + impl EnvGuard { + fn clear() -> Self { + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + Self { saved } + } + + fn set(value: &str) -> Self { + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::set_var(CLIENT_ID_ENV, value); + Self { saved } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.saved.take() { + Some(value) => std::env::set_var(CLIENT_ID_ENV, value), + None => std::env::remove_var(CLIENT_ID_ENV), + } + } + } + + #[test] + fn missing_client_id_message_says_unconfigured_not_unimplemented() { + // 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug), + // 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。 + // 抓不住:文案好不好读——那要靠 review。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id")); + assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置")); + assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持")); + assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现")); + assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV)); + assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build")); + // 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `` 占位**,不出现真 id。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); + } + + #[test] + fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::clear(); + let result = MicrosoftAuth::from_env(); + match result { + Err(AuthError::Unsupported(message)) => { + // 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流), + // 但**人读文案**必须说「未配置」。 + assert_eq!(message, MISSING_CLIENT_ID_MESSAGE); + assert!(!message.contains("未支持")); + } + // 构建期注入过 id 时唯一合法(本仓库 CI/本地都不注入,走不到这里)。 + Ok(_) => assert!( + CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), + "没配 id 却构造成功" + ), + Err(other) => panic!("应是 Unsupported,实际 {other:?}"), + } + } + + #[test] + fn env_client_id_wins_over_build_and_the_log_says_so() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::set("0000-test-env-id"); + + let (id, source) = resolve_client_id().expect("环境变量给了 id"); + assert_eq!(id, "0000-test-env-id"); + // 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。 + assert_eq!(source, ClientIdSource::Env); + + let status = client_id_status(); + assert!(status.is_configured()); + assert_eq!(status.source(), Some(ClientIdSource::Env)); + assert_eq!( + status.log_line(), + "[auth] msa client_id=configured source=env" + ); + + // 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止, + // `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。 + let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功"); + assert!(auth.endpoints().profile.contains("minecraftservices.com")); + } + + #[test] + fn missing_client_id_log_line_is_machine_readable() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::clear(); + if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { + // 构建期注入过 id:这条没有「缺配置」可言,跳过。 + return; + } + let status = client_id_status(); + assert!(!status.is_configured()); + assert_eq!(status.source(), None); + assert_eq!( + status.log_line(), + "[auth] msa client_id=missing source=none" + ); + } + + #[test] + fn blank_env_value_counts_as_missing() { + // 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。 + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::set(" "); + if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { + assert_eq!( + resolve_client_id().map(|(_, source)| source), + Some(ClientIdSource::Build), + "空白环境变量必须让位给构建期注入" + ); + } else { + assert!(resolve_client_id().is_none()); + } + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs index 78f0317..ec8c3af 100644 --- a/crates/core/src/auth/microsoft/mod.rs +++ b/crates/core/src/auth/microsoft/mod.rs @@ -1,7 +1,8 @@ //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 //! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! -//! 分五块,各管一件事: +//! 分六块,各管一件事: +//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); //! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); //! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); @@ -17,12 +18,17 @@ //! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 //! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 //! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 -//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— +//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是 +//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])—— //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 +//! +//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在 +//! [`client_id`],本模块只消费结论。 pub mod authcode; pub mod callback; pub mod chain; +pub mod client_id; pub mod endpoints; pub mod flow; @@ -30,6 +36,12 @@ pub mod session; pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; pub use callback::CallbackServer; +// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认 +// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。 +pub use client_id::{ + client_id_status, resolve_client_id, ClientIdSource, ClientIdStatus, CLIENT_ID_BUILD, + CLIENT_ID_ENV, MISSING_CLIENT_ID_MESSAGE, +}; pub use endpoints::MicrosoftEndpoints; pub use flow::{DeviceCodePrompt, PollPolicy}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; @@ -43,11 +55,6 @@ use crate::auth::journal::{AuthFailureEvent, AuthJournal}; use crate::download::config::EngineConfig; use crate::download::http::build_client; -/// 运行期环境变量名(配 client id)。 -pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; -/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=... cargo build`)。 -pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); - /// 微软账户登录/刷新。 #[derive(Debug, Clone)] pub struct MicrosoftAuth { @@ -86,17 +93,15 @@ impl MicrosoftAuth { /// /// # Errors /// - /// 两处都没配时返回 [`AuthError::Unsupported`]。 + /// 两处都没配时返回 [`AuthError::Unsupported`],文案是 + /// [`MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配)。这条路径**零网络**: + /// id 判定在网络之前,判断失败就地早退,连 HTTP 客户端都不建。 pub fn from_env() -> AuthResult { - let client_id = std::env::var(CLIENT_ID_ENV) - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| CLIENT_ID_BUILD.map(str::to_string)) - .ok_or_else(|| { - AuthError::Unsupported(format!( - "微软登录需要 Azure 公共客户端 id:设置环境变量 {CLIENT_ID_ENV}(或构建时注入)" - )) - })?; + let Some((client_id, _source)) = resolve_client_id() else { + return Err(AuthError::Unsupported( + MISSING_CLIENT_ID_MESSAGE.to_string(), + )); + }; let mut auth = Self::new(client_id)?; auth.journal = AuthJournal::portable_default(); Ok(auth) @@ -340,8 +345,7 @@ pub fn now_ms() -> u64 { #[cfg(test)] mod tests { - use super::{now_ms, MicrosoftAuth, CLIENT_ID_ENV}; - use crate::auth::error::AuthError; + use super::{now_ms, MicrosoftAuth}; #[test] fn explicit_client_id_builds() { @@ -352,23 +356,6 @@ mod tests { .contains("api.minecraftservices.com")); } - #[test] - fn missing_client_id_is_explained() { - // 测试进程里也可能被外部配了环境变量,先摘掉再验。 - let saved = std::env::var(CLIENT_ID_ENV).ok(); - std::env::remove_var(CLIENT_ID_ENV); - let result = MicrosoftAuth::from_env(); - if let Some(value) = saved { - std::env::set_var(CLIENT_ID_ENV, value); - } - match result { - Err(AuthError::Unsupported(message)) => assert!(message.contains(CLIENT_ID_ENV)), - // 构建期注入过 client id 时唯一合法,跳过断言。 - Ok(_) => assert!(super::CLIENT_ID_BUILD.is_some()), - Err(other) => panic!("应是 Unsupported,实际 {other:?}"), - } - } - #[test] fn now_ms_is_sane() { // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index cc631e9..4eb8ef0 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -51,6 +51,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"), ("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"), ("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), + ("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID= cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=。"), ("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"), ("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"), ("FontCjkDownloadFailed", "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。"), diff --git a/crates/i18n/src/catalog/launch.rs b/crates/i18n/src/catalog/launch.rs index d9e6073..1559355 100644 --- a/crates/i18n/src/catalog/launch.rs +++ b/crates/i18n/src/catalog/launch.rs @@ -14,8 +14,46 @@ macro_rules! launch_entries { "疑似静默失败:退出码 0,但游戏没真正跑起来" ), LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), + // M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条 + // 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的 + // `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。 + // 判据:中英都点名 `ULL_MSA_CLIENT_ID` 并给出构建期注入这条路,且**不许**出现 + // 「unsupported / 未支持」——用户实跑就是被那句误导读成「功能没做」。 + LaunchMsaNotConfigured => ( + "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=, or set that environment variable before launching.", + "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID= cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=。" + ), } } }; } pub(crate) use launch_entries; + +#[cfg(test)] +mod tests { + use crate::{Catalog, Key, Lang}; + + #[test] + fn msa_not_configured_copy_is_accurate_in_both_languages() { + // 抓得住:文案退回「本期未支持」这类说法(M117 用户实跑踩到的原 bug), + // 以及两条译文只剩一句「需要 id」却不给怎么配。 + // 抓不住:读起来顺不顺——那要靠 review 与截图。 + let en = Catalog::of(Lang::En).text(Key::LaunchMsaNotConfigured); + let zh = Catalog::of(Lang::ZhCn).text(Key::LaunchMsaNotConfigured); + + for (tag, text) in [("en", en), ("zh-CN", zh)] { + assert!(text.contains("ULL_MSA_CLIENT_ID"), "{tag}: {text}"); + } + assert!(en.contains("client id"), "{en}"); + assert!(en.contains("not configured"), "{en}"); + assert!(!en.to_lowercase().contains("unsupported"), "{en}"); + assert!(!en.to_lowercase().contains("not implemented"), "{en}"); + + assert!(zh.contains("客户端 id"), "{zh}"); + assert!(zh.contains("未配置"), "{zh}"); + assert!(!zh.contains("未支持"), "{zh}"); + assert!(!zh.contains("未实现"), "{zh}"); + // 硬约束:词条里只许出现 `` 占位,不出现真 id(repo 任何文件都不许带)。 + assert!(en.contains("") && zh.contains("")); + } +}