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:
1 parent
1ff00bbe50
commit
8d8552113a
31 files changed
+1540
-497
No files matched your search
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 里的键)
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user