复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。
- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
`.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
`/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
`TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
`callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
(下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。
门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
401 lines
16 KiB
Rust
401 lines
16 KiB
Rust
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
|
||
//!
|
||
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
||
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
|
||
//!
|
||
//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项),
|
||
//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路,
|
||
//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用:
|
||
//! 同一个取消槽位、同一段账户入库、同一个刷新链。
|
||
//!
|
||
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||
//!
|
||
//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板,
|
||
//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。
|
||
|
||
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::offline::offline_meta;
|
||
use unlucky_core::auth::{AccountStore, AuthError, AuthResult};
|
||
use unlucky_core::download::blocking::block_on;
|
||
|
||
use crate::dto::CoreEvent;
|
||
use crate::dto::{
|
||
valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView,
|
||
BrowserLoginNote, DeviceLoginView, LoginFailure,
|
||
};
|
||
|
||
use super::env::HostEnv;
|
||
use super::job::{spawn, HostTask, JobSender};
|
||
use super::opener;
|
||
|
||
/// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。
|
||
pub mod authlib;
|
||
|
||
mod refresh;
|
||
|
||
mod failure;
|
||
|
||
pub use failure::AccountFailure;
|
||
|
||
use failure::classify;
|
||
|
||
pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login};
|
||
|
||
impl super::CoreHost {
|
||
/// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。
|
||
pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) {
|
||
self.send(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new(
|
||
code, subject, detail,
|
||
))));
|
||
}
|
||
|
||
/// 账户写操作失败:**原文进日志**,页面只拿结构化码 + 主题名。
|
||
pub(super) fn notice_failure(&mut self, failure: AccountFailure, subject: &str) {
|
||
eprintln!(
|
||
"[accounts] 操作失败({},subject={subject}):{}",
|
||
failure.code.code(),
|
||
failure.detail
|
||
);
|
||
self.notice(failure.code, subject, &failure.detail);
|
||
}
|
||
}
|
||
|
||
/// 账户列表(公开字段)。
|
||
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
||
let store = AccountStore::open(env.layout.auth_data());
|
||
store
|
||
.accounts()
|
||
.iter()
|
||
.map(|meta| AccountView {
|
||
id: meta.id.as_str().to_string(),
|
||
name: meta.display_name().to_string(),
|
||
kind: AccountKind::parse(meta.kind.as_str()),
|
||
profile_id: meta.profile_id.clone(),
|
||
login_name: meta.login_name.clone().unwrap_or_default(),
|
||
created: display_ms(meta.created_at_ms),
|
||
active: active.is_some_and(|id| id == meta.id.as_str()),
|
||
server_url: meta.server_url.clone().unwrap_or_default(),
|
||
// 皮肤是网络产物:列表先给 `None`,host 抓到之后按账户 id 盖回去(见 `CoreHost`)
|
||
skin: None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
|
||
pub fn display_ms(ms: u64) -> String {
|
||
if ms == 0 {
|
||
return String::new();
|
||
}
|
||
let total_secs = ms / 1000;
|
||
let days = total_secs / 86_400;
|
||
let rem = total_secs % 86_400;
|
||
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
|
||
format!(
|
||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||
rem / 3_600,
|
||
(rem % 3_600) / 60
|
||
)
|
||
}
|
||
|
||
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
|
||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||
let z = days + 719_468;
|
||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||
let doe = z - era * 146_097;
|
||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||
let y = yoe + era * 400;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||
}
|
||
|
||
/// 加一个离线账户。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 名字不合法、同名冲突、落盘失败时返回 [`AccountFailure`](带结构化码)。
|
||
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, AccountFailure> {
|
||
let name = name.trim();
|
||
if !valid_offline_name(name) {
|
||
return Err(AccountFailure {
|
||
code: AccountNoticeCode::OfflineNameInvalid,
|
||
detail: format!("离线名字必须是 3–16 位字母/数字/下划线,收到 {name:?}"),
|
||
});
|
||
}
|
||
let meta = offline_meta(name, now_ms())
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::OfflineNameInvalid))?;
|
||
let display = meta.display_name().to_string();
|
||
let mut store = AccountStore::open(env.layout.auth_data());
|
||
store
|
||
.insert(meta, Credentials::Offline)
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::AccountExists))?;
|
||
store
|
||
.save()
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||
Ok(display)
|
||
}
|
||
|
||
/// 删除账户(按 id 或名字)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 找不到、落盘失败时返回 [`AccountFailure`]。
|
||
pub fn remove(env: &HostEnv, key: &str) -> Result<String, AccountFailure> {
|
||
let mut store = AccountStore::open(env.layout.auth_data());
|
||
let meta = store
|
||
.remove(key)
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::NotFound))?;
|
||
store
|
||
.save()
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||
Ok(meta.display_name().to_string())
|
||
}
|
||
|
||
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct LoginJob {
|
||
/// 用户点了取消(线程收尾时据此丢弃结果)
|
||
pub cancelled: Arc<AtomicBool>,
|
||
}
|
||
|
||
impl LoginJob {
|
||
/// 新建一个未取消的句柄。
|
||
pub fn new() -> Self {
|
||
Self {
|
||
cancelled: Arc::new(AtomicBool::new(false)),
|
||
}
|
||
}
|
||
|
||
/// 取消。
|
||
pub fn cancel(&self) {
|
||
self.cancelled.store(true, Ordering::SeqCst);
|
||
}
|
||
|
||
/// 是否已取消。
|
||
pub fn is_cancelled(&self) -> bool {
|
||
self.cancelled.load(Ordering::SeqCst)
|
||
}
|
||
}
|
||
|
||
/// 起一次微软登录(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 {
|
||
BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered,
|
||
BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable,
|
||
BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed,
|
||
}
|
||
}
|
||
|
||
/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum MsaEntry {
|
||
/// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。
|
||
DeviceCode,
|
||
/// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。
|
||
Browser,
|
||
}
|
||
|
||
impl MsaEntry {
|
||
/// 这个入口要不要把 `browser_first` 置位。
|
||
const fn browser_first(self) -> bool {
|
||
matches!(self, Self::Browser)
|
||
}
|
||
}
|
||
|
||
/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。
|
||
///
|
||
/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话,
|
||
/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。
|
||
fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult<MicrosoftAuth> {
|
||
#[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 的测试构建里都会在网络之前早退,拿不到
|
||
/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用
|
||
/// 的一刻就把模式记下来,把调用点变成可断言的事实。
|
||
///
|
||
/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局
|
||
/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。
|
||
#[cfg(test)]
|
||
mod test_seam {
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use super::{LoginJob, MsaEntry};
|
||
|
||
static ENTRIES: Mutex<Vec<(usize, bool)>> = Mutex::new(Vec::new());
|
||
|
||
fn key(job: &LoginJob) -> usize {
|
||
Arc::as_ptr(&job.cancelled) as usize
|
||
}
|
||
|
||
pub(super) fn record(job: &LoginJob, entry: MsaEntry) {
|
||
let key = key(job);
|
||
let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||
entries.push((key, entry.browser_first()));
|
||
}
|
||
|
||
/// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。
|
||
pub(super) fn mode_for(job: &LoginJob) -> Option<bool> {
|
||
let key = key(job);
|
||
let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||
entries
|
||
.iter()
|
||
.find(|(recorded, _)| *recorded == key)
|
||
.map(|(_, browser_first)| *browser_first)
|
||
}
|
||
}
|
||
|
||
/// `msa_auth` 失败 → 原因码。
|
||
///
|
||
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
||
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
||
/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。
|
||
fn auth_failure(error: &AuthError) -> LoginFailure {
|
||
match error {
|
||
AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured,
|
||
other => classify(other),
|
||
}
|
||
}
|
||
|
||
/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。
|
||
fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into<String>) {
|
||
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<MicrosoftSession>) {
|
||
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<String, String> {
|
||
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;
|