fix(presenter,app-iced,i18n): M132 缺 MSA client id 上屏成「未配置 + 怎么补」

缺 client id 原来整条链路只给 `LoginFailure::Other`:界面画「Sign-in failed /
完整原因在启动器日志里 / Code: other」,用户得翻日志才知道要设 `ULL_MSA_CLIENT_ID`。

落点定在**账户页登录面板**,不是启动流程:这个失败只发生在 `microsoft_auth()`(两条
登录流的入口);没配 id 的发布版建不出微软账户,`identity::resolve` 那条启动路径用户
撞不到(只有「有 id 的构建里登录过、之后 id 被摘掉」才可达)。

- presenter:新增 `LoginFailure::MsaNotConfigured`(`msa_not_configured`)。`microsoft_auth`
  改为回 `AuthResult`,`AuthError::Unsupported`(`from_env` 唯一的产出点,零网络)单独成码,
  其余失败照旧走 `classify`。诊断原文仍只进日志。
- i18n:账户域新增 `AccountLoginNotConfigured`(标题)/ `AccountFailureMsaNotConfigured`
  (正文:设 `ULL_MSA_CLIENT_ID=<id>` 或构建时注入,改完重启),golden 同步。
- app-iced:`failure_title_key` 给这一类专属标题;正文按码查表;这一类**不给「重试」**
  (进程内的环境变量改不了,重试必然是同一个错)。
- 测试:`a_missing_client_id_tells_the_user_what_to_configure` 断言 UI 模型里出现「未配置」
  + 变量名 + `<id>` 占位 + `msa_not_configured`,且没有那句「去翻日志」;反向锚
  `a_network_failure_does_not_look_like_a_missing_config` 与
  `only_a_missing_client_id_becomes_the_config_gap_code` 钉住别的失败不许跟着变。

为什么这一类可以上屏:上屏的是 catalog 固定文案(无凭据、无 token、无内部细节),
`DevicePanel::Failed` 里没有 `message`/`detail` 字段,原文在类型上就画不出来;
理由是它的原因本身就是用户的下一步。M117/M119 的「诊断不上屏」约定不动。
This commit is contained in:
AskaEth committed 2026-11-05 20:08:52 +08:00
1 parent 01ca8033be
commit af39efd23b
7 files changed
+223 -52

No files matched your search

