From 30d41638166622bae693f6e588fb2dc3176a5880 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 06:08:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor(accounts):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E6=B5=81=E6=8B=86=E5=88=B0=20accounts/login.?= =?UTF-8?q?rs=EF=BC=88=E7=BA=AF=E6=90=AC=E7=A7=BB=EF=BC=8CM236=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accounts.rs 正好 400 行零余量,本轮要给测试缝换稳定身份,改前必须先拆。 搬移口径:start_login / start_browser_login / msa_auth / fail / finish / store_session 的每一行(含签名与 doc)与搬移前逐行相同(130 行非空行 diff 为空,只有空行分隔差异);公开路径由 `pub use login::{...}` 保住 (host::accounts::start_login / start_browser_login 不变)。 列表/增删/LoginJob/MsaEntry/测试缝留在父文件:那是测试要用的私有件, 搬走它们就得放宽可见性,纯搬移口径会随之破掉。 --- crates/presenter/src/host/accounts.rs | 156 ++----------------- crates/presenter/src/host/accounts/login.rs | 161 ++++++++++++++++++++ crates/presenter/src/host/accounts/tests.rs | 1 + 3 files changed, 173 insertions(+), 145 deletions(-) create mode 100644 crates/presenter/src/host/accounts/login.rs diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index e98095a..4c1b582 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -13,41 +13,43 @@ //! //! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板, //! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。 +//! +//! M236 拆分:两条流的编排搬到 `accounts/login.rs`(纯搬移,路径靠下面的 `pub use` 保住), +//! 本文件只留列表/增删/句柄/测试缝——原文件正好 400 行零余量,测试缝换身份前必须先拆。 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{ - now_ms, BrowserFallbackReason, MicrosoftAuth, MicrosoftSession, -}; +use unlucky_core::auth::microsoft::{now_ms, BrowserFallbackReason}; use unlucky_core::auth::offline::offline_meta; -use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; -use unlucky_core::download::blocking::block_on; +use unlucky_core::auth::{AccountStore, AuthError}; use crate::dto::CoreEvent; use crate::dto::{ valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, - BrowserLoginNote, DeviceLoginView, LoginFailure, + BrowserLoginNote, LoginFailure, }; use super::env::HostEnv; -use super::job::{spawn, HostTask, JobSender}; -use super::opener; /// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。 pub mod authlib; +mod login; + mod refresh; mod failure; pub use failure::AccountFailure; -use failure::classify; +pub use login::{start_browser_login, start_login}; pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login}; +use failure::classify; + impl super::CoreHost { /// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。 pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) { @@ -186,80 +188,6 @@ impl LoginJob { } } -/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 -/// -/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, -/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 -pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-login", move || { - let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { - Ok(auth) => auth, - Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), - }; - let tx_prompt = tx.clone(); - let result = block_on(auth.device_code_login(|prompt| { - let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting( - &prompt.user_code, - &prompt.verification_uri, - prompt.expires_in.as_secs(), - &format!("{}#{}", prompt.verification_uri, prompt.user_code), - )))); - })); - finish(&env, &tx, &job, result); - }); -} - -/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 -/// -/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 -/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 -pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-browser", move || { - // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, - // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 - let auth = match msa_auth(&env, MsaEntry::Browser, &job) { - Ok(auth) => auth, - Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), - }; - let tx_browser = tx.clone(); - let tx_device = tx.clone(); - let result = block_on(auth.browser_then_device_login( - |prompt| { - // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 - let opened = opener::open_url(&prompt.authorization_url); - let _ = - tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( - &prompt.authorization_url, - prompt.timeout.as_secs(), - opened.err().as_deref(), - )))); - }, - |prompt, fallback| { - let view = DeviceLoginView::waiting( - &prompt.user_code, - &prompt.verification_uri, - prompt.expires_in.as_secs(), - &format!("{}#{}", prompt.verification_uri, prompt.user_code), - ); - let view = match fallback { - Some(fallback) => { - // 回退原文只进日志;面板只看码。 - eprintln!( - "[auth] 浏览器登录未走成({}),已回退设备码:{}", - fallback.reason.code(), - fallback.detail - ); - view.with_browser_note(browser_note(fallback.reason)) - } - None => view, - }; - let _ = tx_device.send(HostTask::Login(Box::new(view))); - }, - )); - finish(&env, &tx, &job, result.map(|outcome| outcome.session)); - }); -} - /// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。 fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { match reason { @@ -285,21 +213,6 @@ impl MsaEntry { } } -/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 -/// -/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, -/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 -fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { - #[cfg(test)] - test_seam::record(_job, entry); - MicrosoftAuth::from_env().map(|auth| { - auth.with_browser_first(entry.browser_first()) - .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))) - }) -} - /// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。 /// /// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到 @@ -349,52 +262,5 @@ fn auth_failure(error: &AuthError) -> LoginFailure { } } -/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。 -fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into) { - let detail = detail.into(); - eprintln!("[auth] 微软登录失败({}):{detail}", failure.code()); - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as( - failure, detail, - )))); -} - -/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。 -fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult) { - if job.is_cancelled() { - let _ = tx.send(HostTask::Login(Box::default())); - return; - } - match result { - Ok(session) => match store_session(env, session) { - Ok(name) => { - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); - let _ = tx.send(HostTask::Accounts(accounts(env, None))); - } - Err(message) => fail(tx, LoginFailure::StoreFailed, message), - }, - Err(error) => { - let failure = classify(&error); - fail(tx, failure, error.to_string()); - } - } -} - -/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。 -fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result { - let meta = unlucky_core::auth::account::AccountMeta::new( - unlucky_core::auth::account::AccountKind::Microsoft, - session.profile_id.clone(), - session.profile_name.clone(), - now_ms(), - ); - let name = meta.display_name().to_string(); - let mut store = AccountStore::open(env.layout.auth_data()); - store - .insert(meta, Credentials::Microsoft(session)) - .and_then(|()| store.save()) - .map_err(|error| format!("账户入库失败:{error}"))?; - Ok(name) -} - #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/accounts/login.rs b/crates/presenter/src/host/accounts/login.rs new file mode 100644 index 0000000..f599ed5 --- /dev/null +++ b/crates/presenter/src/host/accounts/login.rs @@ -0,0 +1,161 @@ +//! 微软登录的两条流(设备码默认 / 浏览器优先失败回退设备码)+ 会话入库。 +//! +//! 从 `accounts.rs` 拆出来(M236):父文件顶着 400 行硬线,而本轮要给测试缝换一个**稳定身份** +//! (`Arc::as_ptr` 会被堆地址复用骗到,见父模块的 `test_seam`),拆出子模块才有地方落。 +//! +//! **纯搬移**:下面每个函数体、每一行签名都与搬移前逐行相同(没有改一个字的可见性);原来的 +//! 公开路径靠父模块的 `pub use` 保住(`host::accounts::start_login` / `start_browser_login`)。 +//! +//! 两条流除了「怎么拿到 MS access_token」之外完全共用:同一个取消槽位(父模块的 `LoginJob`)、 +//! 同一段入库(`store_session`)、同一个收尾(`finish`)——所以它们必须住在同一个文件里。 + +use unlucky_core::auth::account::Credentials; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; +use unlucky_core::auth::{AccountStore, AuthResult}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{DeviceLoginView, LoginFailure}; + +use super::super::env::HostEnv; +use super::super::job::{spawn, HostTask, JobSender}; +use super::super::opener; +use super::failure::classify; +#[cfg(test)] +use super::test_seam; +use super::{accounts, auth_failure, browser_note, LoginJob, MsaEntry}; + +/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// +/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, +/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 +pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-login", move || { + let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { + Ok(auth) => auth, + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), + }; + let tx_prompt = tx.clone(); + let result = block_on(auth.device_code_login(|prompt| { + let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + )))); + })); + finish(&env, &tx, &job, result); + }); +} + +/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 +/// +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 +/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 +pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-browser", move || { + // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, + // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 + let auth = match msa_auth(&env, MsaEntry::Browser, &job) { + Ok(auth) => auth, + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), + }; + let tx_browser = tx.clone(); + let tx_device = tx.clone(); + let result = block_on(auth.browser_then_device_login( + |prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = + tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + }, + |prompt, fallback| { + let view = DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + ); + let view = match fallback { + Some(fallback) => { + // 回退原文只进日志;面板只看码。 + eprintln!( + "[auth] 浏览器登录未走成({}),已回退设备码:{}", + fallback.reason.code(), + fallback.detail + ); + view.with_browser_note(browser_note(fallback.reason)) + } + None => view, + }; + let _ = tx_device.send(HostTask::Login(Box::new(view))); + }, + )); + finish(&env, &tx, &job, result.map(|outcome| outcome.session)); + }); +} + +/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 +/// +/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, +/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 +fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { + #[cfg(test)] + test_seam::record(_job, entry); + MicrosoftAuth::from_env().map(|auth| { + auth.with_browser_first(entry.browser_first()) + .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) + }) +} + +/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。 +fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into) { + let detail = detail.into(); + eprintln!("[auth] 微软登录失败({}):{detail}", failure.code()); + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as( + failure, detail, + )))); +} + +/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。 +fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult) { + if job.is_cancelled() { + let _ = tx.send(HostTask::Login(Box::default())); + return; + } + match result { + Ok(session) => match store_session(env, session) { + Ok(name) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); + let _ = tx.send(HostTask::Accounts(accounts(env, None))); + } + Err(message) => fail(tx, LoginFailure::StoreFailed, message), + }, + Err(error) => { + let failure = classify(&error); + fail(tx, failure, error.to_string()); + } + } +} + +/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。 +fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result { + let meta = unlucky_core::auth::account::AccountMeta::new( + unlucky_core::auth::account::AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let name = meta.display_name().to_string(); + let mut store = AccountStore::open(env.layout.auth_data()); + store + .insert(meta, Credentials::Microsoft(session)) + .and_then(|()| store.save()) + .map_err(|error| format!("账户入库失败:{error}"))?; + Ok(name) +} diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index ba5558e..33e93d8 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -16,6 +16,7 @@ use unlucky_core::auth::AuthError; use super::*; +use super::super::job::{HostTask, JobSender}; use super::failure::mentions_whitelist; #[test]