Merge branch 'feat/m117-msacfg-accurate-wording-and-local-c'

This commit is contained in:
AskaEth committed 2026-11-05 07:31:57 +08:00
commit 1b77a36d3d
8 files changed
+380 -43

No files matched your search

+7
View File
@@ -157,6 +157,13 @@ impl App {
fn on_boot(&mut self) -> Task<Message> {
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();
+5 -2
View File
@@ -125,8 +125,11 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
账户存在 <数据根>/.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=<id> %prog% accounts add msa 每次运行时给(环境变量优先)
ULL_MSA_CLIENT_ID=<id> cargo build 构建时注入,二进制自带
两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
),
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
+50 -3
View File
@@ -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}=<id> %prog% accounts add msa`;\
想让二进制自带就构建时注入 `{env}=<id> 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<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
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=<id>"), "{hint}");
assert!(hint.contains("cargo build"), "{hint}");
assert!(hint.contains("accounts add msa"), "{hint}");
}
}
+8 -2
View File
@@ -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(),
}
}
}
+248
View File
@@ -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=<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=<id> cargo build`;或启动前设置同名环境变量 `ULL_MSA_CLIENT_ID=<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<ClientIdSource>,
}
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<ClientIdSource> {
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<String>,
}
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>` 占位**,不出现真 id。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
}
#[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());
}
}
}
+23 -36
View File
@@ -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<Self> {
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 之前(防止本地时钟/单位抄错)。
+1
View File
@@ -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=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<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.", "中文字体下载失败,界面暂时保持英文。"),
+38
View File
@@ -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=<id>, or set that environment variable before launching.",
"微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<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>` 占位,不出现真 id(repo 任何文件都不许带)。
assert!(en.contains("<id>") && zh.contains("<id>"));
}
}