Files
Unlucky-Launcher/crates/app-iced/src/pages/account.rs
T
AskaEth f82bd395f8 fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路
官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用
注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回
`invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页,
启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。

- core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。
  预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions*
  原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。
  预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化
  原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。
- presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退,
  回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。
- i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。
- app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败
  面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。
- 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码;
  ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路);
  ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。
2026-11-08 20:23:42 +08:00

179 lines
6.4 KiB
Rust

//! 账户页(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_msa.rs"]
mod tests_msa;
#[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),
)
}