Files
Unlucky-Launcher/crates/app-iced/src/pages/account/panels.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

173 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 两条登录流的面板态(微软设备码/授权码 + 第三方站校验/登录)。
//!
//! 从 `model.rs` 拆出来守 400 行硬线:这些类型是「UI 要画什么」的枚举,
//! 判定逻辑(倒计时 → 已过期、服务发现 → 可用)也在这里,页面与表单只消费结果。
//!
//! ## 为什么这里**没有** `message` / `detail` 字段(M114 r3 / 复审 r2 P2-2)
//!
//! 后端的原始诊断串是中文人话,只该进日志。r2 时它们还以 `message`/`detail` 存在于此,
//! 「页面把它们画上屏」只能靠一条**平行实现**的取样断言去拦(复审用变异证明那条断言是哑的)。
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevicePanel {
/// 没有登录在跑
Hidden,
/// 正在申请设备码(进度文案由页面按 i18n 出)
Requesting,
/// 设备码等待用户操作
Code {
/// 用户码
code: String,
/// 验证链接
link: String,
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
///
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
countdown: String,
/// 是否已过期(决定颜色与「重试」按钮)
expired: bool,
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
note: Option<BrowserLoginNote>,
},
/// 授权码流:等浏览器回调
Browser {
/// 授权链接(可复制)
link: String,
/// 倒计时那行已渲染好的文案(同上)
countdown: String,
/// 是否已过期
expired: bool,
},
/// 失败(**只有原因码**;原文在 presenter 的日志里)
Failed {
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
note: Option<BrowserLoginNote>,
},
/// 成功
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>,
catalog: Catalog,
) -> DevicePanel {
let expired = remaining == Some(0);
let countdown = countdown_text(remaining, expired, catalog);
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(),
countdown,
expired,
}
} else {
DevicePanel::Code {
code: view.user_code.clone(),
link: view.qr_link.clone(),
countdown,
expired,
note: view.browser_note,
}
}
}
DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other),
note: view.browser_note,
},
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(),
},
}
}
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
///
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
if expired {
return catalog.text(Key::AccountDeviceExpired).to_string();
}
remaining.map_or_else(String::new, |secs| {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
})
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
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 { .. })
}
}