@@ -10,6 +10,16 @@
//! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` /
//! `detail` 里,**只进日志与详细信息,不上屏**。
//!
//! ## 唯一一个「说清原因」的例外(M132)
//!
//! [`LoginFailure::MsaNotConfigured`]。它上屏的**不是后端诊断串**,而是 catalog 里一条固定
//! 文案(`Key::AccountFailureMsaNotConfigured` / `Key::AccountLoginNotConfigured`,不含任何
//! 凭据、token 或内部细节)——「原文不上屏」这条纪律原样保留,页面仍拿不到、也画不出
//! `message`/`detail`。允许它说清原因的理由只有一条:**原因本身就是用户能执行的下一步**
//! (缺 `ULL_MSA_CLIENT_ID`,设上或构建时注入即可)。判据:没读过代码的用户碰上它,必须能
//! 从界面自己看出「是缺一个配置」并知道怎么补,而不是去翻日志。其余一切(网络 / 拒绝 /
//! 落盘…)仍旧只给码与分类文案,原文只进日志。
//!
//! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。
use serde::{Deserialize, Serialize};
@@ -37,6 +47,12 @@ pub enum LoginFailure {
NoProfile,
/// 落盘失败(账户库读写)
StoreFailed,
/// 微软轨的 client id 没配(`ULL_MSA_CLIENT_ID` 运行期/构建期两处都没有)——**配置缺失**,
/// 不是登录被拒、也不是「没实现」(M132)。
///
/// 单独成码的理由见模块文档「唯一一个『说清原因』的例外」:这一类的原因就是用户能执行的
/// 下一步,所以它的正文必须说清「要设哪个环境变量」,而不是「完整原因在日志里」。
MsaNotConfigured,
/// 其它(未知原因;原文只在 `message`)
Other,
}
@@ -53,6 +69,7 @@ impl LoginFailure {
Self::BadCredentials => "bad_credentials",
Self::NoProfile => "no_profile",
Self::StoreFailed => "store_failed",
Self::MsaNotConfigured => "msa_not_configured",
Self::Other => "other",
}
}
+52 -34
View File
@@ -9,6 +9,9 @@
//!
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
//!
//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板,
//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -16,7 +19,7 @@ use std::sync::Arc;
use unlucky_core::auth::account::Credentials;
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::auth::{AccountStore, AuthError, AuthResult};
use unlucky_core::download::blocking::block_on;
use crate::dto::CoreEvent;
@@ -185,7 +188,7 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, LoginFailure::Other, message),
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
};
let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| {
@@ -208,7 +211,7 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-authcode", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, LoginFailure::Other, message),
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
};
let tx_prompt = tx.clone();
let result = block_on(auth.authcode_login(|prompt| {
@@ -225,14 +228,24 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
}
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
MicrosoftAuth::from_env()
.map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
.map_err(|error| error.to_string())
fn microsoft_auth(env: &HostEnv) -> AuthResult<MicrosoftAuth> {
MicrosoftAuth::from_env().map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
}
/// `microsoft_auth` 失败 → 原因码。
///
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。
fn auth_failure(error: &AuthError) -> LoginFailure {
match error {
AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured,
other => classify(other),
}
}
/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。
@@ -320,34 +333,40 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
fn denied(code: &str) -> AuthError {
AuthError::Denied {
code: code.to_string(),
description: "bad".to_string(),
}
}
fn network() -> AuthError {
AuthError::Network {
url: "https://x".to_string(),
detail: "dns".to_string(),
}
}
#[test]
fn failure_codes_are_classified_from_variants_not_chinese_text() {
assert_eq!(
classify(&AuthError::Network {
url: "https://x".to_string(),
detail: "dns".to_string()
}),
LoginFailure::Unreachable
);
assert_eq!(
classify(&AuthError::Denied {
code: "AADSTS7000218".to_string(),
description: "request body must contain client_assertion".to_string(),
}),
LoginFailure::NotWhitelisted
);
assert_eq!(
classify(&AuthError::Denied {
code: "invalid_grant".to_string(),
description: "bad".to_string(),
}),
LoginFailure::Denied
);
assert_eq!(classify(&network()), LoginFailure::Unreachable);
let whitelist = classify(&denied("AADSTS7000218"));
assert_eq!(whitelist, LoginFailure::NotWhitelisted);
assert_eq!(classify(&denied("invalid_grant")), LoginFailure::Denied);
assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other);
assert!(mentions_whitelist("AADSTS7000218"));
assert!(!mentions_whitelist("invalid_grant"));
}
/// **M132**:只有「缺 client id」变成「需要配置」,网络/凭据失败不许跟着变(否则成噪音)。
#[test]
fn only_a_missing_client_id_becomes_the_config_gap_code() {
let missing = auth_failure(&AuthError::Unsupported(String::new()));
assert_eq!(missing, LoginFailure::MsaNotConfigured);
assert_eq!(auth_failure(&network()), LoginFailure::Unreachable);
assert_eq!(auth_failure(&denied("invalid_grant")), LoginFailure::Denied);
}
#[test]
fn offline_name_rule_matches_the_ui_contract() {
assert!(valid_offline_name("Steve"));
@@ -360,8 +379,7 @@ mod tests {
#[test]
fn a_bad_offline_name_is_classified_not_stringified() {
let dir =
std::env::temp_dir().join(format!("ull-host-accounts-name-{}", std::process::id()));
let dir = std::env::temp_dir().join(format!("ull-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");