feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验) - 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、 本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃) - 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条) - 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证) - presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、 皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml) - i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
This commit is contained in:
1 parent
e7b04c4afe
commit
1ff00bbe50
43 files changed
+4343
-117
No files matched your search
@@ -0,0 +1,204 @@
|
||||
//! 账户/登录状态:事件落地(含**上屏前再脱敏**)、设备码倒计时、当前账户查询。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出来守 400 行硬线;`AppState` 的字段仍在那个文件里,
|
||||
//! 这里只加它的 `impl`(同一个 crate 的固有 impl 可以拆在多个文件)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::dto::{AccountView, AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, REDACTED};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
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);
|
||||
match view.phase {
|
||||
DeviceLoginPhase::Succeeded => {
|
||||
self.toasts
|
||||
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
|
||||
self.device_login_started = None;
|
||||
}
|
||||
DeviceLoginPhase::Failed => {
|
||||
self.toasts.push(ToastKind::Error, view.message.clone());
|
||||
self.device_login_started = None;
|
||||
}
|
||||
DeviceLoginPhase::Waiting => {
|
||||
// 每次进入等待态都重记起点:换流/重试之后倒计时必须从头算
|
||||
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);
|
||||
match view.phase {
|
||||
DeviceLoginPhase::Succeeded => {
|
||||
self.toasts
|
||||
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
|
||||
}
|
||||
DeviceLoginPhase::Failed => {
|
||||
self.toasts.push(ToastKind::Error, view.message.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user