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

+86 -26
View File
@@ -7,24 +7,38 @@
//! 2. **密码只走一次**:`Submit` 把 `AuthlibRequest` 交给后端后立刻清掉表单里的密码
//! (成功失败都清——重试用新输入,不在内存里多留一份凭据);
//! 3. **表单态是纯 UI 状态**:输入框改动只写 `App::account_form` + 置脏,不派命令。
//!
//! ## 返回 `Task`(M114 r2 / 复审 P2-1)
//!
//! 复制用户码/链接必须把 `iced::clipboard::write` 的 `Task` **交回 runtime**——iced 0.14 的
//! `Task` 是惰性 stream,丢掉返回值等于永不执行(日志页的 Copy summary 就是正确写法)。
use unlucky_presenter::dto::{AppCommand, AuthlibRequest};
use iced::Task;
use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest};
use crate::pages::account::{Message as Account, Panel, Track};
use crate::app::Message;
use crate::pages::account::{Hint, Message as Account, Panel, Track};
use super::super::state::App;
impl App {
/// 账户页消息。
pub(super) fn on_account(&mut self, message: Account) {
pub(super) fn on_account(&mut self, message: Account) -> Task<Message> {
// 复制是唯一需要**交回 Task** 的分支:先把它的 Task 抓出来,其余分支照旧改状态
let clipboard = match &message {
Account::Copy(text) => Some(text.clone()),
_ => None,
};
match message {
Account::Open(track) => {
self.account_form.open(track);
// 微软面板一打开就把「设备码」这条路准备好:用户点开就该看到码或选择按钮,
// 而不是再点一次「登录」(面板里的两条流按钮仍然可选)
if track == Track::Microsoft && self.state.device_login.phase.visible() {
self.account_form.panel = Panel::Microsoft;
}
self.account_form.hint = None;
self.view_dirty = true;
}
Account::ToggleAdd => {
self.account_form.section_open = !self.account_form.section_open;
self.account_form.hint = None;
self.view_dirty = true;
}
Account::Close => {
@@ -35,6 +49,7 @@ impl App {
}
self.view_dirty = true;
}
Account::DismissNotice => self.dispatch(AppCommand::DismissAccountNotice),
Account::Server(value) => {
self.account_form.server = value;
self.view_dirty = true;
@@ -59,15 +74,14 @@ impl App {
}
Account::Submit => match self.account_form.panel {
Panel::Offline => {
// 名字**不清空**:失败时用户还要看见自己输入的(复审 r1 P2-2 的另一半)
let name = self.account_form.offline_name.trim().to_string();
if !name.is_empty() {
self.account_form.offline_name.clear();
self.dispatch(AppCommand::AddOfflineAccount(name));
}
}
Panel::Authlib => {
let ready = self.account_form.authlib_ready();
if ready {
if self.account_form.authlib_ready() {
let request = AuthlibRequest::new(
self.account_form.server.clone(),
self.account_form.login.clone(),
@@ -76,7 +90,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request));
}
}
Panel::Microsoft => self.on_account(Account::StartBrowser),
Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::None => {}
},
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -85,26 +99,62 @@ impl App {
self.account_form.close();
self.dispatch(AppCommand::CancelDeviceLogin);
}
Account::Copy(text) => {
// 剪贴板只放用户码/链接这类**公开**内容(凭据永不经过这条路径)
let _: iced::Task<crate::app::Message> = iced::clipboard::write(text);
}
Account::Copy(_) => {}
Account::Switch(id) => self.dispatch(AppCommand::SwitchAccount(id)),
Account::Remove(id) => self.dispatch(AppCommand::RemoveAccount(id)),
Account::Relogin => {
// 「重新登录」按微软轨的默认流走(授权码 + 系统浏览器)
Account::Relogin(id) => return self.relogin(&id),
}
self.refresh_view();
match clipboard {
Some(text) => iced::clipboard::write(text),
None => Task::none(),
}
}
/// 「重新登录」按**账户类型**分派(复审 r1 P2-3)。
///
/// - 微软轨:走授权码流(原路);
/// - 第三方轨:第三方站没有「一键重登」——预填服务器地址与登录名,让用户补密码;
/// - 本地档案:没有服务器账户可重登 ⇒ 明确说明,不假装能做。
fn relogin(&mut self, id: &str) -> Task<Message> {
let Some(account) = self
.state
.accounts
.iter()
.find(|account| account.id == id)
.cloned()
else {
return Task::none();
};
self.account_form.hint = None;
match account.kind {
AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin);
}
AccountKind::Authlib => {
self.account_form.open(Track::Authlib);
self.account_form.server = account.server_url.clone();
self.account_form.login = account.login_name.clone();
self.account_form.password.clear();
self.account_form.hint = Some(Hint::AuthlibReauth);
self.view_dirty = true;
self.refresh_view();
}
AccountKind::Offline => {
self.account_form.hint = Some(Hint::OfflineNoRelogin);
self.view_dirty = true;
self.refresh_view();
}
}
self.refresh_view();
Task::none()
}
}
/// 取证开关 `ULL_ACCOUNT_STATE` 的落地:把「很难自然等到」的状态钉死一次。
/// 取证开关 `ULL_ACCOUNT_STATE` 的落地(**只在 `--screenshot` 下生效**)。
///
/// 为什么需要:设备码过期要等 15 分钟、第三方站表单要手点两下,截图通道等不到。这里只在
/// **截图模式**下生效(`--screenshot`),交互模式一律不读——与 `ULL_START_ROUTE` 同一口径。
/// 为什么需要:设备码过期要等 15 分钟、表单要手点两下,截图通道等不到自然发生的状态。
pub(super) fn force_account_state(app: &mut App) {
if !app.has_screenshot() {
return;
@@ -118,7 +168,7 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
}
// 第三方站表单:直接把面板打开并预填地址(截图要看到输入框与校验按钮)
// 第三方站表单:直接把面板打开并预填地址
"authlib" | "authlib-form" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
if app.account_form.server.trim().is_empty() {
@@ -127,13 +177,17 @@ pub(super) fn force_account_state(app: &mut App) {
app.account_form.login = "steve@example.com".to_string();
app.account_form.password = "not-a-real-password".to_string();
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => {
app.account_form.section_open = true;
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use crate::pages::account::{Message as Account, Panel, Track};
use crate::pages::account::{Form, Panel, Track};
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
///
@@ -141,7 +195,6 @@ mod tests {
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
#[test]
fn the_form_state_follows_the_page_messages() {
use crate::pages::account::Form;
let mut form = Form::new();
assert_eq!(form.panel, Panel::None);
form.open(Track::Authlib);
@@ -150,7 +203,14 @@ mod tests {
form.close();
assert_eq!(form.panel, Panel::None);
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
}
let _ = Account::Open(Track::Offline);
/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。
#[test]
fn the_add_section_starts_collapsed() {
let mut form = Form::new();
assert!(!form.section_open);
form.section_open = true;
assert!(form.section_open);
}
}
@@ -89,7 +89,7 @@ impl App {
self.refresh_view();
}
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => self.on_account(message),
pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
+15 -31
View File
@@ -18,6 +18,7 @@ use super::message::{nav_item_of, Message};
use super::state::{log_first_frame, App};
use super::APP_TITLE;
mod account_page;
mod logs;
mod rail;
@@ -58,10 +59,12 @@ impl App {
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_background(
Some(_) => shell::view_with_account(
&self.ui,
nav_item_of(self.state.route()),
&account,
shell::NavRail {
selected: nav_item_of(self.state.route()),
account: account.clone(),
},
APP_TITLE,
background,
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
@@ -83,36 +86,17 @@ impl App {
self.lang(),
),
// 账户页(M114):它不走 `pages::Screen` 那四页的 `Model`,自己一棵树
// (模型在 `App::account`,由 `refresh_view` 按同一条门控重建;宽度用 M104 的
// 中栏可用宽,与其它页同源)。
None if self.state.route() == &Route::Accounts => shell::view_with_background(
&self.ui,
NavItem::Account,
&account,
APP_TITLE,
background,
pages::account::view(
&self.ui,
pages::layout::frame_metrics(
pages::layout::PaneKit::Single,
self.account.width,
),
&self.account,
self.lang(),
)
.map(Message::Page),
agent::docked(&self.ui, &self.agent, self.width)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
// M106:导航轨标签按当前界面语言取词。
self.lang(),
),
// (树在 `app/view/account_page.rs`,与其它页共用 M104 的中栏可用宽)。
None if self.state.route() == &Route::Accounts => {
self.account_page(&account, background, overlay)
}
// `Route::Agent`:独立页与右栏共用同一个 `conversation::view`(`agent::page` 内部调的它)。
None => shell::view_with_background(
None => shell::view_with_account(
&self.ui,
NavItem::Ai,
&account,
shell::NavRail {
selected: NavItem::Ai,
account: account.clone(),
},
APP_TITLE,
background,
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
@@ -0,0 +1,45 @@
//! 账户页(M114)的**整页装配**:从 `App` 的状态拼出内容树,再交给外壳。
//!
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线(M104 的右栏分支已经把余量吃掉了),
//! 而这段是纯装配(没有状态数学),搬出来不改变任何语义。
//!
//! 宽度口径:用 `AccountPage::width`(= M104 的 `AgentUi::content_width`,中栏可用宽),
//! 与其它页、与 `refresh_view` 同一份来源——右栏展开时账户页**一起收缩**,不会顶到面板底下。
use iced::Element;
use crate::app::Message;
use crate::pages;
use crate::shell::{self, agent, NavItem, RailAccount};
use super::super::state::App;
use super::super::APP_TITLE;
impl App {
/// 账户页(走 `pages::account` 自己的树,而不是 `pages::Screen` 那四页的 `Model`)。
pub(super) fn account_page<'a>(
&'a self,
account: &RailAccount,
background: Option<Element<'a, Message>>,
overlay: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let metrics =
pages::layout::frame_metrics(pages::layout::PaneKit::Single, self.account.width);
shell::view_with_account(
&self.ui,
shell::NavRail {
selected: NavItem::Account,
account: account.clone(),
},
APP_TITLE,
background,
// M106:账户页的文案跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。
pages::account::view(&self.ui, metrics, &self.account, self.lang()).map(Message::Page),
agent::docked(&self.ui, &self.agent, self.width)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
self.lang(),
)
}
}
+31 -8
View File
@@ -38,10 +38,10 @@ pub mod widgets;
#[cfg(test)]
mod tests;
pub use model::{Form, Page, Panel, Track};
pub use model::{Form, Hint, Notice, Page, Panel, Track};
use iced::widget::{column, scrollable};
use iced::{Element, Length};
use iced::widget::{column, container, scrollable};
use iced::{Element, Length, Padding};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Key, Lang};
@@ -84,8 +84,12 @@ pub enum Message {
Switch(String),
/// 删除账户
Remove(String),
/// 重新登录(微软轨 → 授权码流)
Relogin,
/// 重新登录(按账户类型分派:微软 → 授权码流;第三方 → 预填表单;离线 → 明确说明)
Relogin(String),
/// 展开/收起「Add an account」的三张轨道卡(复审 r1 P2-4)
ToggleAdd,
/// 关掉横幅
DismissNotice,
}
impl Message {
@@ -109,7 +113,7 @@ pub fn view<'a>(
.subtitle(subtitle)
.action(buttons::tonal(
ui,
Button::new(add, Message::Open(Track::Microsoft).into_page()),
Button::new(add, Message::ToggleAdd.into_page()),
));
let top = if page.rows.is_empty() {
@@ -131,17 +135,36 @@ pub fn view<'a>(
};
let mut content = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
if let Some(notice) = &page.notice {
content = content.push(widgets::banner(ui, notice, text));
}
if let Some(hint) = page.hint {
content = content.push(widgets::hint(ui, hint, page, text));
}
// 三张轨道卡:空态**总是**画(那就是空态的入口);有账号时收在「Add an account」后面
let show_tracks = page.panel == Panel::None && (page.rows.is_empty() || page.form.section_open);
content = if page.panel == Panel::None {
content.push(top).push(flows)
if show_tracks {
content.push(top).push(flows)
} else {
content.push(top)
}
} else {
content.push(flows).push(top)
};
// 右缘留 8px gutter:全局 `frame::scroll_style` 的滚动条是 6px + 2px 内缩,
// 满宽卡片会被它压住(tower 看图意见 ②)。页面级留白是这条的最小修法。
let padded = container(content).width(Length::Fill).padding(Padding {
right: ui.space(Space::X2),
..Padding::default()
});
frame::page(
ui,
metrics,
header,
scrollable(content)
scrollable(padded)
.style(frame::scroll_style(ui))
.width(Length::Fill)
.height(Length::Fill),
+41 -17
View File
@@ -83,24 +83,9 @@ fn decode(skin: &SkinImage) -> Avatar {
let Some(decoded) = decode_png(&skin.png) else {
return Avatar::default();
};
let (width, height) = (decoded.width(), decoded.height());
// 旧版皮肤是 64×32:没有帽子层,只有脸
if width < FACE.0 + FACE_EDGE || height < FACE.1 + FACE_EDGE {
let Some(scaled) = compose_head(&decoded) else {
return Avatar::default();
}
let mut face =
::image::imageops::crop_imm(&decoded, FACE.0, FACE.1, FACE_EDGE, FACE_EDGE).to_image();
if width >= HAT.0 + FACE_EDGE && height >= HAT.1 + FACE_EDGE {
let hat =
::image::imageops::crop_imm(&decoded, HAT.0, HAT.1, FACE_EDGE, FACE_EDGE).to_image();
::image::imageops::overlay(&mut face, &hat, 0, 0);
}
let scaled = ::image::imageops::resize(
&face,
HEAD_PX,
HEAD_PX,
::image::imageops::FilterType::Nearest,
);
};
Avatar {
head: Some(image::Handle::from_rgba(
HEAD_PX,
@@ -112,6 +97,29 @@ fn decode(skin: &SkinImage) -> Avatar {
}
}
/// 裁 8×8 脸 + 叠 (40,8) 的帽子层 + 放大到 64×64(纯函数,单测直接验像素)。
///
/// 旧版 64×32 皮肤没有帽子层(只有脸),所以尺寸不够时只裁脸。
fn compose_head(decoded: &::image::RgbaImage) -> Option<::image::RgbaImage> {
let (width, height) = (decoded.width(), decoded.height());
if width < FACE.0 + FACE_EDGE || height < FACE.1 + FACE_EDGE {
return None;
}
let mut face =
::image::imageops::crop_imm(decoded, FACE.0, FACE.1, FACE_EDGE, FACE_EDGE).to_image();
if width >= HAT.0 + FACE_EDGE && height >= HAT.1 + FACE_EDGE {
let hat =
::image::imageops::crop_imm(decoded, HAT.0, HAT.1, FACE_EDGE, FACE_EDGE).to_image();
::image::imageops::overlay(&mut face, &hat, 0, 0);
}
Some(::image::imageops::resize(
&face,
HEAD_PX,
HEAD_PX,
::image::imageops::FilterType::Nearest,
))
}
/// 解 PNG(尺寸上限与背景图那条守门同源:皮肤不该有超大图)。
fn decode_png(bytes: &[u8]) -> Option<::image::RgbaImage> {
const MAX_EDGE: u32 = 512;
@@ -164,6 +172,11 @@ mod tests {
png_of(&skin)
}
/// **帽子层真的叠进头像了**(复审 r1 P3-4:原用例只断言 `head.is_some()`,
/// 把 overlay 删掉也不会红)。
///
/// 夹具:脸是纯红 (200,10,10,255),帽子层是半透明蓝 (10,10,200,120)。
/// 叠上去之后头像像素必须**既不等于纯红、也不等于纯蓝**——那就是混合的结果。
#[test]
fn a_real_skin_becomes_a_head_with_the_hat_layer() {
let avatar = decode(&SkinImage {
@@ -172,6 +185,17 @@ mod tests {
});
assert!(avatar.head.is_some(), "64×64 皮肤必须裁出头像");
assert!(avatar.skin.is_some(), "皮肤预览要有原图");
let decoded = decode_png(&skin_bytes()).expect("解码夹具");
let head = compose_head(&decoded).expect("裁头");
let pixel = head.get_pixel(0, 0).0;
assert_ne!(pixel, [200, 10, 10, 255], "帽子层没叠上去(还是纯红)");
assert_ne!(
pixel,
[10, 10, 200, 120],
"帽子层把脸整个盖掉了(不是 blend)"
);
assert!(pixel[2] > 10, "蓝色分量必须被帽子层抬高:{pixel:?}");
}
#[test]
+29 -33
View File
@@ -25,22 +25,13 @@ use crate::pages::Message as PageMessage;
use super::{login, widgets, Message as Account};
/// 空态:不是一张白页——标题 + 说明 + **三条轨道的直达入口**。
/// 空态:不是一张白页——图标块 + 标题 + 说明,下面紧跟**三张轨道卡**(真正的入口)。
///
/// 复审 r1 的 P2-4:空态原先有**三套**同一件事的入口(右上按钮 / 三个 chip / 三张卡),
/// chip 还被当成操作按钮用。现在只剩「右上 Add an account(展开/收起)+ 三张卡」这一套:
/// 空态时卡片本来就可见,有账号时收在按钮后面。
pub fn empty<'a>(ui: &'a Ui, catalog: Catalog, lang: Lang) -> Element<'a, PageMessage> {
let mut actions = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for track in Track::ALL {
// 三个按钮各用**轨道的名字**做标签(三条都叫「登录」的话,用户分不清点哪个)
actions = actions.push(buttons::tonal(
ui,
Button::new(
catalog.text(track.title_key()),
Account::Open(track).into_page(),
),
));
}
let _ = lang;
let state = empty_state(
ui,
EmptyState::new(
@@ -49,17 +40,10 @@ pub fn empty<'a>(ui: &'a Ui, catalog: Catalog, lang: Lang) -> Element<'a, PageMe
catalog.text(Key::AccountEmptyBody),
),
);
let _ = lang;
column![
container(state).height(Length::Fixed(ui.space(Space::X8) * 8.0)),
container(actions)
.width(Length::Fill)
.align_x(Alignment::Center)
.center_x(Length::Fill),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.into()
container(state)
.height(Length::Fixed(ui.space(Space::X8) * 8.0))
.width(Length::Fill)
.into()
}
/// 三条轨道的入口卡(面板没打开时画这一排)。
@@ -288,13 +272,25 @@ fn status_line<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a,
catalog.render(Key::AccountServerOk, &[("server", server_name.as_str())]),
ui.color(Role::Primary),
),
AuthlibPanel::Failed { message } => frame::text_role(
ui,
TypeRole::LabelMedium,
false,
message,
ui.color(Role::Error),
),
// 失败只画**原因码对应的 i18n** + 一行 Code(原文是中文诊断串,上屏就是语言泄漏)
AuthlibPanel::Failed { failure, .. } => column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(super::login::failure_body_key(*failure)),
ui.color(Role::Error),
),
frame::text_role_owned(
ui,
TypeRole::LabelSmall,
false,
catalog.render(Key::AccountFailureCode, &[("code", failure.code())]),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X1))
.into(),
AuthlibPanel::Succeeded { name } => frame::text_role(
ui,
TypeRole::LabelMedium,
+57 -54
View File
@@ -6,14 +6,14 @@
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use iced::widget::{column, row};
use iced::{Alignment, Element};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::LoginFailure;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
@@ -46,7 +46,7 @@ pub fn panel<'a>(
expired,
..
} => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { message } => failed(ui, message, catalog),
DevicePanel::Failed { failure, .. } => failed(ui, *failure, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
}
}
@@ -82,14 +82,16 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body)
}
/// 「正在申请/正在校验」:一句状态 + 取消。
fn notice<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
/// 「正在申请/正在校验」:一句**i18n**状态 + 取消。
///
/// 不复读 presenter 的 `message`:那是后端诊断串(中文),上屏就是语言泄漏(复审 r1 P1-1)。
fn notice<'a>(ui: &'a Ui, _message: &str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
message,
catalog.text(Key::AccountDeviceRequesting),
ui.color(Role::OnSurfaceVariant),
),
row![buttons::text(
@@ -261,8 +263,12 @@ fn countdown_line<'a>(
);
}
let text = remaining.map_or_else(String::new, |secs| {
catalog.render(Key::AccountDeviceCountdown, &[("secs", &secs.to_string())])
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
@@ -272,56 +278,39 @@ fn countdown_line<'a>(
)
}
/// 失败态(`ErrorContainer` 面板:不是一句灰字)。
fn failed<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
///
/// 两条修订叠在这里:
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小)。
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
let body = catalog.text(failure_body_key(failure)).to_string();
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let actions = vec![
buttons::tonal(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::AccountLoginFailed),
ui.color(Role::OnErrorContainer),
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
),
),
frame::text_role(
buttons::text(
ui,
TypeRole::BodyMedium,
false,
message,
ui.color(Role::OnErrorContainer),
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page(),
),
),
row![
buttons::tonal(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page()
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3));
container(body)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Medium),
..Border::default()
},
..container::Style::default()
})
.into()
];
super::widgets::alert(
ui,
super::widgets::Tone::Error,
catalog.text(Key::AccountLoginFailed),
body,
Some(code),
actions,
)
}
/// 成功态。
@@ -347,3 +336,17 @@ fn succeeded<'a>(ui: &'a Ui, name: &'a str, catalog: Catalog) -> Element<'a, Pag
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 原因码 → i18n 正文 key(页面里唯一的「码→文案」映射点)。
pub(super) fn failure_body_key(failure: LoginFailure) -> Key {
match failure {
LoginFailure::Denied => Key::AccountFailureDenied,
LoginFailure::NotWhitelisted => Key::AccountFailureWhitelist,
LoginFailure::Unreachable => Key::AccountFailureUnreachable,
LoginFailure::NotAuthlibServer => Key::AccountFailureNotAuthlib,
LoginFailure::BadCredentials => Key::AccountFailureBadCredentials,
LoginFailure::NoProfile => Key::AccountFailureNoProfile,
LoginFailure::StoreFailed => Key::AccountFailureStore,
LoginFailure::Other => Key::AccountFailureOther,
}
}
+60 -3
View File
@@ -7,7 +7,7 @@
use iced::widget::image;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountView};
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache};
@@ -79,7 +79,23 @@ pub enum Panel {
///
/// 密码只存在这里和「一次 `AppCommand::AddAuthlibAccount` 的载荷」里:不进日志、不进快照、
/// 不进任何回程 DTO(M114 凭据红线)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// **手写 `Debug`**:`Form` 里持着一次性密码,任何 `{:?}` 都不许带出明文(凭据红线)。
impl std::fmt::Debug for Form {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Form")
.field("panel", &self.panel)
.field("server", &self.server)
.field("login", &self.login)
.field("password", &"⟪redacted⟫")
.field("offline_name", &self.offline_name)
.field("section_open", &self.section_open)
.field("hint", &self.hint)
.finish()
}
}
/// 三条轨道的表单输入(纯 UI 状态;密码是**一次性请求值**)。
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Form {
/// 当前面板
pub panel: Panel,
@@ -91,6 +107,19 @@ pub struct Form {
pub password: String,
/// 本地档案名
pub offline_name: String,
/// 「Add an account」展开态(有账号时三张轨道卡默认收起,复审 r1 P2-4)
pub section_open: bool,
/// 一次性引导(重新登录时告诉用户下一步;`None` = 没有引导)
pub hint: Option<Hint>,
}
/// 一次性引导(由 app 层在「重新登录」时设置,页面画一行 i18n)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hint {
/// 第三方轨重登:重新输入密码
AuthlibReauth,
/// 本地档案没有可重登的服务器账户
OfflineNoRelogin,
}
impl Form {
@@ -111,6 +140,7 @@ impl Form {
/// 关掉面板(**密码一起清掉**:留着没有意义,只是多留一份凭据在内存里)。
pub fn close(&mut self) {
self.password.clear();
self.hint = None;
self.panel = Panel::None;
}
@@ -179,10 +209,35 @@ pub struct Page {
/// 版面宽度(**中栏可用宽**,M104 的 `AgentUi::content_width`;账户页自己的
/// `layout::frame_metrics` 拿它算列宽,与其它页同源)
pub width: f32,
/// 账户页横幅(添加/删除/切换的结果;`None` = 不画)
pub notice: Option<Notice>,
/// 一次性引导(重新登录的下一步)
pub hint: Option<Hint>,
}
/// 横幅的渲染数据(文案在视图层按 `code` 查 i18n)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
/// 结果码
pub code: AccountNoticeCode,
/// 主题(账户名/服务器地址)
pub subject: String,
/// 是否失败(决定配色)
pub failure: bool,
}
impl Notice {
fn of(notice: &AccountNotice) -> Self {
Self {
code: notice.code,
subject: notice.subject.clone(),
failure: notice.code.is_failure(),
}
}
}
impl Page {
/// 从状态与表单构造(`avatars` 是解码缓存,见 [`Cache]`;`width` 是中栏可用宽)。
/// 从状态与表单构造(`avatars` 是解码缓存,见 [`Cache`];`width` 是中栏可用宽)。
pub fn build(
state: &AppState,
form: &Form,
@@ -222,6 +277,8 @@ impl Page {
device,
authlib,
checked_server,
notice: state.account_notice.as_ref().map(Notice::of),
hint: form.hint,
width,
}
}
+16 -12
View File
@@ -3,7 +3,7 @@
//! 从 `model.rs` 拆出来守 400 行硬线:这些类型是「UI 要画什么」的枚举,
//! 判定逻辑(倒计时 → 已过期、服务发现 → 可用)也在这里,页面与表单只消费结果。
use unlucky_presenter::dto::{DeviceLoginPhase, DeviceLoginView};
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -39,10 +39,12 @@ pub enum DevicePanel {
/// 状态文案
message: String,
},
/// 失败(原文已脱敏)
/// 失败(**原因码上屏,原文只进日志**)
Failed {
/// 失败原因
message: String,
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
/// 原始诊断(不上屏)
detail: String,
},
/// 成功
Succeeded {
@@ -68,10 +70,12 @@ pub enum AuthlibPanel {
/// 状态文案
message: String,
},
/// 失败(原文已脱敏)
/// 失败(**原因码上屏,原文只进日志**)
Failed {
/// 失败原因
message: String,
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
/// 原始诊断(不上屏)
detail: String,
},
/// 成功
Succeeded {
@@ -107,7 +111,8 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
}
}
DeviceLoginPhase::Failed => DevicePanel::Failed {
message: view.message.clone(),
failure: view.failure.unwrap_or(LoginFailure::Other),
detail: view.message.clone(),
},
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(),
@@ -116,9 +121,7 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
pub(super) fn authlib_panel(
view: &unlucky_presenter::dto::AuthlibLoginView,
) -> (AuthlibPanel, String) {
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase {
DeviceLoginPhase::Idle => (AuthlibPanel::Idle, String::new()),
DeviceLoginPhase::Requesting => (
@@ -142,7 +145,8 @@ pub(super) fn authlib_panel(
),
DeviceLoginPhase::Failed => (
AuthlibPanel::Failed {
message: view.message.clone(),
failure: view.failure.unwrap_or(LoginFailure::Other),
detail: view.message.clone(),
},
String::new(),
),
+45 -4
View File
@@ -36,7 +36,7 @@ pub fn active_card<'a>(
ui,
Button::new(
catalog.text(Key::AccountRelogin),
Account::Relogin.into_page(),
Account::Relogin(current.id.clone()).into_page(),
),
));
actions = actions.push(buttons::text(
@@ -67,7 +67,7 @@ pub fn active_card<'a>(
));
}
let identity = column![
let mut identity = column![
name_line,
frame::text_role(
ui,
@@ -78,6 +78,16 @@ pub fn active_card<'a>(
),
]
.spacing(ui.space(Space::X1));
// 与列表行同源:服务器地址 / 登录名这一行两边都要有(tower 看图意见 ①)
if !current.detail.is_empty() {
identity = identity.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
&current.detail,
ui.color(Role::OnSurfaceVariant),
));
}
let left = row![
widgets::avatar(ui, current, widgets::AVATAR_ACTIVE),
@@ -93,13 +103,44 @@ pub fn active_card<'a>(
]
.spacing(ui.space(Space::X4));
if let Some(skin) = page.active_skin() {
body = body.push(skin_preview(ui, skin.clone(), catalog));
match page.active_skin() {
Some(skin) => body = body.push(skin_preview(ui, skin.clone(), catalog)),
// 没有皮肤就**说出来**(离线轨永远没有;第三方轨没抓到也是这句),别让用户猜
None => body = body.push(skin_absent(ui, catalog)),
}
frame::card(ui, body)
}
/// 无皮肤时的皮肤区:一句话说清「没有可用皮肤」,与皮肤预览区同一位置、同一节奏。
fn skin_absent<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountSkinTitle),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountSkinNone),
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountSkinHint),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.into()
}
/// 皮肤预览区:**原图直出**(64×64),旁边一句话说清「皮肤从哪来」。
fn skin_preview<'a>(
ui: &'a Ui,
+151 -73
View File
@@ -3,20 +3,21 @@
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:后端中文诊断串又直接上屏了 |
//! | `an_empty_account_list_offers_all_three_tracks` | 空态变成白页(没有入口) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」(倒计时/过期不可见) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
//! | `a_third_party_account_falls_back_to_the_monogram` | 没有皮肤却画白块 / 拿假皮肤充数 |
//! | `the_offline_track_keeps_the_agreed_wording` | 离线档案的说明被改写成「兼容旧启动器」那套 |
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AccountKind, AccountView, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase,
DeviceLoginView, SkinImage,
AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppEvent, AuthlibLoginView,
CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::model::{AuthlibPanel, DevicePanel, Form, Page, Panel, Track};
use super::login::failure_body_key;
use super::model::{AuthlibPanel, DevicePanel, Form, Hint, Page, Panel, Track};
fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
AccountView {
@@ -33,11 +34,14 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
}
fn build(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
}
/// 把视图模型里**所有会上屏的文本**拼起来(凭据红线就是在这坨里查)。
fn visible_text(page: &Page) -> String {
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
fn on_screen(page: &Page) -> String {
let catalog = Lang::En.catalog();
let mut out = String::new();
for row in &page.rows {
out.push_str(&row.name);
@@ -46,64 +50,91 @@ fn visible_text(page: &Page) -> String {
out.push_str(&row.added);
out.push_str(&row.detail);
}
out.push_str(&page.form.server);
out.push_str(&page.form.login);
out.push_str(&page.form.offline_name);
out.push_str(&page.checked_server);
out.push_str(&page.form.server);
if let Some(notice) = &page.notice {
// 与 `widgets::banner` 同源:码 → i18n(主题名拼进去)
out.push_str(match notice.code {
AccountNoticeCode::OfflineNameInvalid => {
catalog.text(Key::AccountNoticeOfflineNameInvalid)
}
AccountNoticeCode::AccountExists => catalog.text(Key::AccountNoticeAccountExists),
AccountNoticeCode::StoreFailed => catalog.text(Key::AccountNoticeStoreFailed),
AccountNoticeCode::NotFound => catalog.text(Key::AccountNoticeNotFound),
AccountNoticeCode::Other => catalog.text(Key::AccountNoticeOther),
AccountNoticeCode::Added => catalog.text(Key::AccountNoticeAdded),
AccountNoticeCode::Removed => catalog.text(Key::AccountNoticeRemoved),
AccountNoticeCode::Switched => catalog.text(Key::AccountNoticeSwitched),
});
out.push_str(&notice.subject);
}
if let Some(hint) = page.hint {
out.push_str(match hint {
Hint::AuthlibReauth => catalog.text(Key::AccountHintAuthlibReauth),
Hint::OfflineNoRelogin => catalog.text(Key::AccountHintOfflineNoRelogin),
});
}
match &page.device {
DevicePanel::Hidden => {}
DevicePanel::Requesting { message } => out.push_str(message),
DevicePanel::Requesting { .. } => out.push_str(catalog.text(Key::AccountDeviceRequesting)),
DevicePanel::Code {
code,
link,
message,
remaining,
expired,
..
} => {
out.push_str(code);
out.push_str(link);
out.push_str(message);
if *expired {
out.push_str(catalog.text(Key::AccountDeviceExpired));
} else if let Some(secs) = remaining {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
}
}
DevicePanel::Browser { link, message, .. } => {
out.push_str(link);
out.push_str(message);
DevicePanel::Browser { link, .. } => out.push_str(link),
DevicePanel::Failed { failure, .. } => {
out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
}
DevicePanel::Failed { message } => out.push_str(message),
DevicePanel::Succeeded { name } => out.push_str(name),
}
match &page.authlib {
AuthlibPanel::Idle => {}
AuthlibPanel::Running { message } => out.push_str(message),
AuthlibPanel::Ready {
server_name,
message,
} => {
out.push_str(server_name);
out.push_str(message);
AuthlibPanel::Running { .. } => out.push_str(catalog.text(Key::AccountServerChecking)),
AuthlibPanel::Ready { server_name, .. } => out.push_str(server_name),
AuthlibPanel::Failed { failure, .. } => {
out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(failure.code());
}
AuthlibPanel::Failed { message } => out.push_str(message),
AuthlibPanel::Succeeded { name } => out.push_str(name),
}
out
}
/// 有没有中日韩统一表意文字(语言泄漏的最直接判据)。
fn has_cjk(text: &str) -> bool {
text.chars().any(|c| matches!(c, '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{3000}'..='\u{303F}' | '\u{FF00}'..='\u{FFEF}'))
}
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 视图模型里不许出现。
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 状态与视图模型里都不许出现。
///
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`),
/// 或页面把 DTO 的错误原文直接搬上屏。
/// 抓不住:某个将来新增的**独立**字段绕过 `message` 通道(那需要另加断言)。
#[test]
fn a_jwt_injected_into_events_never_reaches_the_page() {
let mut state = AppState::new();
state.accounts = vec![account("Steve", AccountKind::Microsoft, true)];
// **绕过构造器**直接摆一个带 JWT 的 DTO:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
// 用 `DeviceLoginView::failed(..)` 构造是测不到状态层脱敏的(构造器自己就脱过了)。
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
failure: Some(LoginFailure::Denied),
..DeviceLoginView::new()
},
)));
@@ -113,21 +144,75 @@ fn a_jwt_injected_into_events_never_reaches_the_page() {
server: "https://littleskin.cn/api/yggdrasil/".to_string(),
server_name: String::new(),
message: format!("服务器回包异常 {JWT}"),
failure: Some(LoginFailure::Denied),
account_name: String::new(),
},
)));
let page = build(&state, &Form::new());
let text = visible_text(&page);
assert!(!text.contains("eyJ"), "视图模型里出现了 JWT:{text}");
assert!(!text.to_lowercase().contains("refreshtoken"), "{text}");
// ① 状态层已经脱敏(这一条抓得住「摘掉 state 层 redact」的变异)
assert!(
text.contains("⟪redacted⟫"),
"错误文案要被标成已脱敏:{text}"
!state.device_login.message.contains("eyJ"),
"{}",
state.device_login.message
);
assert!(
!state.authlib_login.message.contains("eyJ"),
"{}",
state.authlib_login.message
);
// ② 上屏文案更不可能有它(页面根本不画原文)
let text = on_screen(&build(&state, &Form::new()));
assert!(!text.contains("eyJ"), "视图里出现了 JWT:{text}");
assert!(!text.contains("⟪redacted⟫"), "脱敏占位符也不该上屏:{text}");
}
/// 空态:**三条轨道都有入口**,不是一张白页。
/// **语言泄漏(复审 r1 p1)**:带 AADSTS 码的失败事件 → 英文视图里不许有中文,且要带 code。
///
/// 抓得住:有人又把 `view.message`(core 的中文诊断串)画回面板上;或者码→i18n 的映射被摘。
/// 抓不住:catalog 里某条英文译文本身写得差(那只能人工审)。
#[test]
fn a_failure_with_a_provider_code_renders_english_and_the_code() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message:
"微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截"
.to_string(),
failure: Some(LoginFailure::NotWhitelisted),
..DeviceLoginView::new()
},
)));
let page = build(&state, &Form::new());
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面里出现了中文:{text}");
assert!(
text.contains("whitelist_pending"),
"面板要给稳定 code:{text}"
);
assert!(
text.to_lowercase().contains("approved"),
"要给出可读的英文原因:{text}"
);
// 第三方轨的失败同样只有 i18n + code
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://example.invalid/".to_string(),
server_name: String::new(),
message: "服务器不可达:dns error".to_string(),
failure: Some(LoginFailure::Unreachable),
account_name: String::new(),
},
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "{text}");
}
/// 空态:**三条轨道都有入口**,不是一张白页;且三张卡的标题各不相同。
#[test]
fn an_empty_account_list_offers_all_three_tracks() {
let state = AppState::new();
@@ -135,8 +220,6 @@ fn an_empty_account_list_offers_all_three_tracks() {
assert!(page.is_empty());
assert_eq!(page.panel, Panel::None);
assert!(page.active_row().is_none());
assert_eq!(Track::ALL.len(), 3);
// 三条轨道的文案 key 各不相同(不是三张一样的卡)
let mut keys: Vec<&str> = Track::ALL
.iter()
.map(|track| track.title_key().name())
@@ -146,7 +229,7 @@ fn an_empty_account_list_offers_all_three_tracks() {
assert_eq!(keys.len(), 3);
}
/// 设备码:**倒计时可见**,过期后是「已过期」而不是继续转圈。
/// 设备码:**倒计时是人话格式(mm:ss)**,过期后是「已过期」而不是继续转圈。
#[test]
fn the_device_code_panel_shows_a_countdown_and_expires() {
let mut state = AppState::new();
@@ -162,11 +245,14 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
..
} => {
assert_eq!(code, "ABCD-EFGH");
assert!(remaining.is_some_and(|secs| secs > 0));
assert_eq!(*remaining, Some(900));
assert!(!expired);
}
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(text.contains("15:00"), "倒计时要画成 mm:ss:{text}");
assert!(!text.contains("900s"), "不再是 `900s left`:{text}");
// 起点往前挪 20 分钟 ⇒ 过期
state.device_login_started =
@@ -190,7 +276,7 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
assert!(matches!(page.device, DevicePanel::Browser { .. }));
}
/// 没有皮肤 = 首字母块(不是白块、不是假皮肤);坏图也必须回落。
/// 没有皮肤 = 首字母块(不是白块、不是假皮肤);坏图也必须回落;无皮肤时**明说**。
#[test]
fn a_third_party_account_falls_back_to_the_monogram() {
let mut state = AppState::new();
@@ -204,7 +290,9 @@ fn a_third_party_account_falls_back_to_the_monogram() {
"没有皮肤就不许有图"
);
assert!(page.rows.iter().all(|row| !row.monogram.is_empty()));
assert!(page.active_skin().is_none(), "皮肤预览区没有图就不画");
assert!(page.active_skin().is_none(), "皮肤预览区没有图就不画图");
let text = on_screen(&page);
assert!(!has_cjk(&text));
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 panic、不白块)
state.accounts[0].skin = Some(SkinImage {
@@ -213,12 +301,6 @@ fn a_third_party_account_falls_back_to_the_monogram() {
});
let page = build(&state, &Form::new());
assert!(page.rows[0].head.is_none());
// 离线档案永远没有皮肤:即使被塞了假数据,UI 侧也只认 DTO 的 `kind`
state.accounts[1].skin = None;
state.accounts[1].kind = AccountKind::Offline;
let page = build(&state, &Form::new());
assert!(page.rows[1].head.is_none());
}
/// 离线档案的说明**必须**带「无法连接启用正版验证的服务器」这条限定(中英都要)。
@@ -242,34 +324,31 @@ fn the_offline_track_keeps_the_agreed_wording() {
}
}
/// 三条轨道在列表里**文案互不相同**(每一行都贴自己的轨道徽标)。
/// **离线轨的失败必须可见**(复审 r1 P2-2):横幅进模型、文案走 i18n、原文不上屏。
#[test]
fn every_track_has_its_own_badge() {
fn a_failed_offline_add_is_visible() {
let mut state = AppState::new();
state.accounts = vec![
account("a", AccountKind::Microsoft, true),
account("b", AccountKind::Authlib, false),
account("c", AccountKind::Offline, false),
];
state.apply(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
AccountNotice::new(
AccountNoticeCode::OfflineNameInvalid,
"ab",
"离线名字必须是 3–16 位字母/数字/下划线,收到 \"ab\"",
),
))));
let page = build(&state, &Form::new());
let labels: Vec<&str> = page
.rows
.iter()
.map(|row| row.kind_label.as_str())
.collect();
assert_eq!(labels.len(), 3);
assert_ne!(labels[0], labels[1]);
assert_ne!(labels[1], labels[2]);
assert!(
labels.iter().all(|label| !label.is_empty()),
"轨道徽标不能空"
);
let notice = page.notice.as_ref().expect("失败要有横幅");
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
assert!(notice.failure, "失败横幅要用 error 配色");
let text = on_screen(&page);
assert!(text.contains("3–16"), "要说清规则:{text}");
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
}
/// 表单状态:三条轨道各开各的,密码关面板即清。
/// 表单状态:三条轨道各开各的,密码关面板即清,三卡默认收起。
#[test]
fn the_form_tracks_are_independent() {
let mut form = Form::new();
assert!(!form.section_open, "有账号时三卡默认收起(P2-4)");
form.open(Track::Offline);
assert_eq!(form.panel, Panel::Offline);
assert!(!form.offline_ready(), "空名字不能提交");
@@ -277,7 +356,6 @@ fn the_form_tracks_are_independent() {
assert!(form.offline_ready());
form.open(Track::Authlib);
assert_eq!(form.panel, Panel::Authlib);
assert!(!form.authlib_ready(), "地址/登录名/密码缺一不可");
form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
form.login = "alex".to_string();
@@ -299,10 +377,10 @@ fn login_phases_map_to_distinct_panels() {
..DeviceLoginView::new()
},
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Requesting { .. }
));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Requesting { .. }));
// 进行中也只有 i18n(不再复读 presenter 的中文状态串)
assert!(!has_cjk(&on_screen(&page)));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed("HTTP 403"),
+192 -2
View File
@@ -11,15 +11,26 @@ use iced::widget::{container, image, row, text};
use iced::{Alignment, Background, Border, ContentFit, Element, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::AccountNoticeCode;
use crate::pages::frame;
use crate::pages::frame::ICON_PX;
use crate::theme::Ui;
use super::super::Message;
use super::model::Row;
use crate::app::components::buttons::{self, Button};
use crate::pages::Message as PageMessage;
use crate::pages::Message;
use super::model::{Hint, Notice, Row};
use super::Message as Account;
/// 头像块的可选尺寸(列表 40 / 当前账户 56)。
pub const AVATAR_LIST: f32 = 40.0;
@@ -56,6 +67,185 @@ fn tone(ui: &Ui, tone: u8) -> (iced::Color, iced::Color) {
}
}
/// 提示条的语义档(决定图标块与图标字形用哪一对角色)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
/// 失败(`ErrorContainer` / `OnErrorContainer` 图标块 + `Error` 图形)
Error,
/// 成功/信息(`SecondaryContainer` / `OnSecondaryContainer` 图标块 + `Check` 图形)
Info,
}
/// **M3 内联提示条**(账户页横幅与登录失败面板共用)。
///
/// ## 为什么不是通栏实心色块(tower r2 意见)
///
/// r1 的失败面板把 `errorContainer` 铺满整条内容宽——角色本身是对的(复审按像素采样反证过:
/// 暗色取到容器角色 30 档、浅色取到 90 档,与设计层逐值一致),但**面积**让它像一条通栏告警带。
/// 现在改成 M3 的 alert 形态:面板底走 `SurfaceContainerHigh` + 1dp 描边,
/// `ErrorContainer` / `OnErrorContainer` 只作左侧**图标块**的强调(成功档换成 Secondary 容器),
/// 标题用 `OnSurface`、正文与 code 行用 `OnSurfaceVariant`。语义仍由角色表达,视觉不再是红条。
pub fn alert<'a>(
ui: &'a Ui,
tone: Tone,
title: &'a str,
body: String,
code: Option<String>,
actions: Vec<Element<'a, PageMessage>>,
) -> Element<'a, PageMessage> {
let (chip_bg, chip_fg, glyph, glyph_color) = match tone {
Tone::Error => (
ui.color(Role::ErrorContainer),
ui.color(Role::OnErrorContainer),
IconKind::Error,
ui.color(Role::Error),
),
Tone::Info => (
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
IconKind::Check,
ui.color(Role::OnSecondaryContainer),
),
};
let _ = chip_fg;
let chip = container(frame::glyph_box(glyph, glyph_color, ICON_PX))
.width(Length::Fixed(ui.space(Space::X8)))
.height(Length::Fixed(ui.space(Space::X8)))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(chip_bg)),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.space(Space::X8)),
..Border::default()
},
..container::Style::default()
});
let mut lines = iced::widget::Column::new().spacing(ui.space(Space::X1));
lines = lines.push(frame::text_role(
ui,
TypeRole::TitleSmall,
true,
title,
ui.color(Role::OnSurface),
));
lines = lines.push(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(Role::OnSurfaceVariant),
));
if let Some(code) = code {
lines = lines.push(frame::text_role_owned(
ui,
TypeRole::LabelSmall,
false,
code,
ui.color(Role::OnSurfaceVariant),
));
}
container(
row![
chip,
container(lines).width(Length::Fill),
row(actions)
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(Component::OutlinedCard.spec().shape),
..container::Style::default()
})
.into()
}
/// 账户页横幅:添加/删除/切换的结果(**页面上真实可见的控件**,不是 toast)。
///
/// 为什么必须有它:app-iced 不渲染 toast(复审 r1 P2-2),离线名字不合法/重名/落盘失败时
/// 用户原本什么都看不到。文案按 `AccountNoticeCode` 查 i18n,后端原文不进 UI。
pub fn banner<'a>(ui: &'a Ui, notice: &'a Notice, catalog: Catalog) -> Element<'a, PageMessage> {
let tone = if notice.failure {
Tone::Error
} else {
Tone::Info
};
let title = if notice.failure {
catalog.text(Key::AccountNoticeTitleFailed)
} else {
catalog.text(Key::AccountNoticeTitleDone)
};
let message = notice_text(notice, catalog);
let dismiss = buttons::text(
ui,
Button::new(
catalog.text(Key::AccountNoticeDismiss),
Account::DismissNotice.into_page(),
),
);
// 只有失败才显示稳定 code(成功态列一串 `added` 是噪音)
let code = notice
.failure
.then(|| catalog.render(Key::AccountFailureCode, &[("code", notice.code.code())]));
alert(ui, tone, title, message, code, vec![dismiss])
}
/// 横幅文案(码 → i18n;主题名拼进带占位符的几句)。
fn notice_text(notice: &Notice, catalog: Catalog) -> String {
let name = notice.subject.as_str();
match notice.code {
AccountNoticeCode::Added => catalog.render(Key::AccountNoticeAdded, &[("name", name)]),
AccountNoticeCode::Removed => catalog.render(Key::AccountNoticeRemoved, &[("name", name)]),
AccountNoticeCode::Switched => {
catalog.render(Key::AccountNoticeSwitched, &[("name", name)])
}
AccountNoticeCode::OfflineNameInvalid => catalog
.text(Key::AccountNoticeOfflineNameInvalid)
.to_string(),
AccountNoticeCode::AccountExists => {
catalog.text(Key::AccountNoticeAccountExists).to_string()
}
AccountNoticeCode::StoreFailed => catalog.text(Key::AccountNoticeStoreFailed).to_string(),
AccountNoticeCode::NotFound => catalog.text(Key::AccountNoticeNotFound).to_string(),
AccountNoticeCode::Other => catalog.text(Key::AccountNoticeOther).to_string(),
}
}
/// 一次性引导(重新登录时告诉用户下一步)。
pub fn hint<'a>(
ui: &'a Ui,
hint: Hint,
page: &'a super::Page,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let message = match hint {
Hint::AuthlibReauth => catalog.render(
Key::AccountHintAuthlibReauth,
&[("server", page.form.server.as_str())],
),
Hint::OfflineNoRelogin => catalog.text(Key::AccountHintOfflineNoRelogin).to_string(),
};
container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
message,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.into()
}
/// 头像块:有真图就画图(圆角裁切),没有就画**首字母块**。
///
/// 三条铁律:① 图解码失败一定回到首字母块(不白块);② 首字母块的字来自 `AccountView::monogram`,
+24 -10
View File
@@ -48,6 +48,17 @@ use crate::cli::ForcedState;
use crate::icons;
use crate::theme::Ui;
/// 导航轨的一帧状态(M114):主序列高亮项 + 轨底账户头像位。
///
/// 绑成一个结构体是为了让 [`view_with_account`] 的参数停在 8 个以内(clippy `too_many_arguments`),
/// 也把「这两个值永远一起传」这件事写进类型:谁加导航项都绕不开轨底那一格。
pub struct NavRail {
/// 高亮哪个主序列项(账户页是 `NavItem::Account`)
pub selected: NavItem,
/// 轨底头像位的状态(**按值持有**:它是每帧现算的,引用会让整棵树的生存期挂到临时值上)
pub account: nav::RailAccount,
}
/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栏,中间那块交给调用方。
///
/// 产品页的唯一入口(M90 r2 / P1-1;M104 起多两样东西:右侧助手栏与拖拽取指针层;
@@ -68,14 +79,14 @@ use crate::theme::Ui;
///
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
// 10 个形参:M104 的 `right`/`overlay`、M106 的 `lang`(导航轨标签按语言取词)、
// M114 的 `account`(轨底账户头像位状态)。拆成结构体要动 `app/view.rs` / `account_page.rs`
// 的调用点,而那正是 M104/M105/M109 反复设锚点的地方——先就地 `allow`,等下次合流再结构体化。
// 9 个形参:M104 的 `right`/`overlay`、M106 的 `lang`(导航轨标签按语言取词),
// 加上 M114 把「高亮项 + 轨底账户态」捆成的 `NavRail`(捆它是为了少一个形参)。
// 再拆就得动 `app/view.rs` / `account_page.rs` 的调用点,而那正是 M104/M105/M109 反复设锚点的
// 地方——先就地 `allow`(主线原本也在这条上 `allow`),等下次合流再结构体化。
#[allow(clippy::too_many_arguments)]
pub fn view_with_background<'a>(
pub fn view_with_account<'a>(
ui: &'a Ui,
selected: NavItem,
account: &nav::RailAccount,
nav_state: NavRail,
app_title: &'a str,
background: Option<Element<'a, Message>>,
content: Element<'a, Message>,
@@ -87,10 +98,13 @@ pub fn view_with_background<'a>(
// 右栏(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
// 中栏以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
// M106:导航轨标签按当前界面语言取词(`lang` 一路从 `App::lang()` 递进来)。
// M114:`account` 只影响轨底那一格,`right`/`overlay` 的语义一字未动。
let mut work = row![nav::rail(ui, selected, account, forced, lang), content]
.width(Length::Fill)
.height(Length::Fill);
// M114:`nav_state` 只影响轨底那一格,`right`/`overlay` 的语义一字未动。
let mut work = row![
nav::rail(ui, nav_state.selected, &nav_state.account, forced, lang),
content
]
.width(Length::Fill)
.height(Length::Fill);
if let Some(right) = right {
work = work.push(right);
}
+28 -3
View File
@@ -52,8 +52,7 @@ macro_rules! account_entries {
AccountDeviceCodeLabel => ("Enter this code", "输入这个用户码"),
AccountDeviceCodeCopy => ("Copy code", "复制用户码"),
AccountDeviceLinkCopy => ("Copy link", "复制链接"),
AccountDeviceOpenLink => ("Open link", "打开链接"),
AccountDeviceCountdown => ("{secs}s left", "剩 {secs}s"),
AccountDeviceCountdown => ("{time} left", "剩 {time}"),
AccountDeviceExpired => ("This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
AccountDeviceRetry => ("Try again", "重试"),
AccountDeviceCancel => ("Cancel", "取消"),
@@ -67,7 +66,7 @@ macro_rules! account_entries {
AccountPasswordLabel => ("Password", "密码"),
AccountPasswordHint => ("Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
AccountAuthlibSubmit => ("Sign in", "登录"),
AccountAuthlibIdleHint => ("Check the server address first; signing in only starts after the check passes.", "先校验服务器地址,通过之后才能登录。"),
AccountAuthlibIdleHint => ("The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
// ── 皮肤区
AccountSkinTitle => ("Skin", "皮肤"),
AccountSkinNone => ("No skin available", "没有可用皮肤"),
@@ -80,6 +79,32 @@ macro_rules! account_entries {
AccountServerOk => ("Server {server} is ready", "服务器 {server} 可用"),
AccountLoginFailed => ("Sign-in failed", "登录失败"),
AccountCurrentTitle => ("Current account", "当前账户"),
// ── 失败原因码(M114 r2:后端只给码,文案在这里;原文只进日志)
AccountFailureCode => ("Code: {code}", "代码:{code}"),
AccountFailureDenied => ("The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
AccountFailureWhitelist => ("Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
AccountFailureUnreachable => ("Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
AccountFailureNotAuthlib => ("This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
AccountFailureBadCredentials => ("The server rejected the username or password.", "服务器不认这个用户名或密码。"),
AccountFailureNoProfile => ("The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
AccountFailureStore => ("Could not save the account to disk.", "账户没能写到磁盘上。"),
AccountFailureOther => ("The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
AccountDeviceRequesting => ("Contacting Microsoft…", "正在联系微软…"),
// ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 toast)
AccountNoticeAdded => ("Added {name}", "已添加 {name}"),
AccountNoticeRemoved => ("Removed {name}", "已删除 {name}"),
AccountNoticeSwitched => ("{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
AccountNoticeOfflineNameInvalid => ("Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
AccountNoticeAccountExists => ("An account with that name already exists.", "同名账户已经存在。"),
AccountNoticeStoreFailed => ("Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
AccountNoticeNotFound => ("That account no longer exists.", "这个账户已经不在了。"),
AccountNoticeOther => ("The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
AccountNoticeDismiss => ("Dismiss", "关闭"),
AccountNoticeTitleFailed => ("Account action failed", "账户操作失败"),
AccountNoticeTitleDone => ("Account updated", "账户已更新"),
// ── 重新登录的引导
AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"),
AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
} }
};
}
+25 -3
View File
@@ -336,8 +336,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
("AccountDeviceOpenLink", "Open link", "打开链接"),
("AccountDeviceCountdown", "{secs}s left", "剩 {secs}s"),
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
@@ -350,7 +349,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AccountPasswordLabel", "Password", "密码"),
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
("AccountAuthlibSubmit", "Sign in", "登录"),
("AccountAuthlibIdleHint", "Check the server address first; signing in only starts after the check passes.", "先校验服务器地址,通过之后才能登录。"),
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
("AccountSkinTitle", "Skin", "皮肤"),
("AccountSkinNone", "No skin available", "没有可用皮肤"),
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
@@ -361,4 +360,27 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
("AccountLoginFailed", "Sign-in failed", "登录失败"),
("AccountCurrentTitle", "Current account", "当前账户"),
("AccountFailureCode", "Code: {code}", "代码:{code}"),
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
("AccountNoticeDismiss", "Dismiss", "关闭"),
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
];
+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;
}