Merge branch 'feat/m114-accountui-accounts-page-three-track'

This commit is contained in:
AskaEth committed 2026-11-05 06:34:09 +08:00
commit aace0c5836
54 files changed
+5878 -256

No files matched your search

+3
View File
@@ -80,6 +80,8 @@ pub(super) fn route_of(nav: NavItem) -> Route {
NavItem::Logs => Route::Logs,
NavItem::Settings => Route::Settings,
NavItem::Ai => Route::Agent,
// 轨底头像位(M114):账户是一级页,但**不在主序列**里
NavItem::Account => Route::Accounts,
}
}
@@ -94,5 +96,6 @@ pub(super) fn nav_item_of(route: &Route) -> NavItem {
Route::Logs => NavItem::Logs,
Route::Settings => NavItem::Settings,
Route::Agent => NavItem::Ai,
Route::Accounts => NavItem::Account,
}
}
+19 -1
View File
@@ -150,6 +150,12 @@ pub struct App {
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
pub(super) content: pages::content::PageState,
/// 账户页的**表单状态**(三条轨道的输入串;密码只活在这一次请求里)。
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
}
impl App {
@@ -251,6 +257,15 @@ impl App {
download_draft: pages::InstallDraft::default(),
install_cancelled_seen: 0,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
&AppState::new(),
&pages::account::Form::new(),
&mut pages::account::avatar::Cache::default(),
unlucky_i18n::Lang::En,
width,
),
avatars: pages::account::avatar::Cache::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
@@ -333,7 +348,7 @@ impl App {
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::view::model(
@@ -345,6 +360,9 @@ impl App {
self.lang(),
&self.content,
);
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
+10 -19
View File
@@ -42,7 +42,9 @@ use super::message::{route_of, Message};
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod account;
mod agent;
mod boot_env;
mod content;
mod download;
mod page_messages;
@@ -177,7 +179,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = env_start_route() {
if let Some(route) = boot_env::env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
@@ -185,6 +187,7 @@ impl App {
// M109:直达内容库时补一次检索(与页面内切换同一个入口)
self.ensure_content_loaded();
}
account::force_account_state(self);
self.auto_select();
self.view_dirty = true;
@@ -245,6 +248,12 @@ impl App {
self.state
.toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -375,23 +384,5 @@ fn heartbeat() -> Task<Message> {
})
}
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
fn env_start_route() -> Option<Route> {
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
"instances" => Some(Route::Instances),
"download" => Some(Route::Download),
"logs" => Some(Route::Logs),
"settings" => Some(Route::Settings),
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
"agent" => Some(Route::Agent),
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
"content" => Some(Route::Content),
_ => None,
}
}
#[cfg(test)]
mod download_wiring;
+329
View File
@@ -0,0 +1,329 @@
//! 账户页消息的落点(M114):页面只发意图,这里翻成 `AppCommand` 或本地的表单状态写入。
//!
//! 三条纪律(与 `update.rs` 同源):
//!
//! 1. **不做乐观回显**:切账户/删账户/登录都只派命令,等 `AccountsChanged` /
//! `DeviceLoginChanged` / `AuthlibLoginChanged` 回来才改列表;
//! 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 iced::Task;
use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest, DeviceLoginPhase};
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) -> 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);
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 => {
self.account_form.close();
// 关面板必须把**两条轨**的登录态都收回 Idle(复审 r2 P2-1:只看 `busy()` 时,
// `Failed`/`Succeeded` 不在 busy ⇒ 状态仍是非 Idle ⇒ `Page::build` 又把面板
// 强制回 Microsoft,按钮成了死按钮)。`CancelDeviceLogin` 在 host 侧本来就
// 同时收两条轨并发 Idle,所以判据用「非 Idle」而不是「busy」。
if self.state.device_login.phase != DeviceLoginPhase::Idle
|| self.state.authlib_login.phase != DeviceLoginPhase::Idle
{
self.dispatch(AppCommand::CancelDeviceLogin);
}
self.view_dirty = true;
}
Account::DismissNotice => self.dispatch(AppCommand::DismissAccountNotice),
Account::Server(value) => {
self.account_form.server = value;
self.view_dirty = true;
}
Account::Login(value) => {
self.account_form.login = value;
self.view_dirty = true;
}
Account::Password(value) => {
self.account_form.password = value;
self.view_dirty = true;
}
Account::OfflineName(value) => {
self.account_form.offline_name = value;
self.view_dirty = true;
}
Account::Probe => {
let server = self.account_form.server.clone();
if !server.trim().is_empty() {
self.dispatch(AppCommand::ProbeAuthlibServer(server));
}
}
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.dispatch(AppCommand::AddOfflineAccount(name));
}
}
Panel::Authlib => {
if self.account_form.authlib_ready() {
let request = AuthlibRequest::new(
self.account_form.server.clone(),
self.account_form.login.clone(),
std::mem::take(&mut self.account_form.password),
);
self.dispatch(AppCommand::AddAuthlibAccount(request));
}
}
Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::None => {}
},
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
Account::StartBrowser => self.dispatch(AppCommand::StartBrowserLogin),
Account::Cancel => {
self.account_form.close();
self.dispatch(AppCommand::CancelDeviceLogin);
}
Account::Copy(_) => {}
Account::Switch(id) => self.dispatch(AppCommand::SwitchAccount(id)),
Account::Remove(id) => self.dispatch(AppCommand::RemoveAccount(id)),
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();
}
}
Task::none()
}
}
/// 取证开关 `ULL_ACCOUNT_STATE` 的落地(**只在 `--screenshot` 下生效**)。
///
/// 为什么需要:设备码过期要等 15 分钟、表单要手点两下,截图通道等不到自然发生的状态。
pub(super) fn force_account_state(app: &mut App) {
if !app.has_screenshot() {
return;
}
let Ok(state) = std::env::var("ULL_ACCOUNT_STATE") else {
return;
};
match state.trim().to_lowercase().as_str() {
// 设备码已过期:起点往前挪一小时(倒计时自己算出来就是 0)
"expired" => {
app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
app.account_form.server = "https://example.invalid/api/yggdrasil/".to_string();
app.account_form.login = "alex@example.com".to_string();
}
// 离线档案失败态:面板打开(横幅由 mock 的 notice 场景给)
"notice" => {
app.account_form.panel = crate::pages::account::Panel::Offline;
app.account_form.offline_name = "ab".to_string();
}
// 第三方站表单:直接把面板打开并预填地址
"authlib" | "authlib-form" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
if app.account_form.server.trim().is_empty() {
app.account_form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
}
app.account_form.login = "steve@example.com".to_string();
app.account_form.password = "not-a-real-password".to_string();
}
// 「关掉之后」:mock 给的是 failed 种子,这里按一次 Close(走真实的
// `on_account` → `CancelDeviceLogin` 通道),出「面板真的关了」的对照图。
"closed" => {
let _ = app.on_account(crate::pages::account::Message::Close);
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => {
app.account_form.section_open = true;
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::pages::account::{Form, Message as Account, Panel, Track};
use crate::theme::Families;
use super::App;
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。
fn headless() -> App {
App::with_presenter(
crate::cli::Options::default(),
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn seed(app: &mut App, event: CoreEvent) {
app.state.apply(AppEvent::Core(event));
app.refresh_view();
}
/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。
///
/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制
/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin`
/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。
///
/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。
/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按
/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。
#[test]
fn close_really_closes_the_login_panel_on_both_tracks() {
// ① 微软轨:失败态
let mut app = headless();
seed(
&mut app,
CoreEvent::DeviceLoginChanged(DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(),
failure: Some(LoginFailure::NotWhitelisted),
..DeviceLoginView::new()
}),
);
assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示");
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉");
assert_eq!(app.state.device_login.phase, DeviceLoginPhase::Idle);
// ② 微软轨:成功态(同样不在 busy 里)
let mut app = headless();
seed(
&mut app,
CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")),
);
assert_eq!(app.account.panel, Panel::Microsoft);
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉");
// ③ 第三方轨:失败态
let mut app = headless();
seed(
&mut app,
CoreEvent::AuthlibLoginChanged(AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://example.invalid/api/yggdrasil/".to_string(),
server_name: String::new(),
message: "服务器返回 HTTP 404".to_string(),
failure: Some(LoginFailure::NotAuthlibServer),
account_name: String::new(),
}),
);
app.account_form.open(Track::Authlib);
app.refresh_view();
assert_eq!(
app.account.panel,
Panel::Authlib,
"第三方轨失败态面板要显示"
);
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉");
assert_eq!(app.state.authlib_login.phase, DeviceLoginPhase::Idle);
}
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
///
/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
#[test]
fn the_form_state_follows_the_page_messages() {
let mut form = Form::new();
assert_eq!(form.panel, Panel::None);
form.open(Track::Authlib);
assert_eq!(form.panel, Panel::Authlib);
form.password = "hunter2".to_string();
form.close();
assert_eq!(form.panel, Panel::None);
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
}
/// 三张轨道卡默认收起(有账号时),`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);
}
}
@@ -0,0 +1,26 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
//!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
//! **纯函数、无状态**,只是取值表长到了七条。
use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
pub(super) fn env_start_route() -> Option<Route> {
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
"instances" => Some(Route::Instances),
"download" => Some(Route::Download),
"logs" => Some(Route::Logs),
"settings" => Some(Route::Settings),
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
"agent" => Some(Route::Agent),
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
"content" => Some(Route::Content),
// M114:账户页的 D30 矩阵要能直达(`cli.rs` 不在本 mission 的 scope)
"accounts" => Some(Route::Accounts),
_ => None,
}
}
@@ -88,6 +88,8 @@ impl App {
self.view_dirty = true;
self.refresh_view();
}
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
+19 -5
View File
@@ -18,7 +18,9 @@ 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;
use logs::logs_model;
@@ -55,10 +57,14 @@ impl App {
// 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
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()),
shell::NavRail {
selected: nav_item_of(self.state.route()),
account: account.clone(),
},
APP_TITLE,
background,
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
@@ -79,10 +85,18 @@ impl App {
self.options.force_state,
self.lang(),
),
// `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。
None => shell::view_with_background(
// 账户页(M114):它不走 `pages::Screen` 那四页的 `Model`,自己一棵树
// (树在 `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_account(
&self.ui,
NavItem::Ai,
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,62 @@
//! 账户页(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::pages::account::Page as AccountPage;
use crate::shell::{self, agent, NavItem, RailAccount};
use super::super::state::App;
use super::super::APP_TITLE;
impl App {
/// 账户页视图模型的重建(`refresh_view` 的账户那一步)。
///
/// 与 `view` 同一次门控;语言跟着 M106 的 `LangState` 走(M114 早期钉死 `Lang::En`
/// 的写法已删),宽度用 M104 的中栏可用宽。放在这里是为了让 `app/state.rs` 守住 400 行。
pub(in crate::app) fn rebuild_account_page(&mut self, width: f32) {
let lang = self.lang();
self.account = AccountPage::build(
&self.state,
&self.account_form,
&mut self.avatars,
lang,
width,
);
}
/// 账户页(走 `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:账户页的文案跟着**生效语言**走(`LangState::boot` 自己读 `ULL_LANG`,
// 所以 M114 早期那个「截图专用语言开关」已删——两套开关必然漂)。
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(),
)
}
}
+35
View File
@@ -0,0 +1,35 @@
//! 导航轨底部**账户头像位**的状态换算(M114)。
//!
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关——
//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」。
use crate::shell;
use super::super::state::App;
/// 导航轨收成「图标条」的窗口高度(M114)。
///
/// 判据不是「项数」而是**真实可用高度**:四项 + 底部头像位在 48 标题栏之上还要放得下
/// 4 × 52 + 4 × 4 + 52 ≈ 300;留出一屏正文的最小高度(424 行宽 + 页头 + 边距),
/// 低于 720 就把标签收掉,只留图标与头像位(头像位**永远可见**,任务要求)。
const RAIL_COMPACT_HEIGHT: f32 = 720.0;
impl App {
/// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。
pub(super) fn rail_account(&self) -> shell::RailAccount {
let compact = self.height < RAIL_COMPACT_HEIGHT;
match self.state.active_account() {
Some(account) => {
shell::RailAccount::signed_in(account.monogram(), account.name.clone())
.compact(compact)
}
// M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。
None => shell::RailAccount::signed_out(
self.lang()
.catalog()
.text(unlucky_i18n::Key::AccountRailSignedOut),
)
.compact(compact),
}
}
}
+11 -6
View File
@@ -15,12 +15,8 @@
//!
//! ## 三处口径说明
//!
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**——这是 A1 文本字段的口径
//! (`HoverActiveIndicatorColor` / `FocusActiveIndicatorColor` 等 token),与按钮家族
//! 「叠一个 8%/10% 的状态层」不同。press 在 A1 里**没有**独立 token,本实现按 hover 处理(登记 D11)。
//! 2. 标签是「**常浮起**」的两段式:有标签就固定占上面那 16px(`BodySmall`),不做上浮动画
//! (M3 的动画由 motion mission 统一接,登记 D12)。
//! 3. 缺省宽度 `Length::Fill`:文本框在页面里按列宽伸展,最小宽 280 无法在 iced 表达(G21)。
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**(A1 的口径,与按钮家族的 8%/10% 状态层不同);press 无独立 token,按 hover 处理(D11)。
//! 2. 标签是「**常浮起**」的两段式(占上面 16px,不做上浮动画,登记 D12);缺省宽度 `Fill`(最小宽 280 无法在 iced 表达,G21)。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
@@ -73,6 +69,7 @@ pub struct Field<'a> {
error: bool,
state: Option<Interaction>,
width: Length,
secure: bool,
}
impl<'a> Field<'a> {
@@ -89,6 +86,7 @@ impl<'a> Field<'a> {
error: false,
state: None,
width: Length::Fill,
secure: false,
}
}
@@ -145,6 +143,12 @@ impl<'a> Field<'a> {
self.width = width;
self
}
/// 密码式输入(M114):上屏是掩码(明文上屏=UI 侧凭据泄漏)。
pub fn secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
}
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
@@ -269,6 +273,7 @@ pub fn text_field<'a, M: Clone + 'a>(
} else {
Some(on_input)
})
.secure(field.secure)
.size(input_style.size_px)
.line_height(LineHeight::Absolute(Pixels(input_style.line_height_px)))
.font(ui.font_of(input_style))
+2 -1
View File
@@ -234,6 +234,7 @@ mod tests {
fn sub_24_boxes_go_through_view_at() {
const VIEW: &[(&str, usize)] = &[
("shell/nav.rs", 1),
("shell/nav/account.rs", 1), // M114:轨底头像位
("components/inputs/field.rs", 2),
("components/inputs/menu.rs", 2),
("components/inputs/search.rs", 2),
@@ -267,7 +268,7 @@ mod tests {
}
}
assert!(bad.is_empty(), "图标入口用法违规:\n{}", bad.join("\n"));
assert_eq!(views, 13, "全仓 `icons::view` 调用点总数变了");
assert_eq!(views, 14, "`icons::view` 调用点总数变了");
fn sites(list: &[(&str, usize)], rel: &str) -> usize {
list.iter().find(|(p, _)| *p == rel).map_or(0, |(_, n)| *n)
+175
View File
@@ -0,0 +1,175 @@
//! 账户页(M114):**三轨**登录(微软正版 / 第三方皮肤站 / 本地档案)+ 账户列表 + 皮肤区。
//!
//! ## 这一页回答什么
//!
//! 用户的原话是「用户管理页面还没做吗?包括不限于正版登录 离线登录 第三方皮肤站登录」。
//! Slint 版曾有这个页面(`DevDocs/ops/screens-m43/account-data.png`),换 iced 时没搬过来,
//! 于是 iced 版**一个账户入口都没有**——本页就是把它补回来,并按 M114 的口径做成三轨。
//!
//! ## 四条红线(都在本页的断言里)
//!
//! | 红线 | 落点 |
//! |---|---|
//! | 页面不得出现任何 token / refresh_token | 上屏文案只来自 [`model`] 的公开字段;`tests` 里有「把 JWT 塞进事件、断言模型干净」的用例 |
//! | 离线档案的说明必须带「仅本地/隔离网络测试用,无法连接启用正版验证的服务器」 | `Key::AccountTrackOfflineBody` / `Key::AccountOfflineWarning` 两处都写全 |
//! | 设备码不得只转圈 | [`login`]:用户码 + 链接 + 倒计时 + 「已过期,重试」 |
//! | 没有皮肤不给假皮肤 | 头像位 = 首字母块(`profile_id` 稳定取色);第三方轨才可能出真图 |
//!
//! ## 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`model`] | 状态 → 视图模型(列表行 / 当前账户 / 三条轨道的面板态) |
//! | [`avatar`] | 首字母块 + 皮肤图(解码/裁剪缓存;失败一律退回首字母块,**绝不白块**) |
//! | [`widgets`] | 本页的小件(头像块 / 章节标题 / 徽标),样式全部走令牌 |
//! | [`rows`] | 当前账户卡片 + 账户列表行 |
//! | [`forms`] | 三条轨道的入口卡 + 本地档案/第三方站表单 |
//! | [`login`] | 微软登录面板(设备码倒计时 / 浏览器授权 / 失败态) |
//! | `tests` | 视图模型的判据(凭据红线、空态、倒计时、皮肤回落) |
pub mod avatar;
pub mod forms;
pub mod login;
pub mod model;
pub mod panels;
pub mod rows;
pub mod widgets;
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "account/tests_text.rs"]
mod tests_text;
pub use model::{Form, Hint, Notice, Page, Panel, Track};
use iced::widget::{column, container, scrollable};
use iced::{Element, Length, Padding};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::{self, Header};
use crate::pages::layout::FrameMetrics;
use crate::theme::Ui;
use super::Message as PageMessage;
/// 账户页的页面消息(app 层把它翻成 `AppCommand` / 状态写入)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 打开某条轨道的表单
Open(Track),
/// 关掉表单/登录面板
Close,
/// 第三方站地址输入
Server(String),
/// 登录名输入
Login(String),
/// 密码输入(只存在内存里,随请求交给 host,用完即弃)
Password(String),
/// 本地档案名输入
OfflineName(String),
/// 校验服务器地址(服务发现)
Probe,
/// 表单提交(本地档案 → 建档案;第三方站 → 登录)
Submit,
/// 微软:设备码流
StartDevice,
/// 微软:授权码流(拉起系统浏览器)
StartBrowser,
/// 取消/关掉登录面板
Cancel,
/// 复制一段文本(用户码 / 链接)
Copy(String),
/// 切换当前启动账户
Switch(String),
/// 删除账户
Remove(String),
/// 重新登录(按账户类型分派:微软 → 授权码流;第三方 → 预填表单;离线 → 明确说明)
Relogin(String),
/// 展开/收起「Add an account」的三张轨道卡(复审 r1 P2-4)
ToggleAdd,
/// 关掉横幅
DismissNotice,
}
impl Message {
/// 包成页面的消息(本模块内部与 app 层都用它,保证只有一处包装点)。
pub fn into_page(self) -> PageMessage {
PageMessage::Account(self)
}
}
/// 整页。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
page: &'a Page,
lang: Lang,
) -> Element<'a, PageMessage> {
let text = lang.catalog();
let subtitle = text.text(Key::AccountSubtitle);
let add = text.text(Key::AccountEmptyAction);
let header = Header::new(text.text(Key::AccountTitle))
.subtitle(subtitle)
.action(buttons::tonal(
ui,
Button::new(add, Message::ToggleAdd.into_page()),
));
let top = if page.rows.is_empty() {
forms::empty(ui, text, lang)
} else {
column![
rows::active_card(ui, page, text, lang),
rows::list(ui, page, text, lang),
]
.spacing(ui.space(Space::X6))
.into()
};
// 三条轨道入口 + 表单/登录面板:面板存在时**只画面板**,且**置顶**——
// 否则在 1180×760 下设备码的「复制用户码 / 取消」会掉到折线以下(D30 复审实测)。
let flows = match page.panel {
Panel::None => forms::tracks(ui, text, lang),
_ => forms::panel(ui, page, text, lang),
};
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 {
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(padded)
.style(frame::scroll_style(ui))
.width(Length::Fill)
.height(Length::Fill),
)
}
+229
View File
@@ -0,0 +1,229 @@
//! 头像位与皮肤区:**首字母块**(无皮肤时)与**真图**(第三方轨抓到皮肤时)。
//!
//! ## 为什么头像可以「真有图」
//!
//! presenter 只把皮肤的 **PNG 原图**送到 UI(它不引入图片解码依赖),裁剪/缩放全部在这里做:
//! Minecraft 皮肤的头部在 `(8,8)` 的 8×8 方块(64×64 皮肤),帽子层在 `(40,8)`,
//! 两层贴起来再放大到 64×64 就是一张真正的头像。**解码失败一律退回首字母块**——
//! 头像位不允许出现白块(M114 明文要求)。
//!
//! ## 依赖口径
//!
//! 用的是 `crates/app-iced/Cargo.toml` **既有**的 `image`(png feature,M93 背景图那一条),
//! 本 mission 没有改任何依赖表。
use std::collections::BTreeMap;
use iced::widget::image;
use unlucky_presenter::dto::SkinImage;
/// 头像边长(像素):列表行 40、当前账户卡片 56 共用同一张 64 的图(缩下来仍然清晰)。
const HEAD_PX: u32 = 64;
/// 皮肤原图的头部坐标(64×64 皮肤的标准布局)。
const FACE: (u32, u32) = (8, 8);
/// 帽子/头发层的坐标(叠在脸上一层)。
const HAT: (u32, u32) = (40, 8);
/// 头部方块边长(8×8)。
const FACE_EDGE: u32 = 8;
/// 一个账户的解码结果(失败就是两个 `None`,UI 画首字母块)。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Avatar {
/// 裁好的头像(64×64 RGBA)
pub head: Option<image::Handle>,
/// 皮肤原图(皮肤预览区用)
pub skin: Option<image::Handle>,
}
/// 解码缓存:同一个账户的同一张皮肤只解码一次。
///
/// 键 = 账户 id,值 = (皮肤来源串, 解码结果)。来源串变了(重新登录换了皮肤)就重解。
#[derive(Debug, Default)]
pub struct Cache {
entries: BTreeMap<String, (String, Avatar)>,
}
impl Cache {
/// 取某个账户的头像(必要时解码)。
pub fn resolve(&mut self, id: &str, skin: Option<&SkinImage>) -> Avatar {
let source = skin.map(|skin| skin.source.clone()).unwrap_or_default();
let empty = skin.is_none_or(SkinImage::is_empty);
if empty {
// 没有皮肤 = 没有图(离线轨永远走这条;**不给假皮肤**)
self.entries.remove(id);
return Avatar::default();
}
if let Some((cached_source, avatar)) = self.entries.get(id) {
if cached_source == &source {
return avatar.clone();
}
}
let decoded = skin.map(decode).unwrap_or_default();
self.entries
.insert(id.to_string(), (source, decoded.clone()));
decoded
}
/// 当前缓存条目数(单测与诊断用)。
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// 缓存是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// PNG 皮肤 → 头像(裁 + 叠帽子层 + 放大)。
fn decode(skin: &SkinImage) -> Avatar {
let Some(decoded) = decode_png(&skin.png) else {
return Avatar::default();
};
let Some(scaled) = compose_head(&decoded) else {
return Avatar::default();
};
Avatar {
head: Some(image::Handle::from_rgba(
HEAD_PX,
HEAD_PX,
scaled.into_raw(),
)),
// 皮肤预览用**原图**(64×64):tower 口径是「原图直接展示,不为好看伪造皮肤」
skin: Some(image::Handle::from_bytes(skin.png.clone())),
}
}
/// 裁 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;
let reader = ::image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let (width, height) = reader.into_dimensions().ok()?;
if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE {
return None;
}
let reader = ::image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
Some(reader.decode().ok()?.to_rgba8())
}
#[cfg(test)]
mod tests {
use super::*;
use ::image::ImageEncoder;
use ::image::{Rgba, RgbaImage};
fn png_of(image: &RgbaImage) -> Vec<u8> {
let mut bytes = Vec::new();
let encoder = ::image::codecs::png::PngEncoder::new(&mut bytes);
encoder
.write_image(
image.as_raw(),
image.width(),
image.height(),
::image::ExtendedColorType::Rgba8,
)
.expect("编码 PNG");
bytes
}
/// 造一张 64×64 的皮肤:脸是红色,帽子层是半透明蓝。
fn skin_bytes() -> Vec<u8> {
let mut skin = RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 0]));
for x in 8..16 {
for y in 8..16 {
skin.put_pixel(x, y, Rgba([200, 10, 10, 255]));
}
}
for x in 40..48 {
for y in 8..16 {
skin.put_pixel(x, y, Rgba([10, 10, 200, 120]));
}
}
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 {
png: skin_bytes(),
source: "test".to_string(),
});
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]
fn a_broken_payload_falls_back_instead_of_panicking() {
let avatar = decode(&SkinImage {
png: b"not a png".to_vec(),
source: "test".to_string(),
});
assert!(avatar.head.is_none(), "解不开就退回首字母块");
assert!(avatar.skin.is_none());
}
#[test]
fn the_cache_keeps_one_entry_per_account_and_drops_it_without_a_skin() {
let mut cache = Cache::default();
let skin = SkinImage {
png: skin_bytes(),
source: "a".to_string(),
};
let first = cache.resolve("account:a", Some(&skin));
assert!(first.head.is_some());
assert_eq!(cache.len(), 1);
// 同一来源:命中缓存
let again = cache.resolve("account:a", Some(&skin));
assert!(again.head.is_some());
// 没有皮肤:条目被删掉(不留「上次的图」冒充真皮肤)
let none = cache.resolve("account:a", None);
assert!(none.head.is_none());
assert!(cache.is_empty());
}
}
+322
View File
@@ -0,0 +1,322 @@
//! 三条轨道的入口卡与两张表单(本地档案 / 第三方皮肤站)。
//!
//! ## 为什么是「三条轨道」而不是「一个添加按钮 + 一个类型下拉」
//!
//! 三条轨道的**字段完全不同**(微软没有密码输入框、本地档案没有服务器、第三方站两样都有),
//! 而且失败原因也不同(403 / 白名单未批 / 服务器不可达)。把它们压进一个表单只会让每种情况
//! 都要解释自己不属于另外两种——所以入口就是三张卡,各自展开自己的面板。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::pages::frame;
use crate::theme::Ui;
use crate::pages::account::model::{AuthlibPanel, Page, Track};
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 _ = lang;
let state = empty_state(
ui,
EmptyState::new(
unlucky_design::icons::IconKind::User,
catalog.text(Key::AccountEmptyTitle),
catalog.text(Key::AccountEmptyBody),
),
);
container(state)
.height(Length::Fixed(ui.space(Space::X8) * 8.0))
.width(Length::Fill)
.into()
}
/// 三条轨道的入口卡(面板没打开时画这一排)。
pub fn tracks<'a>(ui: &'a Ui, catalog: Catalog, lang: Lang) -> Element<'a, PageMessage> {
let _ = lang;
let mut row = iced::widget::Row::new().spacing(ui.space(Space::X4));
for track in Track::ALL {
row = row.push(track_card(ui, track, catalog));
}
row.width(Length::Fill).into()
}
fn track_card<'a>(ui: &'a Ui, track: Track, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(track.title_key()),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(track.body_key()),
ui.color(Role::OnSurfaceVariant),
),
buttons::outlined(
ui,
Button::new(
catalog.text(track.action_key()),
Account::Open(track).into_page(),
),
),
]
.spacing(ui.space(Space::X3));
container(frame::card(ui, body))
.width(Length::FillPortion(1))
.into()
}
/// 当前打开的面板。
pub fn panel<'a>(
ui: &'a Ui,
page: &'a Page,
catalog: Catalog,
lang: Lang,
) -> Element<'a, PageMessage> {
let _ = lang;
match page.panel {
crate::pages::account::model::Panel::None => tracks(ui, catalog, Lang::En),
crate::pages::account::model::Panel::Microsoft => login::panel(ui, &page.device, catalog),
crate::pages::account::model::Panel::Authlib => authlib_form(ui, page, catalog),
crate::pages::account::model::Panel::Offline => offline_form(ui, page, catalog),
}
}
/// 本地档案表单(**说明里带「仅本地/隔离网络测试用」这条限定**)。
fn offline_form<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
let ready = page.form.offline_ready();
let submit = if ready {
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountOfflineSubmit),
Account::Submit.into_page(),
),
)
} else {
buttons::filled(
ui,
Button::disabled(catalog.text(Key::AccountOfflineSubmit)),
)
};
let body = column![
// 标题下**不再复读**同一句限定:下面那条 `ErrorContainer` 警告条就是它
// (两处一模一样的句子在 1180×760 下看着像重复渲染)。
widgets::section(ui, catalog.text(Key::AccountTrackOfflineTitle), None),
warning(ui, catalog.text(Key::AccountOfflineWarning)),
text_field(
ui,
Field::new(&page.form.offline_name)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountOfflineNameLabel))
.placeholder(catalog.text(Key::AccountOfflineNameHint))
.supporting(catalog.text(Key::AccountOfflineNameHint)),
|value| Account::OfflineName(value).into_page(),
),
row![
submit,
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));
frame::card(ui, body)
}
/// 第三方站表单:地址(可单独校验)+ 登录名 + 密码 + 登录。
fn authlib_form<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
let busy = page.authlib.busy();
let ready = page.form.authlib_ready() && !busy;
let submit = if ready {
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountAuthlibSubmit),
Account::Submit.into_page(),
),
)
} else {
buttons::filled(
ui,
Button::disabled(catalog.text(Key::AccountAuthlibSubmit)),
)
};
let mut body = column![
widgets::section(
ui,
catalog.text(Key::AccountTrackAuthlibTitle),
Some(catalog.text(Key::AccountTrackAuthlibBody)),
),
text_field(
ui,
Field::new(&page.form.server)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountServerLabel))
.placeholder(catalog.text(Key::AccountServerHint))
.supporting(if page.checked_server.is_empty() {
catalog.text(Key::AccountServerHint)
} else {
page.checked_server.as_str()
}),
|value| Account::Server(value).into_page(),
),
text_field(
ui,
Field::new(&page.form.login)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountLoginNameLabel))
.placeholder(""),
|value| Account::Login(value).into_page(),
),
text_field(
ui,
Field::new(&page.form.password)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountPasswordLabel))
.supporting(catalog.text(Key::AccountPasswordHint))
// 密码**掩码**显示:明文只在内存里,不上屏
.secure(true),
|value| Account::Password(value).into_page(),
),
status_line(ui, page, catalog),
row![
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountServerProbe),
Account::Probe.into_page()
)
),
submit,
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));
if !ready && !busy {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountAuthlibIdleHint),
ui.color(Role::OnSurfaceVariant),
));
}
frame::card(ui, body)
}
/// 第三方轨的状态行(校验通过 / 进行中 / 失败原文)。
///
/// 进行中与通过都走 **i18n**:presenter 给的 `message` 是后端诊断串(中文),
/// 直接上屏会让英文界面里混一段中文;失败态才用它(那是要人读的错误原因)。
fn status_line<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
match &page.authlib {
AuthlibPanel::Idle => frame::text_role(
ui,
TypeRole::BodySmall,
false,
"",
ui.color(Role::OnSurfaceVariant),
),
AuthlibPanel::Running => frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AccountServerChecking),
ui.color(Role::OnSurfaceVariant),
),
AuthlibPanel::Ready { server_name, .. } => frame::text_role_owned(
ui,
TypeRole::LabelMedium,
true,
catalog.render(Key::AccountServerOk, &[("server", server_name.as_str())]),
ui.color(Role::Primary),
),
// 失败只画**原因码对应的 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,
true,
name,
ui.color(Role::Primary),
),
}
}
/// 「仅本地/隔离网络测试用」这条限定:**带色块**,不是一句灰字(用户要点得明白)。
fn warning<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, PageMessage> {
container(frame::text_role(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnErrorContainer),
))
.width(Length::Fill)
.padding(ui.space(Space::X2))
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(ui.color(Role::ErrorContainer))),
border: iced::Border {
radius: ui.radius_dp(unlucky_design::shape::ShapeScale::Small),
..iced::Border::default()
},
..container::Style::default()
})
.into()
}
+352
View File
@@ -0,0 +1,352 @@
//! 微软登录面板:设备码(用户码 + 链接 + **倒计时** + 过期重试)与授权码(浏览器 + 可复制链接 + 取消)。
//!
//! ## 为什么不是「一个转圈」
//!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
use iced::widget::{column, row};
use iced::{Alignment, Element};
use unlucky_design::color::Role;
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;
use crate::theme::Ui;
use crate::pages::account::model::DevicePanel;
use crate::pages::Message as PageMessage;
use super::Message as Account;
/// 微软登录面板。
pub fn panel<'a>(
ui: &'a Ui,
device: &'a DevicePanel,
catalog: Catalog,
) -> Element<'a, PageMessage> {
match device {
DevicePanel::Hidden => idle(ui, catalog),
DevicePanel::Requesting => requesting(ui, catalog),
DevicePanel::Code {
code,
link,
remaining,
expired,
} => device_code(ui, code, link, *remaining, *expired, catalog),
DevicePanel::Browser {
link,
remaining,
expired,
} => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
}
}
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceBrowser),
Account::StartBrowser.into_page(),
)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceUseCode),
Account::StartDevice.into_page(),
)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Close.into_page()
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 「正在申请」:一句 i18n 状态 + 取消。
///
/// presenter 那边的进度串是中文诊断(只进日志),面板态里**根本没有**这个字段可画。
fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
catalog.text(Key::AccountDeviceRequesting),
ui.color(Role::OnSurfaceVariant),
),
row![buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page()
)
)]
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 设备码等待态:**码 + 链接 + 倒计时**。
fn device_code<'a>(
ui: &'a Ui,
code: &'a str,
link: &'a str,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut actions = row![
buttons::tonal(
ui,
Button::new(
catalog.text(Key::AccountDeviceCodeCopy),
Account::Copy(code.to_string()).into_page(),
)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceLinkCopy),
Account::Copy(link.to_string()).into_page(),
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if expired {
actions = actions.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
),
));
}
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page(),
),
));
let countdown = countdown_line(ui, remaining, expired, catalog);
let body = column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AccountDeviceCodeLabel),
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
code,
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
link,
ui.color(Role::OnSurfaceVariant),
),
countdown,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant),
),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>(
ui: &'a Ui,
link: &'a str,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut actions = row![buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceLinkCopy),
Account::Copy(link.to_string()).into_page(),
)
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if expired {
actions = actions.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartBrowser.into_page(),
),
));
}
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page(),
),
));
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
catalog.text(Key::AccountDeviceBrowserHint),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
link,
ui.color(Role::OnSurfaceVariant),
),
countdown_line(ui, remaining, expired, catalog),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
fn countdown_line<'a>(
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
if expired {
return frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
}
let text = remaining.map_or_else(String::new, |secs| {
// 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,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
/// 失败态:**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,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page(),
),
),
];
super::widgets::alert(
ui,
super::widgets::Tone::Error,
catalog.text(Key::AccountLoginFailed),
body,
Some(code),
actions,
)
}
/// 成功态。
fn succeeded<'a>(ui: &'a Ui, name: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
// 成功也走 key + 参数(名字是 `{name}` 参数)——presenter 不再拼任何人类可读句子
// (复审 r1 P1-1 那条「报告与代码不符」的收口:`已登录:{name}` 这类拼接全部消失)。
let text = catalog.render(Key::AccountLoginSucceeded, &[("name", name)]);
let body = column![
frame::text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(Role::OnSurface),
),
row![buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page()
)
)]
.align_y(Alignment::Center),
]
.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,
}
}
+337
View File
@@ -0,0 +1,337 @@
//! 状态 → 账户页视图模型(页面层不认识 presenter 的其它类型,只认这里)。
//!
//! 三条轨道在 UI 上只有三种「面板」:微软登录面板 / 第三方站表单 / 本地档案表单。
//! 表单里那些**输入串**是纯 UI 状态,归 [`Form`](app 层持有);从 presenter 来的只有
//! 账户列表、登录进度与倒计时。
use iced::widget::image;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache};
use super::panels::{authlib_panel, device_panel};
pub use super::panels::{AuthlibPanel, DevicePanel};
/// 三条轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Track {
/// 微软正版(设备码 / 授权码两条流)
Microsoft,
/// 第三方皮肤站(authlib-injector)
Authlib,
/// 本地档案(离线)
Offline,
}
impl Track {
/// 卡片标题的 key。
#[must_use]
pub const fn title_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackMicrosoftTitle,
Self::Authlib => Key::AccountTrackAuthlibTitle,
Self::Offline => Key::AccountTrackOfflineTitle,
}
}
/// 卡片说明的 key。
#[must_use]
pub const fn body_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackMicrosoftBody,
Self::Authlib => Key::AccountTrackAuthlibBody,
Self::Offline => Key::AccountTrackOfflineBody,
}
}
/// 卡片主按钮的 key(微软/第三方是「登录」,本地是「创建档案」)。
#[must_use]
pub const fn action_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackOpen,
Self::Authlib => Key::AccountTrackOpen,
Self::Offline => Key::AccountTrackCreate,
}
}
/// 三条轨道(卡片顺序)。
pub const ALL: [Track; 3] = [Track::Microsoft, Track::Authlib, Track::Offline];
}
/// 当前打开的面板。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Panel {
/// 只画三条轨道入口
#[default]
None,
/// 微软登录面板
Microsoft,
/// 第三方站表单
Authlib,
/// 本地档案表单
Offline,
}
/// 表单的**纯 UI 状态**(app 层持有,随消息改动)。
///
/// 密码只存在这里和「一次 `AppCommand::AddAuthlibAccount` 的载荷」里:不进日志、不进快照、
/// 不进任何回程 DTO(M114 凭据红线)。
/// **手写 `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,
/// 第三方站地址
pub server: String,
/// 登录名
pub login: String,
/// 密码(一次性的请求值)
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 {
/// 空表单(只画三条轨道)。
pub fn new() -> Self {
Self::default()
}
/// 打开某条轨道的面板(已开着同一个就用原值,避免输入被清掉)。
pub fn open(&mut self, track: Track) {
self.panel = match track {
Track::Microsoft => Panel::Microsoft,
Track::Authlib => Panel::Authlib,
Track::Offline => Panel::Offline,
};
}
/// 关掉面板(**密码一起清掉**:留着没有意义,只是多留一份凭据在内存里)。
pub fn close(&mut self) {
self.password.clear();
self.hint = None;
self.panel = Panel::None;
}
/// 提交之后清掉一次性凭据(成功与否都清:重试用新输入)。
pub fn clear_secret(&mut self) {
self.password.clear();
}
/// 本地档案名是否已填(`trim` 后非空才算)。
#[must_use]
pub fn offline_ready(&self) -> bool {
// 与 host 写盘前用的是**同一份判据**(`unlucky_presenter::dto::valid_offline_name`):
// 按钮亮着却必然失败,比按钮灰着更糟(复审 r1 P2-2 的另一半)。
unlucky_presenter::dto::valid_offline_name(self.offline_name.trim())
}
/// 第三方站表单是否已填(三项都非空;地址的语法问题由 host 的规范化兜)。
#[must_use]
pub fn authlib_ready(&self) -> bool {
!self.server.trim().is_empty() && !self.login.trim().is_empty() && !self.password.is_empty()
}
}
/// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
/// 稳定 id(切/删/重登都按它派命令)
pub id: String,
/// 展示名
pub name: String,
/// 首字母块的字
pub monogram: String,
/// 首字母块色号(0..6,`profile_id` 稳定推导)
pub tone: u8,
/// 轨道
pub kind: AccountKind,
/// 轨道文案(i18n)
pub kind_label: String,
/// 「添加于 {date}」
pub added: String,
/// 副行:服务器 / 登录名(没有就空)
pub detail: String,
/// 是否是当前启动账户
pub active: bool,
/// 裁好的头像(`None` = 画首字母块)
pub head: Option<image::Handle>,
/// 皮肤原图(皮肤预览区)
pub skin: Option<image::Handle>,
}
/// 整页视图模型。
#[derive(Debug, Clone, PartialEq)]
pub struct Page {
/// 账户行
pub rows: Vec<Row>,
/// 当前启动账户在 `rows` 里的下标
pub active: Option<usize>,
/// 打开的面板
pub panel: Panel,
/// 表单值(渲染要读)
pub form: Form,
/// 微软登录面板
pub device: DevicePanel,
/// 第三方轨面板
pub authlib: AuthlibPanel,
/// 校验通过的服务器名(表单上显示)
pub checked_server: String,
/// 版面宽度(**中栏可用宽**,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` 是中栏可用宽)。
pub fn build(
state: &AppState,
form: &Form,
avatars: &mut Cache,
lang: Lang,
width: f32,
) -> Self {
let rows: Vec<Row> = state
.accounts
.iter()
.map(|account| row_of(account, avatars, lang))
.collect();
let active = rows.iter().position(|row| row.active);
let device = device_panel(&state.device_login, state.device_code_remaining());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) {
form.panel
} else {
Panel::Microsoft
};
let (authlib, checked_server) = authlib_panel(&state.authlib_login);
// 「校验通过」的服务器名只在该面板打开且地址一致时显示(换了地址就不再是它)
let checked_server = if form.server.trim().is_empty()
|| state.authlib_login.server.trim().is_empty()
|| form.server.trim() != state.authlib_login.server.trim()
{
String::new()
} else {
checked_server
};
Page {
rows,
active,
panel,
form: form.clone(),
device,
authlib,
checked_server,
notice: state.account_notice.as_ref().map(Notice::of),
hint: form.hint,
width,
}
}
/// 当前账户行。
#[must_use]
pub fn active_row(&self) -> Option<&Row> {
self.active.and_then(|index| self.rows.get(index))
}
/// 有没有账户(空态判据)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
/// 皮肤预览区要画的那张图(当前账户有皮肤才画)。
#[must_use]
pub fn active_skin(&self) -> Option<&image::Handle> {
self.active_row().and_then(|row| row.skin.as_ref())
}
}
/// 单行。
fn row_of(account: &AccountView, avatars: &mut Cache, lang: Lang) -> Row {
let text = lang.catalog();
let avatar: Avatar = avatars.resolve(&account.id, account.skin.as_ref());
let kind_label = match account.kind {
AccountKind::Microsoft => text.text(Key::AccountKindMicrosoft),
AccountKind::Authlib => text.text(Key::AccountKindAuthlib),
AccountKind::Offline => text.text(Key::AccountKindOffline),
};
let detail = if !account.server_url.is_empty() {
text.render(
Key::AccountServer,
&[("server", account.server_url.as_str())],
)
} else {
account.login_name.clone()
};
Row {
id: account.id.clone(),
name: account.name.clone(),
monogram: account.monogram(),
tone: account.monogram_tone(),
kind: account.kind,
kind_label: kind_label.to_string(),
added: text.render(Key::AccountAddedOn, &[("date", account.created.as_str())]),
detail,
active: account.active,
head: avatar.head,
skin: avatar.skin,
}
}
+141
View File
@@ -0,0 +1,141 @@
//! 两条登录流的面板态(微软设备码/授权码 + 第三方站校验/登录)。
//!
//! 从 `model.rs` 拆出来守 400 行硬线:这些类型是「UI 要画什么」的枚举,
//! 判定逻辑(倒计时 → 已过期、服务发现 → 可用)也在这里,页面与表单只消费结果。
//!
//! ## 为什么这里**没有** `message` / `detail` 字段(M114 r3 / 复审 r2 P2-2)
//!
//! 后端的原始诊断串是中文人话,只该进日志。r2 时它们还以 `message`/`detail` 存在于此,
//! 「页面把它们画上屏」只能靠一条**平行实现**的取样断言去拦(复审用变异证明那条断言是哑的)。
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevicePanel {
/// 没有登录在跑
Hidden,
/// 正在申请设备码(进度文案由页面按 i18n 出)
Requesting,
/// 设备码等待用户操作
Code {
/// 用户码
code: String,
/// 验证链接
link: String,
/// 剩余秒数
remaining: Option<u64>,
/// 是否已过期
expired: bool,
},
/// 授权码流:等浏览器回调
Browser {
/// 授权链接(可复制)
link: String,
/// 剩余秒数
remaining: Option<u64>,
/// 是否已过期
expired: bool,
},
/// 失败(**只有原因码**;原文在 presenter 的日志里)
Failed {
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
},
/// 成功
Succeeded {
/// 账户名
name: String,
},
}
/// 第三方轨面板的可见状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthlibPanel {
/// 没在跑
Idle,
/// 正在校验/登录
Running,
/// 地址校验通过(服务发现拿到服务器名)
Ready {
/// 服务器名
server_name: String,
},
/// 失败(**只有原因码**;原文在 presenter 的日志里)
Failed {
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
},
/// 成功
Succeeded {
/// 账户名
name: String,
},
}
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
let expired = remaining == Some(0);
match view.phase {
DeviceLoginPhase::Idle => DevicePanel::Hidden,
DeviceLoginPhase::Requesting => DevicePanel::Requesting,
DeviceLoginPhase::Waiting => {
if view.user_code.trim().is_empty() {
DevicePanel::Browser {
link: view.verification_uri.clone(),
remaining,
expired,
}
} else {
DevicePanel::Code {
code: view.user_code.clone(),
link: view.qr_link.clone(),
remaining,
expired,
}
}
}
DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other),
},
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(),
},
}
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase {
DeviceLoginPhase::Idle => (AuthlibPanel::Idle, String::new()),
DeviceLoginPhase::Requesting => (AuthlibPanel::Running, String::new()),
DeviceLoginPhase::Waiting => (
AuthlibPanel::Ready {
server_name: view.server_name.clone(),
},
view.server_name.clone(),
),
DeviceLoginPhase::Succeeded => (
AuthlibPanel::Succeeded {
name: view.account_name.clone(),
},
view.server_name.clone(),
),
DeviceLoginPhase::Failed => (
AuthlibPanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other),
},
String::new(),
),
}
}
impl AuthlibPanel {
/// 是否正在跑(此时表单字段禁用,避免改了一半的地址被提交)。
#[must_use]
pub fn busy(&self) -> bool {
matches!(self, Self::Running { .. })
}
}
+274
View File
@@ -0,0 +1,274 @@
//! 账户列表:当前账户卡片 + 全部账户行 + 皮肤预览区。
use iced::widget::{column, container, image, row, space};
use iced::{Alignment, ContentFit, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
use crate::theme::Ui;
use crate::pages::account::{model::Page as Model, widgets};
use crate::pages::Message as PageMessage;
use super::Message as Account;
/// 当前账户卡片(含皮肤预览区)。
pub fn active_card<'a>(
ui: &'a Ui,
page: &'a Model,
catalog: Catalog,
lang: Lang,
) -> Element<'a, PageMessage> {
let Some(current) = page.active_row() else {
return container(space::vertical()).into();
};
let _ = lang;
let mut actions = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountRelogin),
Account::Relogin(current.id.clone()).into_page(),
),
));
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountRemove),
Account::Remove(current.id.clone()).into_page(),
),
));
let mut name_line = row![
frame::text_role(
ui,
TypeRole::TitleMedium,
true,
&current.name,
ui.color(Role::OnSurface),
),
widgets::badge(ui, &current.kind_label, false),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if current.active {
name_line = name_line.push(widgets::badge(
ui,
catalog.text(Key::AccountActiveChip),
true,
));
}
let mut identity = column![
name_line,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
&current.added,
ui.color(Role::OnSurfaceVariant),
),
]
.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),
container(identity).width(Length::Fill),
actions,
]
.spacing(ui.space(Space::X4))
.align_y(Alignment::Center);
let mut body = column![
widgets::section(ui, catalog.text(Key::AccountCurrentTitle), None),
left,
]
.spacing(ui.space(Space::X4));
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,
skin: iced::widget::image::Handle,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let size = ui.space(Space::X8) * 2.0;
row![
container(
image(skin)
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.content_fit(ContentFit::Contain),
)
.width(Length::Fixed(size))
.height(Length::Fixed(size)),
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::AccountSkinHint),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1)),
]
.spacing(ui.space(Space::X4))
.align_y(Alignment::Center)
.into()
}
/// 「全部账户」列表。
pub fn list<'a>(
ui: &'a Ui,
page: &'a Model,
catalog: Catalog,
lang: Lang,
) -> Element<'a, PageMessage> {
let _ = lang;
let mut items = column![widgets::section(
ui,
catalog.text(Key::AccountListTitle),
None
)]
.spacing(ui.space(Space::X3));
for row in &page.rows {
items = items.push(item(ui, row, catalog));
}
frame::card(ui, items)
}
/// 一行账户。
fn item<'a>(
ui: &'a Ui,
row: &'a crate::pages::account::model::Row,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut detail = column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
&row.name,
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
&row.added,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1));
if !row.detail.is_empty() {
detail = detail.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
&row.detail,
ui.color(Role::OnSurfaceVariant),
));
}
let mut trailing = row![widgets::badge(ui, &row.kind_label, false),]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if row.active {
trailing = trailing.push(widgets::badge(
ui,
catalog.text(Key::AccountActiveChip),
true,
));
} else {
trailing = trailing.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountSwitch),
Account::Switch(row.id.clone()).into_page(),
),
));
}
trailing = trailing.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountRemove),
Account::Remove(row.id.clone()).into_page(),
),
));
row![
widgets::avatar(ui, row, widgets::AVATAR_LIST),
container(detail).width(Length::Fill),
trailing,
]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.padding(iced::Padding::from([0.0, ui.space(Space::X2)]))
.into()
}
+330
View File
@@ -0,0 +1,330 @@
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:模型 + i18n 查表这一路又出现中文(视图层由类型禁画原文,见 `tests_text` 的边界说明) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppEvent, AuthlibLoginView,
CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::model::{DevicePanel, Form, Page, Panel, Track};
use super::tests_text::{has_cjk, on_screen};
fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
AccountView {
id: format!("account:{name}"),
name: name.to_string(),
kind,
profile_id: "0f8f1d3c4b5a69788796a5b4c3d2e1f0".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active,
server_url: String::new(),
skin: None,
}
}
fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
}
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 状态与视图模型里都不许出现。
///
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`)。
/// 视图层不在此列:面板态里已无 `message`/`detail` 字段,想画原文是编译错误。
#[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:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
failure: Some(LoginFailure::Denied),
..DeviceLoginView::new()
},
)));
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://littleskin.cn/api/yggdrasil/".to_string(),
server_name: String::new(),
message: format!("服务器回包异常 {JWT}"),
failure: Some(LoginFailure::Denied),
account_name: String::new(),
},
)));
// ① 状态层已经脱敏(这一条抓得住「摘掉 state 层 redact」的变异)
assert!(
!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。
///
/// 抓得住:`failure_body_key` 的码→key 映射被摘/接错;DTO 或状态层把中文混进模型字段。
/// 抓不住(复审 r2 P2-2 实测过):视图层把原文画上屏——这条断言是**平行实现**。
/// 那道回归改由**类型**拦:`DevicePanel`/`AuthlibPanel` 已无 `message`/`detail` 字段。
/// 也抓不住: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();
let page = build(&state, &Form::new());
assert!(page.is_empty());
assert_eq!(page.panel, Panel::None);
assert!(page.active_row().is_none());
let mut keys: Vec<&str> = Track::ALL
.iter()
.map(|track| track.title_key().name())
.collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), 3);
}
/// 设备码:**倒计时是人话格式(mm:ss)**,过期后是「已过期」而不是继续转圈。
#[test]
fn the_device_code_panel_shows_a_countdown_and_expires() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, ""),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
code,
remaining,
expired,
..
} => {
assert_eq!(code, "ABCD-EFGH");
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 =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1_200));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
remaining, expired, ..
} => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
}
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
}
// 授权码流也走同一条倒计时(没有用户码时是 Browser 面板)
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::browser_waiting("https://login.microsoftonline.com/x", 300, None),
)));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Browser { .. }));
}
/// 没有皮肤 = 首字母块(不是白块、不是假皮肤);坏图也必须回落;无皮肤时**明说**。
#[test]
fn a_third_party_account_falls_back_to_the_monogram() {
let mut state = AppState::new();
state.accounts = vec![
account("Alex", AccountKind::Authlib, true),
account("Steve", AccountKind::Microsoft, false),
];
let page = build(&state, &Form::new());
assert!(
page.rows.iter().all(|row| row.head.is_none()),
"没有皮肤就不许有图"
);
assert!(page.rows.iter().all(|row| !row.monogram.is_empty()));
assert!(page.active_skin().is_none(), "皮肤预览区没有图就不画图");
let text = on_screen(&page);
assert!(!has_cjk(&text));
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 panic、不白块)
state.accounts[0].skin = Some(SkinImage {
png: b"not a png".to_vec(),
source: "test".to_string(),
});
let page = build(&state, &Form::new());
assert!(page.rows[0].head.is_none());
}
/// 离线档案的说明**必须**带「无法连接启用正版验证的服务器」这条限定(中英都要)。
#[test]
fn the_offline_track_keeps_the_agreed_wording() {
for lang in Lang::ALL {
let catalog = lang.catalog();
for key in [Key::AccountTrackOfflineBody, Key::AccountOfflineWarning] {
let text = catalog.text(key);
assert!(
text.contains("cannot join servers that require a genuine Minecraft account")
|| text.contains("无法连接启用正版验证的服务器"),
"{} 的 {key:?} 丢了「仅本地/隔离网络测试用」口径:{text}",
lang.tag()
);
assert!(
!text.contains("migration") && !text.contains("迁移"),
"不许改写成「兼容旧启动器迁移」:{text}"
);
}
}
}
/// **离线轨的失败必须可见**(复审 r1 P2-2):横幅进模型、文案走 i18n、原文不上屏。
#[test]
fn a_failed_offline_add_is_visible() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
AccountNotice::new(
AccountNoticeCode::OfflineNameInvalid,
"ab",
"离线名字必须是 3–16 位字母/数字/下划线,收到 \"ab\"",
),
))));
let page = build(&state, &Form::new());
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(), "空名字不能提交");
form.offline_name = "ab".to_string();
assert!(
!form.offline_ready(),
"太短也不许提交(与 host 同一条规则)"
);
assert!(
!unlucky_presenter::dto::valid_offline_name("ab"),
"UI 判据 = host 那份规则"
);
form.offline_name = "Alex".to_string();
assert!(form.offline_ready());
form.open(Track::Authlib);
assert!(!form.authlib_ready(), "地址/登录名/密码缺一不可");
form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
form.login = "alex".to_string();
form.password = "hunter2".to_string();
assert!(form.authlib_ready());
form.clear_secret();
assert!(form.password.is_empty());
assert!(!form.authlib_ready());
}
/// 登录阶段的可判据性:Requesting / Waiting / Failed 各自映射到不同面板。
#[test]
fn login_phases_map_to_distinct_panels() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..DeviceLoginView::new()
},
)));
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"),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Failed { .. }
));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::new(),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Hidden
));
}
@@ -0,0 +1,97 @@
//! 「**模型这一层会给出什么**」的取样器:视图模型字段 + **按码做的 i18n 查表**,拼成一串。
//!
//! ## 边界(复审 r2 P2-2 订正,别把这条读成「视图覆盖」)
//!
//! 这是**平行实现**:它自己调 `catalog` 查表,**不调用** `login::panel` / `forms::status_line`,
//! 也不遍历 iced 的元素树。所以它证明的是「模型 + i18n 表里没有中文」,**不证明**「视图真的照它画」。
//!
//! 视图层靠两道别的闸:
//! 1. **类型**:`DevicePanel` / `AuthlibPanel` 里**没有** `message`/`detail` 字段(r3 删掉了),
//! 想画后端原文是编译错误,不是测试问题;
//! 2. **出图**:`DevDocs/artifacts/m114/after/*` 由人(tower/复审)逐张看。
//!
//! 为什么单独一个文件:`pages/account/tests.rs` 贴着 400 行硬线,而这段是**纯取样**
//! (不给任何断言)——断言都在 `tests.rs` 里。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode;
use super::login::failure_body_key;
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
pub(super) 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);
out.push_str(&row.monogram);
out.push_str(&row.kind_label);
out.push_str(&row.added);
out.push_str(&row.detail);
}
out.push_str(&page.form.offline_name);
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 => out.push_str(catalog.text(Key::AccountDeviceRequesting)),
DevicePanel::Code {
code,
link,
remaining,
expired,
} => {
out.push_str(code);
out.push_str(link);
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, .. } => 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::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
}
}
match &page.authlib {
AuthlibPanel::Idle => {}
AuthlibPanel::Running => out.push_str(catalog.text(Key::AccountServerChecking)),
AuthlibPanel::Ready { server_name } => out.push_str(server_name),
AuthlibPanel::Failed { failure } => out.push_str(failure.code()),
AuthlibPanel::Succeeded { name } => out.push_str(name),
}
out
}
/// 有没有中日韩统一表意文字(语言泄漏的最直接判据)。
pub(super) 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}'))
}
@@ -0,0 +1,386 @@
//! 本页小件:头像块(首字母块 / 真图)、章节标题、轨道徽标。
//!
//! ## 令牌纪律
//!
//! 本文件**零**色值 / 字号 / 半径字面量:颜色一律 [`Ui::color`],尺寸走 `Space` /
//! `LayoutMetrics` / `ui.style(..)`。首字母块的「色号」也只是**挑哪一个角色**,
//! 不是一个具体颜色——深浅两套主题各自成立。
use iced::widget::text::LineHeight;
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 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;
/// 当前账户卡片上的头像边长。
pub const AVATAR_ACTIVE: f32 = 56.0;
/// 色号 → (底色, 字色) 角色对。
///
/// 只有「挑角色」这一步是硬的:六个色号对应六个容器角色,深浅主题各有一套值,
/// 所以同一个账户在两种主题下都成立,也不会撞出低对比。
fn tone(ui: &Ui, tone: u8) -> (iced::Color, iced::Color) {
match tone % 6 {
0 => (
ui.color(Role::PrimaryContainer),
ui.color(Role::OnPrimaryContainer),
),
1 => (
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
),
2 => (
ui.color(Role::TertiaryContainer),
ui.color(Role::OnTertiaryContainer),
),
3 => (
ui.color(Role::SurfaceContainerHighest),
ui.color(Role::OnSurface),
),
4 => (ui.color(Role::PrimaryFixed), ui.color(Role::OnPrimaryFixed)),
_ => (
ui.color(Role::SurfaceVariant),
ui.color(Role::OnSurfaceVariant),
),
}
}
/// 提示条的语义档(决定图标块与图标字形用哪一对角色)。
#[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()
});
// 标题与 `Code: …` 同**一行**:`Code` 只是取证用的稳定 id,不值得占一整行高度
// (1180×760 下多一行就把「All accounts」第三行挤到折线下)。
let mut title_row = iced::widget::Row::new()
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.push(frame::text_role(
ui,
TypeRole::TitleSmall,
true,
title,
ui.color(Role::OnSurface),
));
if let Some(code) = code {
title_row = title_row.push(frame::text_role_owned(
ui,
TypeRole::LabelSmall,
false,
code,
ui.color(Role::OnSurfaceVariant),
));
}
let mut lines = iced::widget::Column::new().spacing(ui.space(Space::X1));
lines = lines.push(title_row);
lines = lines.push(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
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`,
/// 永不空串;③ 两者尺寸完全一致,切换时不跳版。
pub fn avatar<'a>(ui: &'a Ui, row: &Row, size: f32) -> Element<'a, Message> {
if let Some(handle) = &row.head {
return container(
image(handle.clone())
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.content_fit(ContentFit::Cover),
)
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.style(move |_theme| container::Style {
border: Border {
radius: ui.radius(ShapeScale::Full, size),
..Border::default()
},
..container::Style::default()
})
.into();
}
monogram(ui, &row.monogram, row.tone, size)
}
/// 首字母块(没有皮肤时的头像位)。
pub fn monogram<'a>(ui: &'a Ui, letter: &str, tone_index: u8, size: f32) -> Element<'a, Message> {
let (background, foreground) = tone(ui, tone_index);
let style = ui.emphasized(TypeRole::TitleMedium);
container(
text(letter.to_string())
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(foreground),
)
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: ui.radius(ShapeScale::Full, size),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 章节标题(`TitleMedium` + 可选副文案)。
pub fn section<'a>(ui: &'a Ui, title: &'a str, support: Option<&'a str>) -> Element<'a, Message> {
let mut column = iced::widget::column![frame::text_role(
ui,
TypeRole::TitleMedium,
true,
title,
ui.color(Role::OnSurface),
)]
.spacing(ui.space(Space::X1));
if let Some(support) = support {
column = column.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
support,
ui.color(Role::OnSurfaceVariant),
));
}
column.into()
}
/// 轨道徽标(小药丸:`LabelMedium` + `SecondaryContainer`)。
pub fn badge<'a>(ui: &'a Ui, label: &'a str, active: bool) -> Element<'a, Message> {
let (background, foreground) = if active {
(
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
)
} else {
(
ui.color(Role::SurfaceContainerHighest),
ui.color(Role::OnSurfaceVariant),
)
};
let style = ui.style(TypeRole::LabelMedium);
container(
text(label.to_string())
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(foreground),
)
.padding(iced::Padding::from([0.0, ui.space(Space::X2)]))
.height(ui.metrics().control_height - ui.space(Space::X2) * 2.0)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 一条「标签 + 值」的元信息行(列表行副文案之外的结构化信息)。
pub fn meta_line<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
row![
frame::text_role(
ui,
TypeRole::LabelSmall,
false,
label,
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
value,
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
+4
View File
@@ -91,6 +91,10 @@ pub enum Message {
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
Content(content::Message),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
Account(crate::pages::account::Message),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
+1
View File
@@ -109,6 +109,7 @@ pub mod harness;
pub mod layout;
pub mod model;
pub mod account;
pub mod content;
mod download;
mod instances;
+3
View File
@@ -69,6 +69,9 @@ impl Screen {
Route::Content => Screen::Content,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
// 账户页(M114)不是本枚举四页之一:它由 `pages::account` 自己的树画
// (路由在 `app/view.rs` 里先被拦下),harness 的单页矩阵也不含它。
Route::Accounts => return None,
Route::Agent => return None,
})
}
+47 -14
View File
@@ -21,13 +21,14 @@ mod nav;
/// 用 `#[path]` 挂到外壳的命名空间。
///
/// 为什么是 `#[path]` 而不是在 `pages/mod.rs` 加一行 `mod agent;`:`pages/mod.rs` 是四条页面线
/// 共用的声明文件(本 mission 的 scope 没给它),而右栗是**外壳的一部分**(它挂在任何页面右边)。
/// 共用的声明文件(M104 的 scope 没给它),而右栏是**外壳的一部分**(它挂在任何页面右边)。
/// 先例是 `app/mod.rs` 挂 `../components/mod.rs`。物理路径只有一份,逻辑名字有两个用途:
/// 宿主(右栗)归外壳,视图(会话组件)归页面目录。
/// 宿主(右栏)归外壳,视图(会话组件)归页面目录。
#[path = "../pages/agent/mod.rs"]
pub(crate) mod agent;
pub use nav::NavItem;
// M114:轨底账户头像位的状态也归外壳(`NavItem` + `RailAccount` 一起导出)。
pub use nav::{NavItem, RailAccount};
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
@@ -47,17 +48,45 @@ 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 起多两样东西:右侧助手栏与拖拽取指针层;
/// M114 起导航轨还会画轨底的账户头像位)。
///
/// 分工写死:
///
/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`,
/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、
/// 导航轨、右栏、根容器底色;
/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88 − 右栏`,
/// 而页面自己的 `layout::frame_metrics` 仍以**中栏可用宽**为输入(M104 的 `content_width`)。
///
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
///
/// 产品页入口:**内容槽位 + 背景层 + 右栏 + 拖拽层 + 轨底账户头像位**。
///
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
// 9 个形参:M104 的 `right`/`overlay` 之后 M106 又加了 `lang`(导航轨标签要按语言取词)。
// 拆成结构体要动 `app/view.rs` 的两处调用点,而那正是 M104/M105 反复设锚点的地方——
// 等 M105 那轮 rebase(`draft` 与 `lang` 两个形参都要留)时一并结构体化,先就地 `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,
nav_state: NavRail,
app_title: &'a str,
background: Option<Element<'a, Message>>,
content: Element<'a, Message>,
@@ -66,12 +95,16 @@ pub fn view_with_background<'a>(
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
// 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
// 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
// 右栏(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
// 中栏以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
// M106:导航轨标签按当前界面语言取词(`lang` 一路从 `App::lang()` 递进来)。
let mut work = row![nav::rail(ui, selected, 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);
}
+51 -19
View File
@@ -23,6 +23,12 @@ use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang};
mod account;
pub use account::RailAccount;
use account::account_item;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
@@ -42,10 +48,12 @@ pub enum NavItem {
Settings,
/// AI 助手。
Ai,
/// 账户页(**不进主序列**:入口是轨底的头像位,见 [`RailAccount`])。
Account,
}
impl NavItem {
/// 全部占位项(顺序 = 导航栏自上而下)。
/// 全部**主序列**项(顺序 = 导航栏自上而下)。账户不在其中:它固定贴在轨底。
pub const ALL: [NavItem; 5] = [
NavItem::Instances,
NavItem::Download,
@@ -62,6 +70,8 @@ impl NavItem {
NavItem::Logs => Key::NavLogs,
NavItem::Settings => Key::NavSettings,
NavItem::Ai => Key::NavAi,
// M114:轨底头像位的可访问名(不在主序列里,但 `router` 的 `nav()` 会问到它)。
NavItem::Account => Key::AccountTitle,
}
}
@@ -81,6 +91,7 @@ impl NavItem {
NavItem::Logs => IconKind::Logs,
NavItem::Settings => IconKind::Settings,
NavItem::Ai => IconKind::Ai,
NavItem::Account => IconKind::User,
}
}
}
@@ -89,27 +100,41 @@ impl NavItem {
pub(super) fn rail<'a>(
ui: &'a Ui,
selected: NavItem,
account: &RailAccount,
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
let metrics = ui.metrics();
let indicator = indicator_size(metrics);
let compact = account.compact;
let items = NavItem::ALL
.iter()
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced, lang))
// `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。
.filter(|item| **item != NavItem::Account)
.map(|item| rail_item(ui, *item, *item == selected, indicator, compact, forced, lang))
.collect::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
//
// 轨底色与其它面板同源(M93):`ui.layer` 已含面板 α,导航轨是面板层的一部分。
container(column(items).spacing(metrics.nav_item_gap))
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
//
// 账户头像位**固定贴在轨底**(M114):主序列四项在上,中间一段 Fill 把头像顶到底部——
// 于是「账户」不是第 6 个导航项,轨的长度也不会随页数增长。
container(
column![
column(items).spacing(metrics.nav_item_gap),
iced::widget::space::vertical(),
account_item(ui, selected, account, indicator, forced),
]
.height(Length::Fill),
)
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
fn rail_item<'a>(
@@ -117,6 +142,7 @@ fn rail_item<'a>(
item: NavItem,
selected: bool,
indicator: (f32, f32),
compact: bool,
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
@@ -152,26 +178,32 @@ fn rail_item<'a>(
});
let item_height = nav_item_height(ui, indicator_height);
let height = if compact {
indicator_height
} else {
item_height
};
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
let stack = column![
pill,
label(
let mut stack = column![pill]
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center)
.height(height);
if !compact {
// M114 的「窄高收成图标条」+ M106 的「按语言取词」并存。
stack = stack.push(label(
ui,
TypeRole::LabelMedium,
false,
item.label(lang),
label_color
),
]
.spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center)
.height(item_height);
label_color,
));
}
stack = stack.spacing(ui.space(Space::X1));
button(stack)
.width(nav_rail_width(ui.metrics()))
.height(item_height)
.height(height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
+220
View File
@@ -0,0 +1,220 @@
//! 导航轨**底部账户头像位**(M114):状态结构体 + 那个按钮。
//!
//! 为什么单独一个文件:`shell/nav.rs` 贴着 400 行硬线,而这块是 M114 新增的一整块
//! (未登录/已登录两态、窄高收成图标条的判据、点击跳账户页)。
use iced::widget::{button, column, container, text};
use iced::{Alignment, Background, Border, Element, Length, Shadow, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::shell::overridden;
use crate::theme::Ui;
use super::{nav_item_height, nav_rail_width, NavItem};
/// 导航轨底部的**账户头像位**状态(M114)。
///
/// 为什么单独传而不是塞进 `NavItem`:它不是主序列里的第 6 项(那会让轨道越堆越长),
/// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 vs 首字母块)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RailAccount {
/// 是否已登录(至少有一个账户)
pub signed_in: bool,
/// 首字母块的字(已登录时有意义)
pub initial: String,
/// 账户名(已登录时作为标签;未登录时是 i18n 的「未登录」)
pub label: String,
/// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位)
pub compact: bool,
}
impl RailAccount {
/// 未登录态。
pub fn signed_out(label: impl Into<String>) -> Self {
Self {
signed_in: false,
initial: String::new(),
label: label.into(),
compact: false,
}
}
/// 已登录态。
pub fn signed_in(initial: impl Into<String>, label: impl Into<String>) -> Self {
Self {
signed_in: true,
initial: initial.into(),
label: label.into(),
compact: false,
}
}
/// 图标条形态(导航项太多/太窄时收起文字标签)。
#[must_use]
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
}
}
/// 轨底的账户头像位:未登录 = 描边剪影,已登录 = 首字母块(两态在**形状**上就不同)。
pub(super) fn account_item<'a>(
ui: &'a Ui,
selected: NavItem,
account: &RailAccount,
indicator: (f32, f32),
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let (indicator_width, indicator_height) = indicator;
let active = selected == NavItem::Account;
let label_color = if active {
ui.color(Role::Secondary)
} else {
ui.color(Role::OnSurfaceVariant)
};
let avatar: Element<'a, Message> = if account.signed_in {
let style = ui.emphasized(TypeRole::TitleMedium);
container(
text(account.initial.clone())
.size(style.size_px)
.line_height(iced::widget::text::LineHeight::Absolute(iced::Pixels(
style.line_height_px,
)))
.font(ui.font_of(style))
.color(ui.color(Role::OnSecondaryContainer)),
)
.width(Length::Fixed(avatar_size(ui, indicator_width)))
.height(Length::Fixed(avatar_size(ui, indicator_width)))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, avatar_size(ui, indicator_width)),
..Border::default()
},
..container::Style::default()
})
.into()
} else {
container(icons::view::<Message>(
IconKind::User,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fixed(avatar_size(ui, indicator_width)))
.height(Length::Fixed(avatar_size(ui, indicator_width)))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
border: Border {
color: ui.color(Role::Outline),
width: 1.0,
radius: ui.radius(ShapeScale::Full, avatar_size(ui, indicator_width)),
},
..container::Style::default()
})
.into()
};
let height = nav_item_height(ui, indicator_height);
let mut stack = column![avatar]
.spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center);
if !account.compact {
// 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态,
// 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。
let style = ui.style(TypeRole::LabelSmall);
stack = stack.push(
text(account.label.clone())
.size(style.size_px)
.line_height(iced::widget::text::LineHeight::Absolute(iced::Pixels(
style.line_height_px,
)))
.font(ui.font_of(style))
.color(label_color),
);
}
button(stack)
.width(nav_rail_width(ui.metrics()))
.height(height)
.padding(0)
.style(move |_theme: &IcedTheme, status| button::Style {
background: match overridden(forced, status) {
button::Status::Hovered if !active => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed if !active => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
_ => None,
},
text_color: label_color,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Select(NavItem::Account))
.into()
}
/// 头像位边长 = 指示器宽 − 16,但不超过 48(12b 里没有这个控件,按令牌栅格取整)。
fn avatar_size(ui: &Ui, indicator_width: f32) -> f32 {
(indicator_width - Space::X4.dp()).min(ui.space(Space::X8) + Space::X2.dp())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_two_states_are_distinguishable() {
let out = RailAccount::signed_out("Not signed in");
assert!(!out.signed_in);
let on = RailAccount::signed_in("S", "Steve");
assert!(on.signed_in);
assert_eq!(on.initial, "S");
let compacted = on.clone().compact(true);
assert!(compacted.compact, "compact(true) 必须收成图标条");
}
#[test]
fn the_avatar_slot_fits_the_rail() {
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
let ui = Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
);
let width = super::super::indicator_size(ui.metrics()).0;
let size = avatar_size(&ui, width);
assert!(size > 0.0);
assert!(
size <= nav_rail_width(ui.metrics()),
"头像位不能比导航轨还宽({size} > {})",
nav_rail_width(ui.metrics())
);
}
}
+5
View File
@@ -112,6 +112,7 @@ macro_rules! finish {
({ $($acc:tt)* }) => { catalog! { $($acc)* } };
}
mod account;
mod agent;
mod content;
mod core_text;
@@ -131,6 +132,7 @@ mod golden;
#[cfg(test)]
mod tests;
use account::account_entries;
use agent::agent_entries;
use content::content_entries;
use core_text::core_text_entries;
@@ -172,6 +174,9 @@ nav_entries! {
// M109:内容库页。与 M107 同一条纪律——本域排在 main 已占住的链尾**之后**,
// 于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version,最后 content。
content_entries,
// M114:账户页。同理**追加在 main 已占住的链尾之后**(先 content 再 account),
// 金表只追加不重排。
account_entries,
finish,
{}
}
+113
View File
@@ -0,0 +1,113 @@
//! 账户页文案(M114 三轨:微软正版 / 第三方皮肤站 / 本地档案)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域**追加在装配链末尾**,
//! 所以金表(`catalog::golden`)里这批 key 也排在最后。
//!
//! ## 口径(不许改写成别的说法)
//!
//! 本地档案的说明必须带「仅本地/隔离网络测试用,无法连接启用正版验证的服务器」——
//! 这是 README 已定的口径(`AccountTrackOfflineBody` / `AccountOfflineWarning` 两处都写全),
//! 不许写成「兼容旧启动器迁移」这种暗示能进正版服的说法。
//!
//! 文案里**没有任何 token 字样**:账户页从不展示凭据(M114 审核红线),
//! 「密码只用于这一次登录」这条提示是唯一提到密码的地方。
macro_rules! account_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 页头 / 列表
AccountTitle => ("Accounts", "账户"),
AccountSubtitle => ("Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
AccountListTitle => ("All accounts", "全部账户"),
AccountAddedOn => ("Added {date}", "添加于 {date}"),
AccountActiveChip => ("Active", "当前"),
AccountSwitch => ("Use for launch", "切换为启动账户"),
AccountRelogin => ("Sign in again", "重新登录"),
AccountRemove => ("Remove", "删除"),
AccountKindMicrosoft => ("Microsoft", "微软正版"),
AccountKindAuthlib => ("Third-party skin server", "第三方皮肤站"),
AccountKindOffline => ("Local profile", "本地档案"),
AccountServer => ("Server {server}", "服务器 {server}"),
// ── 空态
AccountEmptyTitle => ("No accounts yet", "还没有账户"),
AccountEmptyBody => ("Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
AccountEmptyAction => ("Add an account", "添加账户"),
// ── 三条轨道的入口卡
AccountTrackMicrosoftTitle => ("Microsoft account", "微软正版"),
AccountTrackMicrosoftBody => ("Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
AccountTrackAuthlibTitle => ("Third-party skin server", "第三方皮肤站"),
AccountTrackAuthlibBody => ("An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
AccountTrackOfflineTitle => ("Local profile", "本地档案"),
AccountTrackOfflineBody => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
AccountTrackOpen => ("Sign in", "登录"),
AccountTrackCreate => ("Create profile", "创建档案"),
AccountDialogClose => ("Close", "关闭"),
// ── 本地档案表单
AccountOfflineNameLabel => ("Profile name", "档案名"),
AccountOfflineNameHint => ("3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
AccountOfflineSubmit => ("Create profile", "创建档案"),
AccountOfflineWarning => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
// ── 微软登录面板(设备码 / 授权码)
AccountDeviceCodeLabel => ("Enter this code", "输入这个用户码"),
AccountDeviceCodeCopy => ("Copy code", "复制用户码"),
AccountDeviceLinkCopy => ("Copy link", "复制链接"),
AccountDeviceCountdown => ("{time} left", "剩 {time}"),
AccountDeviceExpired => ("This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
AccountDeviceRetry => ("Try again", "重试"),
AccountDeviceCancel => ("Cancel", "取消"),
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
AccountDeviceUseCode => ("Use a device code instead", "改用设备码"),
// ── 第三方站表单
AccountServerLabel => ("Server address", "服务器地址"),
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
AccountServerProbe => ("Check server", "校验服务器"),
AccountLoginNameLabel => ("Email or username", "邮箱或用户名"),
AccountPasswordLabel => ("Password", "密码"),
AccountPasswordHint => ("Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
AccountAuthlibSubmit => ("Sign in", "登录"),
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.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
// ── 导航轨底部头像
AccountRailSignedOut => ("Not signed in", "未登录"),
AccountDeviceHint => ("Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
AccountDeviceBrowserHint => ("Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
AccountServerChecking => ("Checking the server…", "正在校验服务器…"),
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…", "正在联系微软…"),
AccountLoginSucceeded => ("Signed in as {name}", "已登录 {name}"),
// ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 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.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
} }
};
}
pub(crate) use account_entries;
+80
View File
@@ -304,4 +304,84 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
// ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排)
("AccountTitle", "Accounts", "账户"),
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
("AccountListTitle", "All accounts", "全部账户"),
("AccountAddedOn", "Added {date}", "添加于 {date}"),
("AccountActiveChip", "Active", "当前"),
("AccountSwitch", "Use for launch", "切换为启动账户"),
("AccountRelogin", "Sign in again", "重新登录"),
("AccountRemove", "Remove", "删除"),
("AccountKindMicrosoft", "Microsoft", "微软正版"),
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
("AccountKindOffline", "Local profile", "本地档案"),
("AccountServer", "Server {server}", "服务器 {server}"),
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
("AccountEmptyAction", "Add an account", "添加账户"),
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountTrackOpen", "Sign in", "登录"),
("AccountTrackCreate", "Create profile", "创建档案"),
("AccountDialogClose", "Close", "关闭"),
("AccountOfflineNameLabel", "Profile name", "档案名"),
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
("AccountOfflineSubmit", "Create profile", "创建档案"),
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
("AccountServerLabel", "Server address", "服务器地址"),
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
("AccountServerProbe", "Check server", "校验服务器"),
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
("AccountPasswordLabel", "Password", "密码"),
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
("AccountAuthlibSubmit", "Sign in", "登录"),
("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.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
("AccountRailSignedOut", "Not signed in", "未登录"),
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
("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…", "正在联系微软…"),
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
("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.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
];
+217 -92
View File
@@ -3,49 +3,28 @@
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
//!
//! | 文件 | 职责 |
//! |---|---|
//! | 本文件 | 账户/登录**可见状态**(全部是可上屏字段) |
//! | [`redact`] | 形状脱敏:JWT / 不透明串 / `token=...` 一律抹掉(第二道脱敏器) |
//! | `tests` | 契约 round-trip + 凭据红线的入口断言 |
mod failure;
mod kind;
mod name;
mod redact;
#[cfg(test)]
#[path = "accounts/tests.rs"]
mod tests;
pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure};
pub use kind::AccountKind;
pub use name::valid_offline_name;
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 {
/// 徽标文字。
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 {
@@ -60,9 +39,63 @@ pub struct AccountView {
/// 登录名(微软轨才有;可空)
pub login_name: String,
/// 创建时间展示串(`2026-10-31 20:12`)
///
/// 口径:core 的 `AccountMeta` 只有 `created_at_ms`,**没有 last-used**,所以列表上贴的是
/// 「添加于 {created}」,不伪造「最近使用」(缺口登记在 M114 报告)。
pub created: String,
/// 是否是当前启动使用的账户
pub active: bool,
/// 第三方轨的认证服务器 API 根(其余轨为空串)
#[serde(default)]
pub server_url: String,
/// 皮肤原图(第三方轨才可能有;`None` = 没有/没取到,UI 退回首字母块)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skin: Option<SkinImage>,
}
impl AccountView {
/// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。
///
/// 放在 DTO 而不是页面里,是为了「没有皮肤时画什么」这件事只有一处判据,两个 UI 版一致。
#[must_use]
pub fn monogram(&self) -> String {
self.name
.chars()
.find(|c| !c.is_whitespace())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string())
}
/// 首字母块的取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次都是同一个色块。
#[must_use]
pub fn monogram_tone(&self) -> u8 {
let mut hash: u32 = 2_166_136_261;
for byte in self.profile_id.as_bytes() {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(16_777_619);
}
u8::try_from(hash % 6).unwrap_or(0)
}
}
/// 一张皮肤原图。
///
/// **停在「原图」这一层是刻意的**:presenter 不引入图片解码依赖(`presenter-no-ui` +
/// 依赖极简),裁剪/缩放/取首字母块都由 UI 层自己做。离线轨永远没有它——没有皮肤就不给假皮肤。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkinImage {
/// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32)
pub png: Vec<u8>,
/// 来源 URL(**只进取证日志,不上屏**)
pub source: String,
}
impl SkinImage {
/// 是否有可用字节(0 字节等同没有皮肤)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.png.is_empty()
}
}
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
@@ -108,8 +141,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,
}
@@ -130,6 +166,7 @@ impl DeviceLoginView {
qr_link: String::new(),
expires_in: 0,
message: String::new(),
failure: None,
account_name: String::new(),
}
}
@@ -156,6 +193,7 @@ impl DeviceLoginView {
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
expires_in
),
failure: None,
account_name: String::new(),
}
}
@@ -174,7 +212,7 @@ impl DeviceLoginView {
expires_in.max(1)
);
if let Some(warning) = warning {
message.push_str(&format!("({warning})"));
message.push_str(&format!("({})", redact(warning)));
}
Self {
phase: DeviceLoginPhase::Waiting,
@@ -183,15 +221,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: message.into(),
message: redact(&message.into()),
failure: Some(failure),
..Self::new()
}
}
@@ -202,69 +247,149 @@ impl DeviceLoginView {
Self {
phase: DeviceLoginPhase::Succeeded,
message: format!("已登录:{account_name}"),
failure: None,
account_name,
..Self::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 第三方轨(authlib-injector)登录请求。
///
/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的
/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、
/// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthlibRequest {
/// 认证服务器地址(用户原样输入;host 侧 `normalize_api_root` 规范化)
pub server: String,
/// 登录名(邮箱或用户名)
pub login: String,
/// 密码(只在本调用栈里出现)
pub password: String,
}
#[test]
fn kind_round_trips_through_stable_ids() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::Authlib,
] {
assert_eq!(AccountKind::parse(kind.as_str()), kind);
assert!(!kind.label().is_empty());
/// **手写 `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(
server: impl Into<String>,
login: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
server: server.into(),
login: login.into(),
password: password.into(),
}
}
/// 可打印形态(日志/诊断/错误路径用):**密码恒被抹掉**。
#[must_use]
pub fn redacted(&self) -> String {
format!(
"server={} login={} password={REDACTED}",
redact(&self.server),
redact(&self.login)
)
}
}
/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:都是「申请 → 等待 → 成/败」)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthlibLoginView {
/// 阶段
pub phase: DeviceLoginPhase,
/// 认证服务器地址(规范化后的 API 根)
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,
}
impl Default for AuthlibLoginView {
fn default() -> Self {
Self::new()
}
}
impl AuthlibLoginView {
/// 空闲态。
pub fn new() -> Self {
Self {
phase: DeviceLoginPhase::Idle,
server: String::new(),
server_name: String::new(),
message: String::new(),
failure: None,
account_name: String::new(),
}
}
/// 进行中(校验服务器地址 / 换 token)。
pub fn running(server: &str, message: &str) -> Self {
Self {
phase: DeviceLoginPhase::Requesting,
server: server.to_string(),
server_name: String::new(),
message: redact(message),
failure: None,
account_name: String::new(),
}
}
#[test]
fn device_login_view_never_carries_tokens() {
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.qr_link.contains("ABCD-EFGH"));
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"视图里不该出现 {forbidden}"
);
/// 地址校验通过(服务发现拿到了服务器名)。
pub fn reachable(server: &str, server_name: &str) -> Self {
Self {
phase: DeviceLoginPhase::Waiting,
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)
}
#[test]
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
let view = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
Some("拉起浏览器失败"),
);
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.user_code.is_empty(), "授权码流没有用户码");
assert_eq!(view.qr_link, view.verification_uri);
assert!(view.message.contains("浏览器"));
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["refresh", "mc-access"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
/// 带**原因码**的失败态(页面据码出 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()
}
}
#[test]
fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert!(ok.user_code.is_empty());
let bad = DeviceLoginView::failed("网络超时");
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
assert_eq!(bad.message, "网络超时");
/// 成功态。
pub fn succeeded(server_name: &str, account_name: impl Into<String>) -> Self {
let account_name = account_name.into();
Self {
phase: DeviceLoginPhase::Succeeded,
server_name: server_name.to_string(),
message: format!("已登录:{account_name}"),
account_name,
..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,
}
}
}
+29
View File
@@ -0,0 +1,29 @@
//! 离线档案的名字契约(**UI 与 host 共用的同一个判据**)。
//!
//! 放在 `dto` 而不是 host 里:UI 用它决定「创建档案」按钮亮不亮,host 用它做写盘前校验。
//! 两处各写一遍,迟早会漂成「按钮亮着但必然失败」——那正是复审 r1 P2-2 的一半。
//! core 侧还有一道同名规则做兜底(`auth::offline::offline_meta`),这里只管界面这条。
/// 3–16 位 ascii 字母 / 数字 / 下划线。
#[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 == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_rule_is_three_to_sixteen_ascii_word_chars() {
assert!(valid_offline_name("Steve"));
assert!(valid_offline_name("a_1"));
assert!(valid_offline_name("abcdefghijklmnop"), "16 位是上限");
assert!(!valid_offline_name("ab"), "太短");
assert!(!valid_offline_name("abcdefghijklmnopq"), "太长");
assert!(!valid_offline_name("has space"));
assert!(!valid_offline_name("中文名字"), "非 ascii");
assert!(!valid_offline_name(""));
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 凭据红线的**形状脱敏**:把 JWT / 不透明 token / `token=...` 这类串从任何要上屏的文案里抹掉。
//!
//! ## 为什么还要第二道脱敏器
//!
//! `core::launch::redact` 是**按已知明文值**替换的(拿着会话里的 token 原串去抹)。这里要挡的是
//! **没登记过的**字符串:服务端在错误响应里回显一个 token、HTTP 库把 header 拼进错误文案、
//! 某段调试串被画到面板上。两者互补,缺一不可:
//!
//! | 场景 | `core::launch::redact` | 本模块 |
//! |---|---|---|
//! | 已知会话 token 出现在日志里 | ✅ | ❌(不知道明文) |
//! | 服务端回包里的陌生 JWT 被画到错误面板 | ❌ | ✅ |
//!
//! 走形状识别(三段 base64url / 超长不透明串 / 敏感键名)就够拦住「一眼能看出是凭据」的串,
//! 而普通文案(URL、用户码 `ABCD-EFGH`、服务器名)**一个字都不动**——脱敏器本身不能变成噪音源。
/// 脱敏占位符(**故意刺眼**:宁可难看,也不许静默把凭据画到屏幕上)。
pub const REDACTED: &str = "⟪redacted⟫";
/// 把一段要上屏的文案里的凭据形状串抹掉(空白与其余字符**原样保留**)。
#[must_use]
pub fn redact(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for piece in text.split_inclusive(char::is_whitespace) {
let split = piece.find(char::is_whitespace).unwrap_or(piece.len());
let (chunk, space) = piece.split_at(split);
out.push_str(&redact_chunk(chunk));
out.push_str(space);
}
out
}
/// 单个「词」的脱敏:① 敏感键名的值 ② 整个词本身就是凭据形状。
fn redact_chunk(chunk: &str) -> String {
if let Some((key, _value)) = chunk.split_once('=') {
if is_sensitive_key(key) || looks_opaque(chunk) {
return format!("{key}={REDACTED}");
}
}
if looks_opaque(chunk) {
return REDACTED.to_string();
}
chunk.to_string()
}
/// 键名是否属于「它的值一定是凭据」(`accessToken` / `refresh_token` / `password` …)。
fn is_sensitive_key(key: &str) -> bool {
let key = key
.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.to_lowercase();
[
"token",
"secret",
"password",
"passwd",
"authorization",
"bearer",
"session",
]
.iter()
.any(|needle| key.contains(needle))
}
/// 词的形状是否像凭据:① 三段 base64url(JWT)② 长度 ≥40 的不透明串。
fn looks_opaque(chunk: &str) -> bool {
let trimmed = chunk.trim_matches(|c: char| {
!c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '+'
});
looks_like_jwt(trimmed) || (trimmed.len() >= 40 && trimmed.chars().all(is_base64url))
}
/// 是否是 JWT 形状:`header.payload.signature`,三段都非空且够长、全是 base64url 字符。
///
/// 公开是为了单测能直接钉这条判据(UI 侧「注入 JWT 必须被抹掉」的测试也用它)。
#[must_use]
pub fn looks_like_jwt(token: &str) -> bool {
let parts: Vec<&str> = token.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| part.len() >= 8 && part.chars().all(is_base64url))
}
/// base64url 字母表(含 `=` 填充与 `+`/`/` 的宽容分支)。
fn is_base64url(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '/' | '=' | '.')
}
#[cfg(test)]
mod tests {
use super::*;
/// 标准 JWT 形状(2 段 header/payload + 1 段签名)。
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
#[test]
fn the_jwt_shape_judgement_is_not_a_length_heuristic() {
assert!(looks_like_jwt(JWT));
assert!(!looks_like_jwt("not.a.jwt"), "短段不算 JWT");
assert!(!looks_like_jwt("a.b.c.d"), "四段不算 JWT");
assert!(!looks_like_jwt("ABCD-EFGH"), "设备码不是 JWT");
}
#[test]
fn bare_jwts_and_sensitive_pairs_are_redacted() {
assert_eq!(redact(JWT), REDACTED);
assert_eq!(
redact(&format!("Bearer {JWT}")),
format!("Bearer {REDACTED}")
);
assert_eq!(
redact(&format!("accessToken={JWT}")),
format!("accessToken={REDACTED}")
);
// 敏感键名 + 短值(真实 refresh token 常常很短,靠键名兜)
assert_eq!(redact("password=hunter2"), format!("password={REDACTED}"));
assert_eq!(
redact("refresh_token=abc"),
format!("refresh_token={REDACTED}")
);
}
#[test]
fn ordinary_copy_is_left_byte_for_byte() {
for text in [
"在浏览器打开 https://www.microsoft.com/link 并输入 ABCD-EFGH",
"服务器不可达:dns error",
"HTTP 403 Forbidden — 该服务器要求白名单",
"",
] {
assert_eq!(redact(text), text, "普通文案被改了:{text}");
}
}
#[test]
fn whitespace_and_line_breaks_survive() {
let text = format!("第一行 token={JWT}\n\n第二行 缩进");
let out = redact(&text);
assert!(out.contains("\n\n"), "{out}");
assert!(out.contains(" 缩进"), "{out}");
assert!(!out.contains(JWT), "{out}");
}
}
+167
View File
@@ -0,0 +1,167 @@
//! 账户 DTO 的契约测试(凭据红线的主要断言都在这里)。
use super::*;
#[test]
fn kind_round_trips_through_stable_ids() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::Authlib,
] {
assert_eq!(AccountKind::parse(kind.as_str()), kind);
assert!(!kind.label().is_empty());
}
}
#[test]
fn device_login_view_never_carries_tokens() {
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.qr_link.contains("ABCD-EFGH"));
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"视图里不该出现 {forbidden}"
);
}
}
#[test]
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
let view = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
Some("拉起浏览器失败"),
);
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.user_code.is_empty(), "授权码流没有用户码");
assert_eq!(view.qr_link, view.verification_uri);
assert!(view.message.contains("浏览器"));
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["refresh", "mc-access"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
}
}
#[test]
fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert!(ok.user_code.is_empty());
let bad = DeviceLoginView::failed("网络超时");
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
assert_eq!(bad.message, "网络超时");
}
/// 凭据红线(审核项):JWT 形状串从**任何**入口进来都必须被抹掉。
///
/// 抓得住:`redact` 被摘掉、判据被放宽成「长度 ≥40 就行」、或者某个构造器绕过它。
/// 抓不住:真实凭据的明文值(那是 `core::launch::redact` 的活儿,按已知值替换)。
#[test]
fn jwt_shaped_text_is_redacted_at_every_entry_point() {
let jwt =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
let device = DeviceLoginView::failed(format!("登录失败:accessToken={jwt}"));
assert!(!device.message.contains(jwt), "{}", device.message);
assert!(device.message.contains(REDACTED), "{}", device.message);
let authlib = AuthlibLoginView::failed(format!("服务器回包异常 {jwt}"));
assert!(!authlib.message.contains(jwt), "{}", authlib.message);
assert!(authlib.message.contains(REDACTED), "{}", authlib.message);
let ready = AuthlibLoginView::reachable("https://littleskin.cn/", "LittleSkin");
assert!(!ready.message.contains(jwt));
}
#[test]
fn authlib_request_never_prints_the_password() {
let request = AuthlibRequest::new(
"https://littleskin.cn/api/yggdrasil/",
"me@example.com",
"hunter2",
);
let shown = request.redacted();
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("littleskin"), "{shown}");
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");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert_eq!(view.server_name, "LittleSkin");
assert!(view.message.contains("LittleSkin"));
let ok = AuthlibLoginView::succeeded("LittleSkin", "Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert_eq!(ok.account_name, "Steve");
let json = serde_json::to_string(&ok).expect("序列化");
for forbidden in ["accessToken", "clientToken", "refresh", "password"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
}
}
#[test]
fn account_view_serializes_without_credentials() {
let view = AccountView {
id: "account:a".to_string(),
name: "Steve".to_string(),
kind: AccountKind::Authlib,
profile_id: "0f8f".to_string(),
login_name: "me@example.com".to_string(),
created: "2026-10-31 20:12".to_string(),
active: true,
server_url: "https://littleskin.cn/api/yggdrasil/".to_string(),
skin: Some(SkinImage {
png: vec![1, 2, 3],
source: "textures".to_string(),
}),
};
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"账户视图里不该出现 {forbidden}"
);
}
assert!(json.contains("\"png\""));
}
#[test]
fn monogram_is_stable_and_never_empty() {
let mut view = AccountView {
id: "account:a".to_string(),
name: " steve".to_string(),
kind: AccountKind::Microsoft,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: String::new(),
active: false,
server_url: String::new(),
skin: None,
};
assert_eq!(view.monogram(), "S");
let tone = view.monogram_tone();
assert!(tone < 6);
view.profile_id = "0f8f".to_string();
assert_eq!(view.monogram_tone(), tone, "同一个人每次同一个色块");
view.name = String::new();
assert_eq!(view.monogram(), "?", "空名字也要有字,不能画白块");
}
+65 -4
View File
@@ -9,10 +9,33 @@ use serde::{Deserialize, Serialize};
use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, ContentInstallRequest, ContentQuery, ContentResults, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, InstanceSummary,
JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, PackCommand, PackEvent,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
// 主线(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,
};
use crate::toast::Toast;
@@ -68,6 +91,10 @@ pub enum CoreEvent {
AccountsChanged(Vec<AccountView>),
/// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView),
/// 第三方轨(authlib-injector)登录/校验状态刷新
AuthlibLoginChanged(AuthlibLoginView),
/// 账户页横幅(添加/删除/切换的结果;`None` = 清掉横幅)
AccountNoticeChanged(Option<AccountNotice>),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
@@ -168,6 +195,14 @@ pub enum AppCommand {
StartBrowserLogin,
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
CancelDeviceLogin,
/// 加一个第三方皮肤站(authlib-injector)账户:校验服务器 + 密码登录
///
/// **凭据红线**:`request.password` 只是这一次的请求值,host 用完即弃,绝不进回程 DTO。
AddAuthlibAccount(AuthlibRequest),
/// 只校验第三方皮肤站地址(服务发现):结果走 `AuthlibLoginChanged`
ProbeAuthlibServer(String),
/// 关掉账户页横幅(页面上的 × / Dismiss)
DismissAccountNotice,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String),
/// 写一条设置(`key` 必须是 core schema 里的键)
@@ -246,6 +281,8 @@ mod tests {
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
server_url: String::new(),
skin: None,
}]));
let text = serde_json::to_string(&account).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
@@ -273,5 +310,29 @@ mod tests {
login
);
assert!(DeviceLoginPhase::Waiting.busy());
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
"https://littleskin.cn/api/yggdrasil/",
"me@example.com",
"hunter2",
));
let text = serde_json::to_string(&authlib).expect("序列化");
assert_eq!(
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
authlib
);
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
"https://littleskin.cn/api/yggdrasil/",
"LittleSkin",
)));
let text = serde_json::to_string(&view).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
assert!(
!text.to_lowercase().contains("token"),
"回程视图里不该有 token"
);
}
}
+5 -1
View File
@@ -28,7 +28,11 @@ mod pack;
mod settings;
mod versions;
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
pub use accounts::{
looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode,
AccountView, AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure,
SkinImage, REDACTED,
};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{
loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults,
+124 -16
View File
@@ -19,12 +19,48 @@ 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::{
valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView,
DeviceLoginView, LoginFailure,
};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
use super::opener;
/// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。
pub mod authlib;
mod refresh;
mod failure;
pub use failure::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());
@@ -39,6 +75,9 @@ pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
login_name: meta.login_name.clone().unwrap_or_default(),
created: display_ms(meta.created_at_ms),
active: active.is_some_and(|id| id == meta.id.as_str()),
server_url: meta.server_url.clone().unwrap_or_default(),
// 皮肤是网络产物:列表先给 `None`,host 抓到之后按账户 id 盖回去(见 `CoreHost`)
skin: None,
})
.collect()
}
@@ -77,18 +116,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)
}
@@ -96,15 +142,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())
}
@@ -139,7 +185,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| {
@@ -162,7 +208,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| {
@@ -189,8 +235,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 也走这条)。
@@ -205,9 +256,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());
}
}
}
@@ -231,6 +285,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() {
@@ -263,6 +320,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();
@@ -0,0 +1,337 @@
//! 第三方轨(authlib-injector):服务发现校验、密码登录、皮肤原图抓取。
//!
//! 全部跑在后台线程里(网络),出口只有 [`AuthlibLoginView`] / [`SkinImage`]——
//! 密码与 accessToken 在调用栈里就地丢弃,永不进 DTO 回程(M114 凭据红线)。
//!
//! ## 三条流的坐标
//!
//! | 动作 | 端点 | 产出 |
//! |---|---|---|
//! | 服务发现(「校验」按钮) | `GET {apiRoot}` | 服务器名 / skinDomains(`meta` 缺失即判「不是认证服务器」) |
//! | 登录 | `POST {apiRoot}authserver/authenticate` | `AuthlibSession`(入库,不外传) |
//! | 皮肤 | `GET {apiRoot}sessionserver/session/minecraft/profile/{uuid}` → textures → 皮肤 URL | 皮肤 PNG 原图 |
//!
//! `core::download::http::build_client` 是唯一的 HTTP 基座(复用下载器的客户端配置),
//! 所以本 crate **不新增任何依赖**(`presenter-no-ui` 与依赖最小化同时成立)。
use unlucky_core::auth::account::{AccountKind as CoreAccountKind, AccountMeta, Credentials};
use unlucky_core::auth::authlib::{
normalize_api_root, AuthlibAuth, AuthlibServerMeta, AuthlibSession,
};
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, LoginFailure, SkinImage};
mod util;
pub use util::scrub_known;
use util::{base64_decode, client_token, get_bytes, get_text};
use super::super::env::HostEnv;
use super::super::job::{spawn, HostTask, JobSender};
use super::LoginJob;
/// 校验认证服务器地址(「校验」按钮的后端)。
///
/// # Errors
///
/// 地址不合法、网络不可达、HTTP 非 2xx、响应不是 authlib-injector 的服务发现 JSON 时,
/// 返回**具名**的用户可读文案(这是任务要求的「失败态 + 具名错误」)。
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(Failure::new(
LoginFailure::NotAuthlibServer,
format!("服务器返回 HTTP {status}({api_root} 不像可用的 authlib 认证服务器)"),
));
}
let value: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
Failure::new(
LoginFailure::NotAuthlibServer,
format!("响应不是 JSON:{api_root} —— 可能填成了网页地址,而不是 API 根(通常以 /api/yggdrasil/ 结尾)"),
)
})?;
if value.get("meta").is_none() && value.get("skinDomains").is_none() {
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 || {
let mut request = request;
// 日志只打脱敏形态;密码在下面用完立刻丢弃。
eprintln!("[auth] authlib 登录开始({})", request.redacted());
stage(&tx, &request.server, "正在校验服务器地址…");
let (api_root, meta) = match probe_authlib(&env, &request.server) {
Ok(found) => found,
Err(failure) => return fail(&tx, failure),
};
let server_name = meta
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| api_root.clone());
stage(
&tx,
&api_root,
&format!("服务器 {server_name} 可用,正在登录…"),
);
let auth = match AuthlibAuth::new() {
Ok(auth) => auth,
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);
let password = Secret::new(password_text.clone());
let login_name = request.login.clone();
let result = block_on(auth.authenticate(&api_root, &login_name, &password, &client_token));
drop(password);
if job.is_cancelled() {
let _ = tx.send(HostTask::Authlib(Box::default()));
return;
}
match result {
Ok(session) => match store(&env, session, &api_root, &login_name) {
Ok(account) => {
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::succeeded(
&server_name,
&account,
))));
let _ = tx.send(HostTask::Accounts(super::accounts(
&env,
Some(account.as_str()),
)));
}
Err(failure) => fail(&tx, failure),
},
// 错误原文里可能有服务端回显的凭据:先按**形状**脱敏(`AuthlibLoginView::failed`),
// 再按**已知值**兜一遍(万一服务端把密码原样回显)。
Err(error) => {
let failure = Failure::from_error(&error);
fail(
&tx,
Failure::new(failure.code, scrub_known(&failure.detail, &password_text)),
);
}
}
drop(password_text);
});
}
/// 只校验服务器地址(「校验服务器」按钮);结果走同一条 `AuthlibLoginChanged`。
pub fn spawn_probe(env: HostEnv, tx: JobSender, server: String) {
spawn("authlib-probe", move || {
stage(&tx, &server, "正在校验服务器地址…");
match probe_authlib(&env, &server) {
Ok((api_root, meta)) => {
let name = meta
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map_or_else(|| api_root.clone(), str::to_string);
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::reachable(
&api_root, &name,
))));
}
Err(failure) => fail(&tx, failure),
}
});
}
/// 抓第三方轨账户的皮肤原图(`profile_id` → textures → 皮肤 URL → PNG 字节)。
///
/// # Errors
///
/// 服务器不可达、响应缺 textures、纹理不是 PNG 时返回原因(调用方按「没有皮肤」处理)。
pub fn fetch_skin(env: &HostEnv, api_root: &str, profile_id: &str) -> Result<SkinImage, String> {
let base = normalize_api_root(api_root).map_err(|error| error.to_string())?;
let url = format!("{base}sessionserver/session/minecraft/profile/{profile_id}");
let (status, text) = get_text(env, &url)?;
if !(200..300).contains(&status) {
return Err(format!("皮肤接口返回 HTTP {status}"));
}
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|error| format!("皮肤接口响应不是 JSON:{error}"))?;
let encoded = value
.get("properties")
.and_then(|properties| properties.as_array())
.and_then(|properties| {
properties
.iter()
.find(|entry| entry.get("name").and_then(|name| name.as_str()) == Some("textures"))
})
.and_then(|entry| entry.get("value"))
.and_then(|value| value.as_str())
.ok_or_else(|| "档案里没有 textures 属性".to_string())?;
let raw = base64_decode(encoded)?;
let textures: serde_json::Value =
serde_json::from_slice(&raw).map_err(|error| format!("textures 属性解不开:{error}"))?;
let skin_url = textures
.get("textures")
.and_then(|textures| textures.get("SKIN"))
.and_then(|skin| skin.get("url"))
.and_then(|url| url.as_str())
.ok_or_else(|| "textures 里没有 SKIN 条目".to_string())?;
let (status, bytes) = get_bytes(env, skin_url)?;
if !(200..300).contains(&status) {
return Err(format!("皮肤图片返回 HTTP {status}"));
}
if !bytes.starts_with(b"\x89PNG") {
return Err("皮肤图片不是 PNG".to_string());
}
Ok(SkinImage {
png: bytes,
source: skin_url.to_string(),
})
}
/// 入库(元数据 + 凭据分文件);返回展示名。
fn store(
env: &HostEnv,
session: AuthlibSession,
api_root: &str,
login_name: &str,
) -> Result<String, Failure> {
let profile = session
.selected_profile
.clone()
.filter(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
.ok_or_else(|| Failure::new(LoginFailure::NoProfile, "服务器没有返回角色"))?;
let mut meta = AccountMeta::new(
CoreAccountKind::AuthlibInjector,
profile.id,
profile.name,
now_ms(),
);
meta.login_name = Some(login_name.to_string());
meta.server_url = Some(api_root.to_string());
let name = meta.display_name().to_string();
let mut account_store = AccountStore::open(env.layout.auth_data());
account_store
.insert(meta, Credentials::AuthlibInjector(session))
.and_then(|()| account_store.save())
.map_err(|error| Failure::new(LoginFailure::StoreFailed, error.to_string()))?;
Ok(name)
}
/// 发一条「进行中」状态(UI 据此只转圈不误报)。
fn stage(tx: &JobSender, server: &str, message: &str) {
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::running(
server, message,
))));
}
/// 失败收尾:**先脱敏再上屏**(服务端错误原文里可能带 token)。
/// 失败收尾:原文进日志,页面拿**原因码**(复审 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,
))));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_decodes_textures_payload() {
// `{"textures":{}}` 的 base64
let encoded = "eyJ0ZXh0dXJlcyI6e319";
assert_eq!(base64_decode(encoded).expect("解码"), br#"{"textures":{}}"#);
// URL-safe 变体 + 填充 + 换行都要吃
assert_eq!(
base64_decode("eyJ0ZXh0dXJlcyI6e319\n").expect("解码"),
br#"{"textures":{}}"#
);
assert!(base64_decode("!!!").is_err());
assert!(base64_decode("").is_err());
}
#[test]
fn client_token_is_stable_length_and_unique() {
let a = client_token();
let b = client_token();
assert_eq!(a.len(), 32);
assert_ne!(
a, b,
"两次生成的 clientToken 不能相同(会被服务端当成重放)"
);
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");
assert!(!text.contains("hunter2"), "{text}");
assert!(text.contains(REDACTED));
assert_eq!(scrub_known("普通错误", ""), "普通错误");
}
#[test]
fn a_web_page_address_is_rejected_with_a_named_error() {
// 不发网络请求:只测「规范化 + 空输入」这条不联网的分支
let dir = std::env::temp_dir().join(format!("ull-authlib-probe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
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,126 @@
//! 账户写操作的失败分类 + 离线名字契约(M114 r2)。
//!
//! 分类**只看 `AuthError` 的变体与 ascii 码**,不看中文文案——文案随时会改,
//! 而页面据 `AccountNoticeCode` 选 i18n,两边必须能独立演进。
use unlucky_core::auth::AuthError;
use unlucky_core::auth::BodyKind;
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(),
}
}
}
/// `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
}
}
// 只看 `ResponseDetail` 的**结构化字段**(status / kind / server_message),
// 不把整条错误 Debug 成字符串再去扫——那会把中文 `what`/`digest` 也算进判据里。
AuthError::Response(detail) => {
let server = detail.server_message.as_deref().unwrap_or_default();
let digest = detail.digest.as_str();
if mentions_whitelist(server) || mentions_whitelist(digest) {
LoginFailure::NotWhitelisted
} else if detail.status >= 500 || matches!(detail.kind, BodyKind::Html | BodyKind::Text)
{
// 5xx / 拦截页:服务器或链路出了问题,不是「它拒绝了你」。
LoginFailure::Unreachable
} 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")
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_core::auth::body::ResponseDetail;
fn response(status: u16, kind: BodyKind, server_message: Option<&str>) -> AuthError {
AuthError::Response(Box::new(ResponseDetail {
step: "xsts".to_string(),
what: "XSTS 授权".to_string(),
status,
content_type: None,
kind,
server_message: server_message.map(str::to_string),
digest: "digest".to_string(),
}))
}
/// 403 带 AADSTS 码 → 白名单未批;403 不带 → 一般性拒绝。
#[test]
fn aadsts_markers_win_over_the_generic_denial() {
assert_eq!(
classify(&response(
403,
BodyKind::Json,
Some("AADSTS7000218: app not approved")
)),
LoginFailure::NotWhitelisted
);
assert_eq!(
classify(&response(403, BodyKind::Json, Some("invalid_grant"))),
LoginFailure::Denied
);
}
/// 5xx 与「拦截页」(HTML/纯文本)归「连不上」,不是「服务器拒绝了你」。
#[test]
fn upstream_failures_and_intercept_pages_are_unreachable() {
assert_eq!(
classify(&response(503, BodyKind::Json, None)),
LoginFailure::Unreachable
);
assert_eq!(
classify(&response(200, BodyKind::Html, None)),
LoginFailure::Unreachable
);
}
}
@@ -0,0 +1,66 @@
//! 账户列表的刷新与皮肤抓取(`impl CoreHost` 的这一段,从 `host/mod.rs` 拆出来守 400 行)。
//!
//! 为什么在这里:`host/mod.rs` 在 M109/M113 之后已经贴着 400 行硬线,而这三段是**账户专属**
//! 的编排(读账户库 → 盖皮肤 → 起抓取线程)。放回 `host/accounts.rs` 也不行——那个文件同样
//! 380 行。归属仍是 accounts(`mod accounts { mod refresh; }`),只是按功能再分一层。
use crate::dto::{AccountKind, CoreEvent};
use super::super::{accounts, job, CoreHost, HostTask};
impl CoreHost {
pub(in crate::host) fn refresh_accounts(&mut self) {
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
if self.active_account.is_none() {
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
self.active_account = self.accounts.first().map(|account| account.id.clone());
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
}
self.attach_skins();
self.request_skins();
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
}
/// 把已抓到的皮肤盖到账户视图上。
///
/// 只盖第三方轨:离线档案没有皮肤,微软轨不做真头像(M114 口径)——
/// 「没有皮肤就画首字母块」,绝不给假皮肤/白块。
pub(in crate::host) fn attach_skins(&mut self) {
for account in &mut self.accounts {
if account.kind != AccountKind::Authlib {
account.skin = None;
continue;
}
if let Some(skin) = self.skins.get(&account.id) {
account.skin = skin.clone();
}
}
}
/// 给还没抓过皮肤的第三方轨账户起后台抓取(每个账户只抓一次)。
///
/// 抓不到就记 `None`:错误在面板上不显示(皮肤是装饰,不是业务失败),但也不会重试刷屏。
fn request_skins(&mut self) {
let mut pending = Vec::new();
for account in &self.accounts {
if account.kind != AccountKind::Authlib || account.server_url.trim().is_empty() {
continue;
}
if self.skin_requested.insert(account.id.clone()) {
pending.push((
account.id.clone(),
account.server_url.clone(),
account.profile_id.clone(),
));
}
}
for (id, server, profile) in pending {
let tx = self.tx.clone();
let env = self.env.clone();
job::spawn("account-skin", move || {
let skin = accounts::fetch_skin(&env, &server, &profile).ok();
let _ = tx.send(HostTask::Avatar { id, skin });
});
}
}
}
+46 -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());
@@ -154,9 +172,31 @@ impl CoreHost {
job.cancel();
}
self.login = None;
// 第三方轨的面板也归这条「关掉登录面板」:两条轨都不该留一个转不完的圈
if let Some(job) = &self.authlib {
job.cancel();
}
self.authlib = None;
self.send(CoreEvent::DeviceLoginChanged(
crate::dto::DeviceLoginView::new(),
));
self.send(CoreEvent::AuthlibLoginChanged(
crate::dto::AuthlibLoginView::new(),
));
}
AppCommand::AddAuthlibAccount(request) => {
self.authlib = Some(LoginJob::new());
let job = self.authlib.clone().unwrap_or_default();
self.send(CoreEvent::AuthlibLoginChanged(
crate::dto::AuthlibLoginView::running(&request.server, "正在校验服务器地址…"),
));
accounts::start_authlib_login(self.env.clone(), self.tx.clone(), job, request);
}
AppCommand::ProbeAuthlibServer(server) => {
// 只探活:后台线程跑服务发现,结果回到同一条 `AuthlibLoginChanged`
let env = self.env.clone();
let tx = self.tx.clone();
accounts::spawn_probe(env, tx, server);
}
AppCommand::InstallJava(vendor) => {
self.java_busy = true;
+7
View File
@@ -91,6 +91,13 @@ impl CoreHost {
self.refresh_instances();
}
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
HostTask::Authlib(view) => self.send(CoreEvent::AuthlibLoginChanged(*view)),
HostTask::Avatar { id, skin } => {
// 抓到/没抓到都记下来(`None` = 试过了,不再重试),再把列表盖好发出去
self.skins.insert(id, skin);
self.attach_skins();
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
}
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
HostTask::Logs {
sessions,
+30 -3
View File
@@ -7,9 +7,27 @@
use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome,
LogSession, NoticeView, PackEvent, SessionId, VersionSummary,
// 主线(M109 ContentResults / M105 LoaderKind / M113 PackEvent)与 M114 的账户轨并集。
AccountView,
AuthlibLoginView,
ContentResults,
DeviceLoginView,
DiagnosisReport,
InstallProgressView,
InstanceId,
InstanceRuntime,
JavaPageView,
LaunchFinished,
LiveLogBatch,
LoaderKind,
LogLine,
LogOutcome,
LogSession,
NoticeView,
PackEvent,
SessionId,
SkinImage,
VersionSummary,
};
use crate::toast::Toast;
@@ -66,6 +84,15 @@ pub enum HostTask {
Accounts(Vec<AccountView>),
/// 设备码登录状态
Login(Box<DeviceLoginView>),
/// 第三方轨(authlib-injector)登录/校验状态
Authlib(Box<AuthlibLoginView>),
/// 账户皮肤抓取结果(`skin = None` = 没取到;UI 退回首字母块)
Avatar {
/// 账户 id
id: String,
/// 皮肤原图
skin: Option<SkinImage>,
},
/// Java 页(发行版/回退链/运行时)
Java(Box<JavaPageView>),
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
+11 -11
View File
@@ -38,7 +38,7 @@ use std::time::Duration;
use crate::dto::{
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
InstanceRuntime, JavaChainStep, NoticeView,
InstanceRuntime, JavaChainStep, NoticeView, SkinImage,
};
use crate::mock::MockBackend;
use crate::toast::{Toast, ToastKind};
@@ -72,6 +72,13 @@ pub struct CoreHost {
active_account: Option<String>,
accounts: Vec<AccountView>,
login: Option<LoginJob>,
/// 第三方轨(authlib)登录任务句柄(与微软两条流共用取消语义,但**不复用槽位**:
/// 两条轨同时点「登录」时各自的结果不能互相踩)
authlib: Option<LoginJob>,
/// 已抓到的皮肤(账户 id → 原图;`None` = 试过但没取到,不再重试)
skins: BTreeMap<String, Option<SkinImage>>,
/// 已经发起过皮肤抓取的账户 id(防每次刷新都重新抓一遍)
skin_requested: BTreeSet<String>,
java_chain: Vec<JavaChainStep>,
java_busy: bool,
java_message: String,
@@ -126,6 +133,9 @@ impl CoreHost {
active_account: None,
accounts: Vec::new(),
login: None,
authlib: None,
skins: BTreeMap::new(),
skin_requested: BTreeSet::new(),
java_chain: Vec::new(),
java_busy: false,
java_message: String::new(),
@@ -212,16 +222,6 @@ impl CoreHost {
self.send(CoreEvent::InstancesChanged(instances));
}
fn refresh_accounts(&mut self) {
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
if self.active_account.is_none() {
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
self.active_account = self.accounts.first().map(|account| account.id.clone());
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
}
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
}
fn refresh_java(&mut self) {
let page = java::page(
&self.env,
+33 -2
View File
@@ -16,6 +16,9 @@ use crate::dto::{
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
use crate::toast::{Toast, ToastKind};
#[path = "mock/accounts.rs"]
mod accounts_screen;
/// 启动脚本:按时间推进 download → verify → launch → running。
#[derive(Debug)]
struct LaunchScript {
@@ -65,6 +68,26 @@ impl MockBackend {
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.seed_accounts();
}
/// 账户页的截图矩阵(`ULL_ACCOUNT_STATE`,见 [`accounts_screen`])。
fn seed_accounts(&mut self) {
let scenario = accounts_screen::scenario();
self.emit(AppEvent::Core(CoreEvent::AccountsChanged(
accounts_screen::accounts(&scenario),
)));
if let Some(view) = accounts_screen::device_login(&scenario) {
self.emit(AppEvent::Core(CoreEvent::DeviceLoginChanged(view)));
}
if let Some(view) = accounts_screen::authlib_login(&scenario) {
self.emit(AppEvent::Core(CoreEvent::AuthlibLoginChanged(view)));
}
if let Some(notice) = accounts_screen::notice(&scenario) {
self.emit(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
notice,
))));
}
}
fn emit(&mut self, event: AppEvent) {
@@ -260,7 +283,10 @@ impl Backend for MockBackend {
crate::mock_data::mock_diagnosis(id),
)));
}
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
AppCommand::RefreshAll => self.seed_accounts(),
// 与真 host 同一契约:取消登录把两条轨都收回 Idle(否则 Close 在 mock 下「没反应」)
AppCommand::CancelDeviceLogin => self.cancel_login(),
AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
_ => {}
}
@@ -281,7 +307,12 @@ mod tests {
fn seeds_instances_and_versions() {
let mut backend = MockBackend::new();
let events = backend.drain_events();
assert_eq!(events.len(), 4);
// 实例 / 版本 / 会话 / 日志行 **+ 账户页的种子**(M114:账户列表 + 可选的登录面板态)
assert!(
events.len() >= 5,
"至少含账户列表,实际 {} 条",
events.len()
);
assert!(events
.iter()
.all(|event| matches!(event, AppEvent::Core(_))));
+177
View File
@@ -0,0 +1,177 @@
//! 截图/演示用的账户矩阵(**只在 mock 后端**里被调用)。
//!
//! D30 要求账户页出五态 × 浅深:空态 / 多账号列表 / 设备码进行中 / 登录失败 / 第三方站表单。
//! 截图通道强制 mock 后端(`AppOptions::mock`),所以这五态的**数据**必须由 mock 造:
//! 走 `ULL_ACCOUNT_STATE` 选场景,与 `ULL_START_ROUTE` / `ULL_APPEARANCE_*` 同一套取证开关。
//!
//! ## 造的是什么、不造什么
//!
//! - 列表行造的是**公开字段**(名字 / 轨道 / 创建时间 / 服务器),与真 host 的
//! `AccountView` 同构;
//! - **不造皮肤字节**:第三方轨的真图来自服务器,mock 里没有(伪造一张「假皮肤」正好违反
//! M114 的口径)。所以截图里皮肤区显示的是首字母块 + 「没有可用皮肤」。
use crate::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, AuthlibLoginView, DeviceLoginView,
LoginFailure,
};
impl super::MockBackend {
/// 取消登录:两条轨都发 Idle(真 host 的 `CancelDeviceLogin` 就是这么做的)。
///
/// 为什么放在 mock 里也要认真做:M114 的 `Close` 回归闸与「关掉之后」的取证图都走这条
/// 通道——mock 用 `_ => {}` 兜底的话,Close 会看起来「点了没反应」,断言会假红/假绿。
pub(super) fn cancel_login(&mut self) {
self.emit(crate::dto::AppEvent::Core(
crate::dto::CoreEvent::DeviceLoginChanged(DeviceLoginView::new()),
));
self.emit(crate::dto::AppEvent::Core(
crate::dto::CoreEvent::AuthlibLoginChanged(AuthlibLoginView::new()),
));
}
}
/// 场景名(`ULL_ACCOUNT_STATE`;未知值按 `list` 处理)。
pub(super) fn scenario() -> String {
std::env::var("ULL_ACCOUNT_STATE")
.unwrap_or_else(|_| "list".to_string())
.trim()
.to_lowercase()
}
/// 场景对应的账户列表(`empty` 场景是空表 ⇒ 空态)。
pub(super) fn accounts(scenario: &str) -> Vec<AccountView> {
if scenario == "empty" {
return Vec::new();
}
vec![
account(
"account:mock-msa",
"Steve",
AccountKind::Microsoft,
"8f2f1d3c4b5a69788796a5b4c3d2e1f0",
"steve@outlook.com",
"2026-10-28 20:12",
true,
"",
),
account(
"account:mock-authlib",
"Alex",
AccountKind::Authlib,
"0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"alex@example.com",
"2026-10-30 09:40",
false,
"https://littleskin.cn/api/yggdrasil/",
),
account(
"account:mock-offline",
"Herobrine",
AccountKind::Offline,
"3c4d5e6f708192a3b4c5d6e7f8091a2b",
"",
"2026-10-31 21:05",
false,
"",
),
]
}
/// 场景对应的登录面板状态(`None` = 没有面板)。
pub(super) fn device_login(scenario: &str) -> Option<DeviceLoginView> {
match scenario {
"device" | "expired" => Some(DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
)),
// 带**原因码**的失败:原文(中文诊断)留在 detail 里只进日志,页面画 i18n + Code
// `closed` 用同一份种子,再由 `force_account_state` 按一次「关闭」,出「关掉之后」的图。
"failed" | "closed" => Some(DeviceLoginView::failed_as(
LoginFailure::NotWhitelisted,
"微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截",
)),
_ => None,
}
}
/// 场景对应的**第三方站**登录状态(`authlib-failed` = 地址不是 authlib 服务器)。
///
/// 原文(中文诊断)只进日志;页面据 `failure` 码出 i18n(复审 r1 P1-1 的第三方轨那一半)。
pub(super) fn authlib_login(scenario: &str) -> Option<AuthlibLoginView> {
match scenario {
"authlib-failed" => Some(AuthlibLoginView {
phase: crate::dto::DeviceLoginPhase::Failed,
server: "https://example.invalid/api/yggdrasil/".to_string(),
server_name: String::new(),
message: "服务器返回 HTTP 404(不像可用的 authlib 认证服务器)".to_string(),
failure: Some(LoginFailure::NotAuthlibServer),
account_name: String::new(),
}),
_ => 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,
name: &str,
kind: AccountKind,
profile_id: &str,
login_name: &str,
created: &str,
active: bool,
server_url: &str,
) -> AccountView {
AccountView {
id: id.to_string(),
name: name.to_string(),
kind,
profile_id: profile_id.to_string(),
login_name: login_name.to_string(),
created: created.to_string(),
active,
server_url: server_url.to_string(),
skin: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_empty_scenario_has_no_rows_and_the_list_scenario_covers_all_three_tracks() {
assert!(accounts("empty").is_empty());
let rows = accounts("list");
assert_eq!(rows.len(), 3);
assert!(rows.iter().any(|row| row.kind == AccountKind::Microsoft));
assert!(rows.iter().any(|row| row.kind == AccountKind::Authlib));
assert!(rows.iter().any(|row| row.kind == AccountKind::Offline));
assert_eq!(rows.iter().filter(|row| row.active).count(), 1);
assert!(rows.iter().all(|row| row.skin.is_none()), "mock 不伪造皮肤");
}
#[test]
fn the_device_states_are_renderable() {
assert!(device_login("device").is_some_and(|view| !view.user_code.is_empty()));
assert!(device_login("failed")
.is_some_and(|view| view.phase == crate::dto::DeviceLoginPhase::Failed));
assert!(device_login("list").is_none());
}
}
+13 -2
View File
@@ -35,6 +35,11 @@ pub enum Route {
Logs,
/// G6 设置页
Settings,
/// G7 账户页(三轨登录:微软正版 / 第三方皮肤站 / 本地档案)
///
/// 一级页,但**不进主序列**:入口是导航轨底部的头像位(`shell/nav.rs`),
/// 所以 [`Route::nav`] 对它返回 `None`(不点亮四个主项中的任何一个)。
Accounts,
/// 实例详情(G4):从磁贴点进来,返回时回到实例页
Detail(InstanceId),
/// AI 助手独立页(D16 双形态之一)
@@ -46,7 +51,13 @@ impl Route {
pub fn is_top_level(&self) -> bool {
matches!(
self,
Self::Instances | Self::Download | Self::Content | Self::Logs | Self::Settings
// M109 的 `Content` 与 M114 的 `Accounts` 都是一级页:两个都留。
Self::Instances
| Self::Download
| Self::Content
| Self::Logs
| Self::Settings
| Self::Accounts
)
}
@@ -57,7 +68,7 @@ impl Route {
Self::Download | Self::Content => Some(NavSection::Download),
Self::Logs => Some(NavSection::Logs),
Self::Settings => Some(NavSection::Settings),
Self::Agent => None,
Self::Accounts | Self::Agent => None,
}
}
}
+42 -54
View File
@@ -6,9 +6,26 @@
use crate::agent::AgentRail;
use crate::dialogs::DialogQueue;
use crate::dto::{
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
LogSession, NoticeView, PackState, Progress, SettingGroupView, VersionSummary,
// 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。
AccountView,
AgentEvent,
AppEvent,
AuthlibLoginView,
CoreEvent,
DeviceLoginView,
DiagnosisReport,
InstallProgressView,
InstanceRuntime,
InstanceSummary,
JavaPageView,
LoaderKind,
LogLine,
LogSession,
NoticeView,
PackState,
Progress,
SettingGroupView,
VersionSummary,
};
use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
@@ -17,29 +34,17 @@ use crate::router::{AiPanelMode, Route, Router};
use crate::toast::{ToastCenter, ToastKind};
use crate::wizard::WizardState;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
//
// `AiSession` 不再存在:M104 把它换成了 `crate::agent::AgentRail`(agent 栏的视图模型),
// 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`,
// 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。
mod accounts;
mod live;
mod tiles;
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
pub use live::MAX_LIVE_LINES;
pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
pub type LoaderVersions = Result<Vec<String>, String>;
@@ -79,6 +84,12 @@ pub struct AppState {
pub accounts: Vec<AccountView>,
/// 微软登录状态
pub device_login: DeviceLoginView,
/// 第三方轨(authlib-injector)登录/校验状态
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 = 没有安装任务)
@@ -140,6 +151,9 @@ impl AppState {
download_query: String::new(),
accounts: Vec::new(),
device_login: DeviceLoginView::new(),
authlib_login: AuthlibLoginView::new(),
device_login_started: None,
account_notice: None,
java: JavaPageView::new(),
install: None,
install_cancelled: 0,
@@ -237,16 +251,9 @@ impl AppState {
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => {
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
if view.phase == crate::dto::DeviceLoginPhase::Failed {
self.toasts.push(ToastKind::Error, view.message.clone());
}
self.device_login = view;
}
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) => {
@@ -309,25 +316,6 @@ impl AppState {
self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed
}
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
+193
View File
@@ -0,0 +1,193 @@
//! 账户/登录状态:事件落地(含**上屏前再脱敏**)、设备码倒计时、当前账户查询。
//!
//! 从 `state.rs` 拆出来守 400 行硬线;`AppState` 的字段仍在那个文件里,
//! 这里只加它的 `impl`(同一个 crate 的固有 impl 可以拆在多个文件)。
use std::time::Instant;
use crate::dto::{AccountView, AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, REDACTED};
use super::AppState;
impl AppState {
/// 微软登录状态落地。
///
/// **凭据红线(M114)**:这里对 `message` 再做一次形状脱敏。DTO 的构造器已经脱过一遍,
/// 但状态层是**所有**事件都要过的唯一闸口——将来谁新加一个构造器忘了脱敏,也不会把
/// 服务端回显的 token 画到面板上。脱敏是幂等的,重复做没有代价。
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.device_login_started = None;
}
DeviceLoginPhase::Failed => {
self.device_login_started = None;
}
DeviceLoginPhase::Waiting => {
// 每次进入等待态都重记起点:换流/重试之后倒计时必须从头算
if self.device_login.phase != DeviceLoginPhase::Waiting
|| self.device_login.user_code != view.user_code
{
self.device_login_started = Some(Instant::now());
}
}
DeviceLoginPhase::Idle | DeviceLoginPhase::Requesting => {
self.device_login_started = None;
}
}
self.device_login = view;
}
/// 第三方轨登录状态落地(同样在状态层再脱敏一遍)。
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);
// 同 `apply_device_login`:不发 toast,成败由页面按 i18n 画。
self.authlib_login = view;
}
/// 设备码/授权码还剩几秒(`None` = 没有在等 / 不知道有效期)。
///
/// 纯计算 + 一个起点,UI 侧每秒重问一次就能画出倒计时——这也让「过期」成为**可观测**的
/// 状态(任务要求:设备码不得只转圈,过期要显示「已过期,重试」)。
pub fn device_code_remaining(&self) -> Option<u64> {
self.device_code_remaining_at(Instant::now())
}
/// [`Self::device_code_remaining`] 的可注入时钟版本(单测用)。
pub fn device_code_remaining_at(&self, now: Instant) -> Option<u64> {
if self.device_login.phase != DeviceLoginPhase::Waiting {
return None;
}
let expires = self.device_login.expires_in;
if expires == 0 {
return None;
}
let started = self.device_login_started?;
let elapsed = now.saturating_duration_since(started).as_secs();
Some(expires.saturating_sub(elapsed))
}
/// 当前启动账户(列表里 `active == true` 的那条)。
pub fn active_account(&self) -> Option<&AccountView> {
self.accounts.iter().find(|account| account.active)
}
/// 面板要显示的脱敏占位符(守门测试用:值不该出现在任何可见文案里)。
pub const REDACTED: &'static str = REDACTED;
}
#[cfg(test)]
mod tests {
use super::*;
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
fn state() -> AppState {
AppState::new()
}
/// 凭据红线:**绕过构造器**直接塞一个带 JWT 的事件,状态层必须把它抹掉。
///
/// 抓得住:`apply_device_login` / `apply_authlib_login` 里的脱敏被删(那是「最后一个闸口」)。
/// 抓不住:恶意的 UI 层(如果有人绕过 `AppState` 直接画 DTO)。
#[test]
fn a_token_injected_into_events_never_reaches_the_state() {
let mut state = state();
state.apply_device_login(DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("登录失败:accessToken={JWT}"),
..DeviceLoginView::new()
});
assert!(
!state.device_login.message.contains(JWT),
"{}",
state.device_login.message
);
assert!(state.device_login.message.contains(REDACTED));
state.apply_authlib_login(AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("服务器回包 {JWT}"),
..AuthlibLoginView::new()
});
assert!(
!state.authlib_login.message.contains(JWT),
"{}",
state.authlib_login.message
);
}
#[test]
fn the_device_code_countdown_is_derived_from_a_start_instant() {
let mut state = state();
state.apply_device_login(DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
));
let started = state.device_login_started.expect("等待态要记起点");
assert_eq!(state.device_code_remaining_at(started), Some(900));
let later = started + std::time::Duration::from_secs(120);
assert_eq!(state.device_code_remaining_at(later), Some(780));
let expired = started + std::time::Duration::from_secs(901);
assert_eq!(
state.device_code_remaining_at(expired),
Some(0),
"过期后是 0,不是 None"
);
// 没有在等的时候不画倒计时
state.apply_device_login(DeviceLoginView::failed("网络超时"));
assert_eq!(state.device_code_remaining_at(started), None);
}
#[test]
fn a_new_waiting_view_restarts_the_countdown() {
let mut state = state();
state.apply_device_login(DeviceLoginView::waiting("AAAA-AAAA", "https://a", 900, ""));
let first = state.device_login_started.expect("起点");
state.apply_device_login(DeviceLoginView::waiting("BBBB-BBBB", "https://a", 900, ""));
let second = state.device_login_started.expect("起点");
assert!(second >= first, "换了一个用户码就要重算倒计时");
}
#[test]
fn the_active_account_is_the_one_marked_active() {
let mut state = state();
state.accounts = vec![
AccountView {
id: "account:a".to_string(),
name: "Steve".to_string(),
kind: crate::dto::AccountKind::Microsoft,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: String::new(),
active: false,
server_url: String::new(),
skin: None,
},
AccountView {
id: "account:b".to_string(),
name: "Alex".to_string(),
kind: crate::dto::AccountKind::Offline,
profile_id: "1a2b".to_string(),
login_name: String::new(),
created: String::new(),
active: true,
server_url: String::new(),
skin: None,
},
];
assert_eq!(
state.active_account().map(|a| a.name.as_str()),
Some("Alex")
);
}
}
+30
View File
@@ -0,0 +1,30 @@
//! 实时日志的滚动窗口(从 `state.rs` 搬出,守 400 行硬线)。
//!
//! 纯搬移:常量与 `append_live_lines` 一字未改;`state.rs` 用 `pub use` 再导出
//! [`MAX_LIVE_LINES`],老路径 `crate::state::MAX_LIVE_LINES` 继续可用。
use super::AppState;
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
impl AppState {
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
pub(super) fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
//! 磁贴网格的纯函数与单元类型(从 `state.rs` 原样搬出,守 400 行硬线)。
//!
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
use crate::dto::InstanceSummary;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}