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::{
|
||||
|
||||
Reference in new issue
Block a user