feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)

- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验)
- 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、
  本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃)
- 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条)
- 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证)
- presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、
  皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml)
- i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
This commit is contained in:
AskaEth committed 2026-11-05 05:08:35 +08:00
1 parent e7b04c4afe
commit 1ff00bbe50
43 files changed
+4343 -117

No files matched your search

+322
View File
@@ -0,0 +1,322 @@
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
//! | `an_empty_account_list_offers_all_three_tracks` | 空态变成白页(没有入口) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」(倒计时/过期不可见) |
//! | `a_third_party_account_falls_back_to_the_monogram` | 没有皮肤却画白块 / 拿假皮肤充数 |
//! | `the_offline_track_keeps_the_agreed_wording` | 离线档案的说明被改写成「兼容旧启动器」那套 |
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AccountKind, AccountView, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase,
DeviceLoginView, SkinImage,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::model::{AuthlibPanel, DevicePanel, Form, Page, Panel, Track};
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 {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// 把视图模型里**所有会上屏的文本**拼起来(凭据红线就是在这坨里查)。
fn visible_text(page: &Page) -> String {
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.server);
out.push_str(&page.form.login);
out.push_str(&page.form.offline_name);
out.push_str(&page.checked_server);
match &page.device {
DevicePanel::Hidden => {}
DevicePanel::Requesting { message } => out.push_str(message),
DevicePanel::Code {
code,
link,
message,
..
} => {
out.push_str(code);
out.push_str(link);
out.push_str(message);
}
DevicePanel::Browser { link, message, .. } => {
out.push_str(link);
out.push_str(message);
}
DevicePanel::Failed { message } => out.push_str(message),
DevicePanel::Succeeded { name } => out.push_str(name),
}
match &page.authlib {
AuthlibPanel::Idle => {}
AuthlibPanel::Running { message } => out.push_str(message),
AuthlibPanel::Ready {
server_name,
message,
} => {
out.push_str(server_name);
out.push_str(message);
}
AuthlibPanel::Failed { message } => out.push_str(message),
AuthlibPanel::Succeeded { name } => out.push_str(name),
}
out
}
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 视图模型里不许出现。
///
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`),
/// 或页面把 DTO 的错误原文直接搬上屏。
/// 抓不住:某个将来新增的**独立**字段绕过 `message` 通道(那需要另加断言)。
#[test]
fn a_jwt_injected_into_events_never_reaches_the_page() {
let mut state = AppState::new();
state.accounts = vec![account("Steve", AccountKind::Microsoft, true)];
// **绕过构造器**直接摆一个带 JWT 的 DTO:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
// 用 `DeviceLoginView::failed(..)` 构造是测不到状态层脱敏的(构造器自己就脱过了)。
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
..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}"),
account_name: String::new(),
},
)));
let page = build(&state, &Form::new());
let text = visible_text(&page);
assert!(!text.contains("eyJ"), "视图模型里出现了 JWT:{text}");
assert!(!text.to_lowercase().contains("refreshtoken"), "{text}");
assert!(
text.contains("⟪redacted⟫"),
"错误文案要被标成已脱敏:{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());
assert_eq!(Track::ALL.len(), 3);
// 三条轨道的文案 key 各不相同(不是三张一样的卡)
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);
}
/// 设备码:**倒计时可见**,过期后是「已过期」而不是继续转圈。
#[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!(remaining.is_some_and(|secs| secs > 0));
assert!(!expired);
}
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
}
// 起点往前挪 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(), "皮肤预览区没有图就不画");
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 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());
// 离线档案永远没有皮肤:即使被塞了假数据,UI 侧也只认 DTO 的 `kind`
state.accounts[1].skin = None;
state.accounts[1].kind = AccountKind::Offline;
let page = build(&state, &Form::new());
assert!(page.rows[1].head.is_none());
}
/// 离线档案的说明**必须**带「无法连接启用正版验证的服务器」这条限定(中英都要)。
#[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}"
);
}
}
}
/// 三条轨道在列表里**文案互不相同**(每一行都贴自己的轨道徽标)。
#[test]
fn every_track_has_its_own_badge() {
let mut state = AppState::new();
state.accounts = vec![
account("a", AccountKind::Microsoft, true),
account("b", AccountKind::Authlib, false),
account("c", AccountKind::Offline, false),
];
let page = build(&state, &Form::new());
let labels: Vec<&str> = page
.rows
.iter()
.map(|row| row.kind_label.as_str())
.collect();
assert_eq!(labels.len(), 3);
assert_ne!(labels[0], labels[1]);
assert_ne!(labels[1], labels[2]);
assert!(
labels.iter().all(|label| !label.is_empty()),
"轨道徽标不能空"
);
}
/// 表单状态:三条轨道各开各的,密码关面板即清。
#[test]
fn the_form_tracks_are_independent() {
let mut form = Form::new();
form.open(Track::Offline);
assert_eq!(form.panel, Panel::Offline);
assert!(!form.offline_ready(), "空名字不能提交");
form.offline_name = "Alex".to_string();
assert!(form.offline_ready());
form.open(Track::Authlib);
assert_eq!(form.panel, Panel::Authlib);
assert!(!form.authlib_ready(), "地址/登录名/密码缺一不可");
form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
form.login = "alex".to_string();
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()
},
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Requesting { .. }
));
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
));
}