fix(app-iced, presenter): M114 r2 —— 登录失败走结构化错误码 + i18n,修 4 条 p2 与 3 条看图意见

p1(语言泄漏):后端诊断串不再上屏
- presenter 新增 LoginFailure 原因码(denied / whitelist_pending / unreachable /
  not_authlib_server / bad_credentials / no_profile / store_failed / other)
- 页面按码查 i18n 出标题+正文+「Code: …」,原文只进日志/详细信息
- 进行中(Requesting)也走 i18n,不再复读后端中文状态串
- presenter/state 不再发中文 toast(成败由页面按 i18n 画)
- 新增守门测试:注入带 AADSTS 的失败事件,断言英文视图无 CJK 且含 code

p2-1 复制:on_account 返回 Task,page_messages 把 iced::clipboard::write 交回 runtime
p2-2 可见失败态:新增 AccountNotice(added/removed/switched/名字非法/重名/落盘失败/
      找不到),页面用 M3 内联 alert 画(不是 toast);离线名字先按 3–16 位规则校验,
      失败不再清空输入
p2-3 重新登录按账户类型分派:微软→授权码流;第三方→预填服务器/登录名并提示补密码;
      离线→明确说明没有可重登的服务器账户
p2-4 入口收敛:删 chip 行;空态=三卡片;有账号时三卡收进 Add an account(按钮不再绑死微软轨)

看图意见/tower 三条:
- 失败横幅改成 M3 alert(SurfaceContainerHigh + 描边,errorContainer 只作图标块,
  不再通栏实心红);色值本身经复审像素采样确认是 90/30 档,未改角色
- 当前账户卡补齐与列表一致的「服务器/登录名」行;无皮肤时明说 No skin available
- 页面级 scrollable 加 8px 右 gutter,滚动条不再压卡片
- 倒计时改 mm:ss(15:00 left),数字与冒号语言无关

p3(顺带):AuthlibRequest / Form 手写 Debug 脱敏密码;HTTP 响应体与 base64 输出加上限;
离线提示文案与行为对齐;删除无人使用的 key;皮肤测试断言帽子层真的 blend

测试:app-iced lib 418 绿、presenter 151 / i18n 24 绿(workspace 全量另跑)
This commit is contained in:
AskaEth committed 2026-11-05 05:09:51 +08:00
1 parent 1ff00bbe50
commit 8d8552113a
31 files changed
+1540 -497

No files matched your search

+43 -43
View File
@@ -10,55 +10,19 @@
//! | [`redact`] | 形状脱敏:JWT / 不透明串 / `token=...` 一律抹掉(第二道脱敏器) |
//! | `tests` | 契约 round-trip + 凭据红线的入口断言 |
mod failure;
mod kind;
mod redact;
#[cfg(test)]
#[path = "accounts/tests.rs"]
mod tests;
pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure};
pub use kind::AccountKind;
pub use redact::{looks_like_jwt, redact, REDACTED};
use serde::{Deserialize, Serialize};
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountKind {
/// 离线账户(HMCL 兼容 UUID)
Offline,
/// 微软正版
Microsoft,
/// authlib-injector(第三方皮肤站)
Authlib,
}
impl AccountKind {
/// 徽标文字(CLI/终端用;GUI 走 i18n 词表,不读这里的中文)。
pub fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::Authlib => "第三方",
}
}
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::Authlib => "authlib_injector",
}
}
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
pub fn parse(raw: &str) -> Self {
match raw {
"microsoft" => Self::Microsoft,
"authlib_injector" => Self::Authlib,
_ => Self::Offline,
}
}
}
/// 账户列表行(**只含可公开字段**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountView {
@@ -175,8 +139,11 @@ pub struct DeviceLoginView {
pub qr_link: String,
/// 有效期(秒;0 = 未知)
pub expires_in: u64,
/// 状态/错误文案
/// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**)
pub message: String,
/// 失败原因码(`None` = 不是失败态 / 未分类)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<LoginFailure>,
/// 成功后入库的账户展示名
pub account_name: String,
}
@@ -197,6 +164,7 @@ impl DeviceLoginView {
qr_link: String::new(),
expires_in: 0,
message: String::new(),
failure: None,
account_name: String::new(),
}
}
@@ -223,6 +191,7 @@ impl DeviceLoginView {
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
expires_in
),
failure: None,
account_name: String::new(),
}
}
@@ -250,15 +219,22 @@ impl DeviceLoginView {
qr_link: authorization_url.to_string(),
expires_in,
message,
failure: None,
account_name: String::new(),
}
}
/// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。
pub fn failed(message: impl Into<String>) -> Self {
Self::failed_as(LoginFailure::Other, message)
}
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
Self {
phase: DeviceLoginPhase::Failed,
message: redact(&message.into()),
failure: Some(failure),
..Self::new()
}
}
@@ -269,6 +245,7 @@ impl DeviceLoginView {
Self {
phase: DeviceLoginPhase::Succeeded,
message: format!("已登录:{account_name}"),
failure: None,
account_name,
..Self::new()
}
@@ -280,7 +257,7 @@ impl DeviceLoginView {
/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的
/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、
/// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthlibRequest {
/// 认证服务器地址(用户原样输入;host 侧 `normalize_api_root` 规范化)
pub server: String,
@@ -290,6 +267,17 @@ pub struct AuthlibRequest {
pub password: String,
}
/// **手写 `Debug`**:任何 `{:?}`(日志/panic 消息/测试失败输出)都不得带出明文密码。
impl std::fmt::Debug for AuthlibRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthlibRequest")
.field("server", &redact(&self.server))
.field("login", &redact(&self.login))
.field("password", &REDACTED)
.finish()
}
}
impl AuthlibRequest {
/// 构造。
pub fn new(
@@ -324,8 +312,11 @@ pub struct AuthlibLoginView {
pub server: String,
/// 服务发现里的服务器名(校验通过后才有;空 = 没校验/没名字)
pub server_name: String,
/// 状态/错误文案(已脱敏)
/// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**)
pub message: String,
/// 失败原因码(`None` = 不是失败态 / 未分类)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<LoginFailure>,
/// 成功后入库的账户展示名
pub account_name: String,
}
@@ -344,6 +335,7 @@ impl AuthlibLoginView {
server: String::new(),
server_name: String::new(),
message: String::new(),
failure: None,
account_name: String::new(),
}
}
@@ -355,6 +347,7 @@ impl AuthlibLoginView {
server: server.to_string(),
server_name: String::new(),
message: redact(message),
failure: None,
account_name: String::new(),
}
}
@@ -366,15 +359,22 @@ impl AuthlibLoginView {
server: server.to_string(),
server_name: redact(server_name),
message: format!("服务器可用:{}", server_name.trim()),
failure: None,
account_name: String::new(),
}
}
/// 失败态(文案在入口就脱敏)。
pub fn failed(message: impl Into<String>) -> Self {
Self::failed_as(LoginFailure::Other, message)
}
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
Self {
phase: DeviceLoginPhase::Failed,
message: redact(&message.into()),
failure: Some(failure),
..Self::new()
}
}
@@ -0,0 +1,132 @@
//! 失败原因码与账户页横幅(M114 r2 / 复审 P1-1、P2-2)。
//!
//! ## 为什么这一层必须有
//!
//! core/presenter 的诊断串是中文人话。把它们直接画到英文界面上,就是复审 r1 那条 p1
//! (「全英文界面里整句中文」)。根因不是 catalog 缺条目,而是**后端诊断串没有结构**:
//! 页面除了原样画没有别的选择。
//!
//! 这里补的就是那个缺环——把「失败属于哪一类」「这次操作的结果是什么」变成**枚举**。
//! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` /
//! `detail` 里,**只进日志与详细信息,不上屏**。
//!
//! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。
use serde::{Deserialize, Serialize};
use super::redact;
/// 登录失败的**结构化原因码**(M114 r2 / 复审 P1-1)。
///
/// 为什么要有它:core/presenter 的诊断串是中文人话,**直接上屏**会让英文界面里出现整句中文
/// (复审 r1 的 p1)。这里把「失败属于哪一类」变成**可判据的枚举**,页面据码选 i18n key 出
/// 标题 + 正文 + 一个稳定 code;原始诊断只留在 `message` 里进日志/详细信息,**不上屏**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoginFailure {
/// 服务器/微软明确拒绝(403、OAuth `error`、XErr)
Denied,
/// 应用注册还没获批(AADSTS7000218 一类「白名单未批」)
NotWhitelisted,
/// 网络不可达 / 超时
Unreachable,
/// 目标地址不是 authlib-injector 服务器(响应不是服务发现 JSON)
NotAuthlibServer,
/// 服务器拒绝了这个账号(密码错 / 账号不存在)
BadCredentials,
/// 服务器没有返回角色(账号还没创建角色)
NoProfile,
/// 落盘失败(账户库读写)
StoreFailed,
/// 其它(未知原因;原文只在 `message`)
Other,
}
impl LoginFailure {
/// 稳定的 ascii code(日志 / 页面「Code: …」/ 测试断言都用它,**不随语言变**)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Denied => "denied",
Self::NotWhitelisted => "whitelist_pending",
Self::Unreachable => "unreachable",
Self::NotAuthlibServer => "not_authlib_server",
Self::BadCredentials => "bad_credentials",
Self::NoProfile => "no_profile",
Self::StoreFailed => "store_failed",
Self::Other => "other",
}
}
}
/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。
///
/// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。
/// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountNotice {
/// 结果码(决定标题/正文/颜色)
pub code: AccountNoticeCode,
/// 涉及的账户名或服务器(拼进文案;可为空)
pub subject: String,
/// 原始诊断(已脱敏;**只进日志/详细信息,不上屏**)
pub detail: String,
}
impl AccountNotice {
/// 构造。
pub fn new(
code: AccountNoticeCode,
subject: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
code,
subject: subject.into(),
detail: redact(&detail.into()),
}
}
}
/// 横幅的结果码。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountNoticeCode {
/// 已添加
Added,
/// 已删除
Removed,
/// 已切换
Switched,
/// 名字不合法(长度/字符集)
OfflineNameInvalid,
/// 同名账户已存在
AccountExists,
/// 落盘失败
StoreFailed,
/// 找不到这个账户
NotFound,
/// 其它(原文在 `detail`)
Other,
}
impl AccountNoticeCode {
/// 是否是一个「失败」结果(决定横幅配色)。
#[must_use]
pub const fn is_failure(self) -> bool {
!matches!(self, Self::Added | Self::Removed | Self::Switched)
}
/// 稳定 ascii code(日志/测试用)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Switched => "switched",
Self::OfflineNameInvalid => "offline_name_invalid",
Self::AccountExists => "account_exists",
Self::StoreFailed => "store_failed",
Self::NotFound => "not_found",
Self::Other => "other",
}
}
}
+43
View File
@@ -0,0 +1,43 @@
//! 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
use serde::{Deserialize, Serialize};
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountKind {
/// 离线账户(HMCL 兼容 UUID)
Offline,
/// 微软正版
Microsoft,
/// authlib-injector(第三方皮肤站)
Authlib,
}
impl AccountKind {
/// 徽标文字(CLI/终端用;GUI 走 i18n 词表,不读这里的中文)。
pub fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::Authlib => "第三方",
}
}
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::Authlib => "authlib_injector",
}
}
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
pub fn parse(raw: &str) -> Self {
match raw {
"microsoft" => Self::Microsoft,
"authlib_injector" => Self::Authlib,
_ => Self::Offline,
}
}
}
@@ -92,6 +92,16 @@ fn authlib_request_never_prints_the_password() {
assert!(shown.contains(REDACTED), "{shown}");
}
/// `Debug` 也不许带出密码(复审 r1 P3-5):日志一行 `{:?}` 就破红线。
#[test]
fn authlib_request_debug_never_prints_the_password() {
let request = AuthlibRequest::new("https://littleskin.cn/api/yggdrasil/", "me", "hunter2");
let shown = format!("{request:?}");
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains(REDACTED), "{shown}");
assert!(shown.contains("littleskin"), "{shown}");
}
#[test]
fn authlib_view_carries_the_server_name_and_no_tokens() {
let view = AuthlibLoginView::reachable("https://littleskin.cn/api/yggdrasil/", "LittleSkin");
+7 -3
View File
@@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize};
use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::CreateRequest;
use crate::dto::{
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib* 的并集。
AccountView, AgentEvent, AuthlibLoginView, AuthlibRequest, ContentInstallRequest, ContentQuery,
ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
AccountNotice, AccountView, AgentEvent, AuthlibLoginView, AuthlibRequest, ContentInstallRequest,
ContentQuery, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession,
NoticeView, PackCommand, PackEvent, SessionId, SettingGroupView, VersionCategory,
VersionSummary,
@@ -72,6 +72,8 @@ pub enum CoreEvent {
DeviceLoginChanged(DeviceLoginView),
/// 第三方轨(authlib-injector)登录/校验状态刷新
AuthlibLoginChanged(AuthlibLoginView),
/// 账户页横幅(添加/删除/切换的结果;`None` = 清掉横幅)
AccountNoticeChanged(Option<AccountNotice>),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
@@ -178,6 +180,8 @@ pub enum AppCommand {
AddAuthlibAccount(AuthlibRequest),
/// 只校验第三方皮肤站地址(服务发现):结果走 `AuthlibLoginChanged`
ProbeAuthlibServer(String),
/// 关掉账户页横幅(页面上的 × / Dismiss)
DismissAccountNotice,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String),
/// 写一条设置(`key` 必须是 core schema 里的键)
+3 -2
View File
@@ -29,8 +29,9 @@ mod settings;
mod versions;
pub use accounts::{
looks_like_jwt, redact, AccountKind, AccountView, AuthlibLoginView, AuthlibRequest,
DeviceLoginPhase, DeviceLoginView, SkinImage, REDACTED,
looks_like_jwt, redact, AccountKind, AccountNotice, AccountNoticeCode, AccountView,
AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage,
REDACTED,
};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{
+113 -16
View File
@@ -19,7 +19,10 @@ use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::download::blocking::block_on;
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use crate::dto::CoreEvent;
use crate::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, DeviceLoginView, LoginFailure,
};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
@@ -28,8 +31,33 @@ use super::opener;
/// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。
pub mod authlib;
mod failure;
pub use failure::{valid_offline_name, AccountFailure};
use failure::classify;
pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login};
impl super::CoreHost {
/// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。
pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) {
self.send(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new(
code, subject, detail,
))));
}
/// 账户写操作失败:**原文进日志**,页面只拿结构化码 + 主题名。
pub(super) fn notice_failure(&mut self, failure: AccountFailure, subject: &str) {
eprintln!(
"[accounts] 操作失败({},subject={subject}):{}",
failure.code.code(),
failure.detail
);
self.notice(failure.code, subject, &failure.detail);
}
}
/// 账户列表(公开字段)。
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
let store = AccountStore::open(env.layout.auth_data());
@@ -85,18 +113,25 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
///
/// # Errors
///
/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
/// 名字不合法、同名冲突、落盘失败时返回 [`AccountFailure`](带结构化码)。
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, AccountFailure> {
let name = name.trim();
let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?;
if !valid_offline_name(name) {
return Err(AccountFailure {
code: AccountNoticeCode::OfflineNameInvalid,
detail: format!("离线名字必须是 3–16 位字母/数字/下划线,收到 {name:?}"),
});
}
let meta = offline_meta(name, now_ms())
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::OfflineNameInvalid))?;
let display = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Offline)
.map_err(|error| format!("添加账户失败:{error}"))?;
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::AccountExists))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
Ok(display)
}
@@ -104,15 +139,15 @@ pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
///
/// # Errors
///
/// 找不到、落盘失败时返回用户可读文案。
pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
/// 找不到、落盘失败时返回 [`AccountFailure`]。
pub fn remove(env: &HostEnv, key: &str) -> Result<String, AccountFailure> {
let mut store = AccountStore::open(env.layout.auth_data());
let meta = store
.remove(key)
.map_err(|error| format!("删除账户失败:{error}"))?;
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::NotFound))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
Ok(meta.display_name().to_string())
}
@@ -147,7 +182,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, message),
Err(message) => return fail(&tx, LoginFailure::Other, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| {
@@ -170,7 +205,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, message),
Err(message) => return fail(&tx, LoginFailure::Other, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.authcode_login(|prompt| {
@@ -197,8 +232,13 @@ fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
.map_err(|error| error.to_string())
}
fn fail(tx: &JobSender, message: String) {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。
fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into<String>) {
let detail = detail.into();
eprintln!("[auth] 微软登录失败({}):{detail}", failure.code());
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as(
failure, detail,
))));
}
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
@@ -213,9 +253,12 @@ fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<Micr
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
}
Err(message) => fail(tx, message),
Err(message) => fail(tx, LoginFailure::StoreFailed, message),
},
Err(error) => fail(tx, error.to_string()),
Err(error) => {
let failure = classify(&error);
fail(tx, failure, error.to_string());
}
}
}
@@ -239,6 +282,9 @@ fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, Str
#[cfg(test)]
mod tests {
use super::*;
use unlucky_core::auth::AuthError;
use super::failure::mentions_whitelist;
#[test]
fn time_display_matches_known_timestamps() {
@@ -271,6 +317,57 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
#[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(&AuthError::Canceled), LoginFailure::Other);
assert!(mentions_whitelist("AADSTS7000218"));
assert!(!mentions_whitelist("invalid_grant"));
}
#[test]
fn offline_name_rule_matches_the_ui_contract() {
assert!(valid_offline_name("Steve"));
assert!(valid_offline_name("a_1"));
assert!(!valid_offline_name("ab"), "太短");
assert!(!valid_offline_name("中文名字"), "非 ascii");
assert!(!valid_offline_name("has space"));
assert!(!valid_offline_name("x"));
}
#[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 _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
let failure = add_offline(&env, "ab").expect_err("太短要报错");
assert_eq!(failure.code, AccountNoticeCode::OfflineNameInvalid);
assert!(!failure.detail.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn login_job_cancel_flag_is_shared() {
let job = LoginJob::new();
+83 -131
View File
@@ -22,42 +22,81 @@ use unlucky_core::auth::microsoft::now_ms;
use unlucky_core::auth::{AccountStore, Secret};
use unlucky_core::download::blocking::block_on;
use crate::dto::{AuthlibLoginView, AuthlibRequest, SkinImage, REDACTED};
use crate::dto::{AuthlibLoginView, AuthlibRequest, LoginFailure, SkinImage};
mod util;
pub use util::scrub_known;
use util::{base64_decode, client_token, get_bytes, get_text};
use super::super::engine;
use super::super::env::HostEnv;
use super::super::job::{spawn, HostTask, JobSender};
use super::LoginJob;
/// 一次 GET 的超时上限(秒):服务器不可达时要能失败,而不是挂住 UI 的登录面板。
const HTTP_TIMEOUT_SECS: u64 = 15;
/// 校验认证服务器地址(「校验」按钮的后端)。
///
/// # Errors
///
/// 地址不合法、网络不可达、HTTP 非 2xx、响应不是 authlib-injector 的服务发现 JSON 时,
/// 返回**具名**的用户可读文案(这是任务要求的「失败态 + 具名错误」)。
pub fn probe_authlib(env: &HostEnv, input: &str) -> Result<(String, AuthlibServerMeta), String> {
let api_root = normalize_api_root(input).map_err(|error| error.to_string())?;
let (status, text) = get_text(env, &api_root)?;
pub fn probe_authlib(env: &HostEnv, input: &str) -> Result<(String, AuthlibServerMeta), Failure> {
let api_root = normalize_api_root(input)
.map_err(|error| Failure::new(LoginFailure::Other, error.to_string()))?;
let (status, text) =
get_text(env, &api_root).map_err(|error| Failure::new(LoginFailure::Unreachable, error))?;
if !(200..300).contains(&status) {
return Err(format!(
"服务器返回 HTTP {status}({api_root} 不像可用的 authlib 认证服务器)"
return Err(Failure::new(
LoginFailure::NotAuthlibServer,
format!("服务器返回 HTTP {status}({api_root} 不像可用的 authlib 认证服务器)"),
));
}
let value: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
format!("响应不是 JSON:{api_root} —— 可能填成了网页地址,而不是 API 根(通常以 /api/yggdrasil/ 结尾)")
Failure::new(
LoginFailure::NotAuthlibServer,
format!("响应不是 JSON:{api_root} —— 可能填成了网页地址,而不是 API 根(通常以 /api/yggdrasil/ 结尾)"),
)
})?;
if value.get("meta").is_none() && value.get("skinDomains").is_none() {
return Err(
"响应里没有 meta / skinDomains:这看起来不是 authlib-injector 服务器".to_string(),
);
return Err(Failure::new(
LoginFailure::NotAuthlibServer,
"响应里没有 meta / skinDomains:这看起来不是 authlib-injector 服务器",
));
}
let meta = AuthlibServerMeta::from_value(&value);
Ok((api_root, meta))
}
/// 第三方轨的失败:**原因码 + 原文**(原文进日志,页面据码出 i18n)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
/// 原因码
pub code: LoginFailure,
/// 原始诊断(已脱敏)
pub detail: String,
}
impl Failure {
fn new(code: LoginFailure, detail: impl Into<String>) -> Self {
Self {
code,
detail: detail.into(),
}
}
/// `AuthError` → 原因码(只看变体)。
fn from_error(error: &unlucky_core::auth::AuthError) -> Self {
use unlucky_core::auth::AuthError;
let code = match error {
AuthError::Network { .. } | AuthError::Timeout { .. } => LoginFailure::Unreachable,
AuthError::Denied { .. } => LoginFailure::BadCredentials,
AuthError::Store { .. } => LoginFailure::StoreFailed,
_ => LoginFailure::Other,
};
Self::new(code, error.to_string())
}
}
/// 起一次第三方轨登录(后台线程)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob, request: AuthlibRequest) {
spawn("authlib-login", move || {
@@ -68,7 +107,7 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob, request: AuthlibR
let (api_root, meta) = match probe_authlib(&env, &request.server) {
Ok(found) => found,
Err(reason) => return fail(&tx, reason),
Err(failure) => return fail(&tx, failure),
};
let server_name = meta
.name
@@ -83,7 +122,7 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob, request: AuthlibR
let auth = match AuthlibAuth::new() {
Ok(auth) => auth,
Err(error) => return fail(&tx, error.to_string()),
Err(error) => return fail(&tx, Failure::new(LoginFailure::Other, error.to_string())),
};
let client_token = Secret::new(client_token());
let password_text = std::mem::take(&mut request.password);
@@ -108,11 +147,17 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob, request: AuthlibR
Some(account.as_str()),
)));
}
Err(reason) => fail(&tx, reason),
Err(failure) => fail(&tx, failure),
},
// 错误原文里可能有服务端回显的凭据:先按**形状**脱敏(`AuthlibLoginView::failed`),
// 再按**已知值**兜一遍(万一服务端把密码原样回显)。
Err(error) => fail(&tx, scrub_known(&error.to_string(), &password_text)),
Err(error) => {
let failure = Failure::from_error(&error);
fail(
&tx,
Failure::new(failure.code, scrub_known(&failure.detail, &password_text)),
);
}
}
drop(password_text);
});
@@ -134,7 +179,7 @@ pub fn spawn_probe(env: HostEnv, tx: JobSender, server: String) {
&api_root, &name,
))));
}
Err(reason) => fail(&tx, reason),
Err(failure) => fail(&tx, failure),
}
});
}
@@ -192,14 +237,12 @@ fn store(
session: AuthlibSession,
api_root: &str,
login_name: &str,
) -> Result<String, String> {
) -> Result<String, Failure> {
let profile = session
.selected_profile
.clone()
.filter(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
.ok_or_else(|| {
"服务器没有返回角色:这个账号可能还没在皮肤站里创建角色(建好后再试)".to_string()
})?;
.ok_or_else(|| Failure::new(LoginFailure::NoProfile, "服务器没有返回角色"))?;
let mut meta = AccountMeta::new(
CoreAccountKind::AuthlibInjector,
profile.id,
@@ -213,7 +256,7 @@ fn store(
account_store
.insert(meta, Credentials::AuthlibInjector(session))
.and_then(|()| account_store.save())
.map_err(|error| format!("账户入库失败:{error}"))?;
.map_err(|error| Failure::new(LoginFailure::StoreFailed, error.to_string()))?;
Ok(name)
}
@@ -225,112 +268,19 @@ fn stage(tx: &JobSender, server: &str, message: &str) {
}
/// 失败收尾:**先脱敏再上屏**(服务端错误原文里可能带 token)。
fn fail(tx: &JobSender, message: String) {
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::failed(
message,
/// 失败收尾:原文进日志,页面拿**原因码**(复审 r1 P1-1)。
fn fail(tx: &JobSender, failure: Failure) {
eprintln!(
"[auth] authlib 失败({}):{}",
failure.code.code(),
failure.detail
);
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::failed_as(
failure.code,
failure.detail,
))));
}
/// 启动器侧生成的 `clientToken`(Yggdrasil 要求刷新/校验都带,且服务端回传必须一致)。
fn client_token() -> String {
use std::hash::{BuildHasher, Hasher};
let mut out = String::with_capacity(32);
for salt in 0..2u64 {
// `RandomState` 每个实例自带随机种子(标准库给的是进程级随机),不引第三方随机库。
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(now_ms() ^ salt);
hasher.write_u32(std::process::id());
out.push_str(&format!("{:016x}", hasher.finish()));
}
out
}
/// GET 文本(状态码 + 正文)。
///
/// 客户端来自**既有的下载引擎**(`host::engine::engine`):代理/超时/证书栈与本仓的下载器
/// 完全同源,不在这里另起一套 HTTP 配置(本 crate 也没有 reqwest 依赖,拿到的只是它的 `&Client`)。
fn get_text(env: &HostEnv, url: &str) -> Result<(u16, String), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("服务器不可达:{error}"))?;
let status = reply.status().as_u16();
let text = reply
.text()
.await
.map_err(|error| format!("读取响应失败:{error}"))?;
Ok((status, text))
})
}
/// GET 字节(皮肤图片)。
fn get_bytes(env: &HostEnv, url: &str) -> Result<(u16, Vec<u8>), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("皮肤图片不可达:{error}"))?;
let status = reply.status().as_u16();
let bytes = reply
.bytes()
.await
.map_err(|error| format!("读取皮肤图片失败:{error}"))?
.to_vec();
Ok((status, bytes))
})
}
/// base64 解码(标准字母表,容忍 URL-safe 变体与缺失填充)。
///
/// 为什么自己写:textures 属性是 base64 编码的 JSON,而本 crate 不能引第三方解码库
/// (依赖最小化);这段是 40 行的确定性算法,配单测比拖一个依赖更划算。
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(input.len() / 4 * 3);
let mut buffer: u32 = 0;
let mut bits = 0u32;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b'\n' | b'\r' | b' ' => continue,
other => return Err(format!("base64 里有非法字符:{:?}", other as char)),
};
buffer = (buffer << 6) | u32::from(value);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or(0));
}
}
if out.is_empty() {
return Err("base64 内容为空".to_string());
}
Ok(out)
}
/// 错误文案里可能混进已知凭据值:按值再兜一遍(形状脱敏由 `AuthlibLoginView::failed` 做)。
#[must_use]
pub fn scrub_known(message: &str, secret: &str) -> String {
if secret.is_empty() {
return message.to_string();
}
message.replace(secret, REDACTED)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -361,6 +311,8 @@ mod tests {
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
use crate::dto::REDACTED;
#[test]
fn known_secret_is_scrubbed_from_error_text() {
let text = scrub_known("登录失败:hunter2 被拒绝", "hunter2");
@@ -376,10 +328,10 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
let error = probe_authlib(&env, " ").expect_err("空地址必须报错");
assert!(error.contains("不能为空"), "{error}");
let error = probe_authlib(&env, "https://").expect_err("只给协议头也必须报错");
assert!(!error.is_empty());
let failure = probe_authlib(&env, " ").expect_err("空地址必须报错");
assert!(failure.detail.contains("不能为空"), "{failure:?}");
let failure = probe_authlib(&env, "https://").expect_err("只给协议头也必须报错");
assert!(!failure.detail.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -0,0 +1,129 @@
//! authlib 轨的 HTTP 基座与纯函数小工具:`clientToken` 生成、GET 文本/字节、base64 解码、按已知值脱敏。
//!
//! 从 `authlib.rs` 拆出来守 400 行硬线。网络只走**既有下载引擎**的客户端
//! (`host::engine::engine(env).client()`):代理/超时/证书栈与下载器同源,本 crate 不新增依赖。
use unlucky_core::auth::microsoft::now_ms;
use unlucky_core::download::blocking::block_on;
use crate::dto::REDACTED;
use super::super::super::engine;
use super::super::super::env::HostEnv;
/// 一次 GET 的超时上限(秒):服务器不可达时要能失败,而不是挂住 UI 的登录面板。
pub(super) const HTTP_TIMEOUT_SECS: u64 = 15;
/// 单次 GET 的响应体上限(字节):用户能填任意 URL,服务发现 JSON/皮肤 PNG 都远小于它。
pub(super) const MAX_BODY_BYTES: usize = 512 * 1024;
/// base64 解码输出上限(textures 属性是个小 JSON)。
pub(super) const MAX_BASE64_BYTES: usize = 64 * 1024;
/// 启动器侧生成的 `clientToken`(Yggdrasil 要求刷新/校验都带,且服务端回传必须一致)。
pub(super) fn client_token() -> String {
use std::hash::{BuildHasher, Hasher};
let mut out = String::with_capacity(32);
for salt in 0..2u64 {
// `RandomState` 每个实例自带随机种子(标准库给的是进程级随机),不引第三方随机库。
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(now_ms() ^ salt);
hasher.write_u32(std::process::id());
out.push_str(&format!("{:016x}", hasher.finish()));
}
out
}
/// GET 文本(状态码 + 正文)。
///
/// 客户端来自**既有的下载引擎**(`host::engine::engine`):代理/超时/证书栈与本仓的下载器
/// 完全同源,不在这里另起一套 HTTP 配置(本 crate 也没有 reqwest 依赖,拿到的只是它的 `&Client`)。
pub(super) fn get_text(env: &HostEnv, url: &str) -> Result<(u16, String), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("服务器不可达:{error}"))?;
let status = reply.status().as_u16();
let text = reply
.text()
.await
.map_err(|error| format!("读取响应失败:{error}"))?;
Ok((status, text))
})
}
/// GET 字节(皮肤图片)。
pub(super) fn get_bytes(env: &HostEnv, url: &str) -> Result<(u16, Vec<u8>), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("皮肤图片不可达:{error}"))?;
let status = reply.status().as_u16();
let bytes = reply
.bytes()
.await
.map_err(|error| format!("读取皮肤图片失败:{error}"))?
.to_vec();
if bytes.len() > MAX_BODY_BYTES {
return Err(format!(
"图片过大({} 字节,上限 {MAX_BODY_BYTES})",
bytes.len()
));
}
Ok((status, bytes))
})
}
/// base64 解码(标准字母表,容忍 URL-safe 变体与缺失填充)。
///
/// 为什么自己写:textures 属性是 base64 编码的 JSON,而本 crate 不能引第三方解码库
/// (依赖最小化);这段是 40 行的确定性算法,配单测比拖一个依赖更划算。
pub(super) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(input.len() / 4 * 3);
let mut buffer: u32 = 0;
let mut bits = 0u32;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b'\n' | b'\r' | b' ' => continue,
other => return Err(format!("base64 里有非法字符:{:?}", other as char)),
};
buffer = (buffer << 6) | u32::from(value);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or(0));
}
if out.len() > MAX_BASE64_BYTES {
return Err(format!("base64 输出超过上限 {MAX_BASE64_BYTES} 字节"));
}
}
if out.is_empty() {
return Err("base64 内容为空".to_string());
}
Ok(out)
}
/// 错误文案里可能混进已知凭据值:按值再兜一遍(形状脱敏由 `AuthlibLoginView::failed` 做)。
#[must_use]
pub fn scrub_known(message: &str, secret: &str) -> String {
if secret.is_empty() {
return message.to_string();
}
message.replace(secret, REDACTED)
}
@@ -0,0 +1,76 @@
//! 账户写操作的失败分类 + 离线名字契约(M114 r2)。
//!
//! 分类**只看 `AuthError` 的变体与 ascii 码**,不看中文文案——文案随时会改,
//! 而页面据 `AccountNoticeCode` 选 i18n,两边必须能独立演进。
use unlucky_core::auth::AuthError;
use crate::dto::{AccountNoticeCode, LoginFailure};
/// 账户写操作的失败:**结构化码 + 原文**(原文只进日志/详细信息,不上屏)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountFailure {
/// 结果码(页面据此选 i18n)
pub code: AccountNoticeCode,
/// 原始诊断(已脱敏)
pub detail: String,
}
impl AccountFailure {
/// 按 core 的错误类型分类(不看中文文案,只看变体)。
pub(super) fn classify(error: AuthError, fallback: AccountNoticeCode) -> Self {
let code = match &error {
unlucky_core::auth::AuthError::Conflict { .. } => AccountNoticeCode::AccountExists,
unlucky_core::auth::AuthError::NotFound { .. } => AccountNoticeCode::NotFound,
unlucky_core::auth::AuthError::Malformed(_) => AccountNoticeCode::OfflineNameInvalid,
unlucky_core::auth::AuthError::Store { .. } => AccountNoticeCode::StoreFailed,
_ => fallback,
};
Self {
code,
detail: error.to_string(),
}
}
}
/// 离线名字的界面契约(3–16 位 ascii 字母/数字/下划线)。core 侧还有一道同名规则做兜底。
#[must_use]
pub fn valid_offline_name(name: &str) -> bool {
let len = name.chars().count();
(3..=16).contains(&len) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// `AuthError` → 原因码(**只看变体与 ascii 码,不看中文文案**)。
pub(super) fn classify(error: &AuthError) -> LoginFailure {
match error {
AuthError::Network { .. } | AuthError::Timeout { .. } => LoginFailure::Unreachable,
AuthError::Denied { code, description } => {
if mentions_whitelist(code) || mentions_whitelist(description) {
LoginFailure::NotWhitelisted
} else {
LoginFailure::Denied
}
}
AuthError::Response(detail) => {
let blob = format!("{detail:?}");
if mentions_whitelist(&blob) {
LoginFailure::NotWhitelisted
} else {
LoginFailure::Denied
}
}
AuthError::Xbox { .. } | AuthError::CredentialExpired => LoginFailure::Denied,
AuthError::Store { .. } => LoginFailure::StoreFailed,
_ => LoginFailure::Other,
}
}
/// 是否属于「应用注册未获批」这一类(AADSTS7000218 / consent required / whitelist)。
pub(super) fn mentions_whitelist(text: &str) -> bool {
let lowered = text.to_lowercase();
lowered.contains("aadsts7000218")
|| lowered.contains("aadsts65001")
|| lowered.contains("consent")
|| lowered.contains("whitelist")
|| lowered.contains("not approved")
}
+24 -6
View File
@@ -2,7 +2,9 @@
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind, PackEvent};
use crate::dto::{
AccountNoticeCode, AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind, PackEvent,
};
use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
@@ -107,27 +109,43 @@ impl CoreHost {
}
Err(reason) => self.toast(ToastKind::Warn, reason),
},
// 账户增删切:结果走**页面横幅**(`AccountNoticeChanged`),不发 toast——
// app-iced 不渲染 toast,发出去等于用户什么都看不到(复审 r1 P2-2)。
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
Ok(display) => {
self.toast(ToastKind::Success, format!("已添加离线账户 {display}"));
self.notice(AccountNoticeCode::Added, &display, "");
self.refresh_accounts();
}
Err(failure) => {
self.notice_failure(failure, &name);
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
Ok(display) => {
if self.active_account.as_deref() == Some(key.as_str()) {
self.active_account = None;
}
self.toast(ToastKind::Info, format!("已删除账户 {display}"));
self.notice(AccountNoticeCode::Removed, &display, "");
self.refresh_accounts();
}
Err(failure) => {
self.notice_failure(failure, &key);
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::SwitchAccount(id) => {
let name = self
.accounts
.iter()
.find(|account| account.id == id)
.map_or_else(|| id.clone(), |account| account.name.clone());
self.active_account = Some(id);
self.refresh_accounts();
self.toast(ToastKind::Info, "已切换启动账户");
self.notice(AccountNoticeCode::Switched, &name, "");
}
AppCommand::DismissAccountNotice => {
self.send(CoreEvent::AccountNoticeChanged(None));
}
AppCommand::StartDeviceLogin => {
self.login = Some(LoginJob::new());
+5
View File
@@ -80,6 +80,11 @@ impl MockBackend {
if let Some(view) = accounts_screen::device_login(&scenario) {
self.emit(AppEvent::Core(CoreEvent::DeviceLoginChanged(view)));
}
if let Some(notice) = accounts_screen::notice(&scenario) {
self.emit(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
notice,
))));
}
}
fn emit(&mut self, event: AppEvent) {
+19 -2
View File
@@ -11,7 +11,9 @@
//! - **不造皮肤字节**:第三方轨的真图来自服务器,mock 里没有(伪造一张「假皮肤」正好违反
//! M114 的口径)。所以截图里皮肤区显示的是首字母块 + 「没有可用皮肤」。
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use crate::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, DeviceLoginView, LoginFailure,
};
/// 场景名(`ULL_ACCOUNT_STATE`;未知值按 `list` 处理)。
pub(super) fn scenario() -> String {
@@ -69,13 +71,28 @@ pub(super) fn device_login(scenario: &str) -> Option<DeviceLoginView> {
900,
"",
)),
"failed" => Some(DeviceLoginView::failed(
// 带**原因码**的失败:原文(中文诊断)留在 detail 里只进日志,页面画 i18n + Code
"failed" => Some(DeviceLoginView::failed_as(
LoginFailure::NotWhitelisted,
"微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截",
)),
_ => None,
}
}
/// 场景对应的横幅(`notice` = 离线名字不合法那条;`added` = 添加成功的正向样例)。
pub(super) fn notice(scenario: &str) -> Option<AccountNotice> {
match scenario {
"notice" => Some(AccountNotice::new(
AccountNoticeCode::OfflineNameInvalid,
"ab",
"离线名字必须是 3–16 位字母/数字/下划线",
)),
"added" => Some(AccountNotice::new(AccountNoticeCode::Added, "Alex", "")),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
fn account(
id: &str,
+4
View File
@@ -92,6 +92,8 @@ pub struct AppState {
pub authlib_login: AuthlibLoginView,
/// 设备码等待态的开始时刻(倒计时;`None` = 没有在等)
pub device_login_started: Option<std::time::Instant>,
/// 账户页横幅(一次性操作结果;页面渲染后由用户关掉)
pub account_notice: Option<crate::dto::AccountNotice>,
/// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
@@ -155,6 +157,7 @@ impl AppState {
device_login: DeviceLoginView::new(),
authlib_login: AuthlibLoginView::new(),
device_login_started: None,
account_notice: None,
java: JavaPageView::new(),
install: None,
install_cancelled: 0,
@@ -254,6 +257,7 @@ impl AppState {
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => self.apply_device_login(view),
CoreEvent::AuthlibLoginChanged(view) => self.apply_authlib_login(view),
CoreEvent::AccountNoticeChanged(notice) => self.account_notice = notice,
CoreEvent::JavaChanged(page) => self.java = page,
CoreEvent::InstallProgress(progress) => match progress {
Some(progress) => {
+3 -14
View File
@@ -6,7 +6,6 @@
use std::time::Instant;
use crate::dto::{AccountView, AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, REDACTED};
use crate::toast::ToastKind;
use super::AppState;
@@ -19,14 +18,13 @@ impl AppState {
pub fn apply_device_login(&mut self, mut view: DeviceLoginView) {
view.message = crate::dto::redact(&view.message);
view.account_name = crate::dto::redact(&view.account_name);
// 这里**不发 toast**:presenter 是共享宿主层,写什么语言都不对(复审 r1 P1-1)。
// 成败反馈由页面自己画(登录面板的 Succeeded/Failed 态 + 账户页横幅),文案走 i18n。
match view.phase {
DeviceLoginPhase::Succeeded => {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
self.device_login_started = None;
}
DeviceLoginPhase::Failed => {
self.toasts.push(ToastKind::Error, view.message.clone());
self.device_login_started = None;
}
DeviceLoginPhase::Waiting => {
@@ -48,16 +46,7 @@ impl AppState {
pub fn apply_authlib_login(&mut self, mut view: AuthlibLoginView) {
view.message = crate::dto::redact(&view.message);
view.server_name = crate::dto::redact(&view.server_name);
match view.phase {
DeviceLoginPhase::Succeeded => {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
DeviceLoginPhase::Failed => {
self.toasts.push(ToastKind::Error, view.message.clone());
}
_ => {}
}
// 同 `apply_device_login`:不发 toast,成败由页面按 i18n 画。
self.authlib_login = view;
}