fix(app-iced, presenter): M114 r2 —— 登录失败走结构化错误码 + i18n,修 4 条 p2 与 3 条看图意见

p1(语言泄漏):后端诊断串不再上屏
- presenter 新增 LoginFailure 原因码(denied / whitelist_pending / unreachable /
  not_authlib_server / bad_credentials / no_profile / store_failed / other)
- 页面按码查 i18n 出标题+正文+「Code: …」,原文只进日志/详细信息
- 进行中(Requesting)也走 i18n,不再复读后端中文状态串
- presenter/state 不再发中文 toast(成败由页面按 i18n 画)
- 新增守门测试:注入带 AADSTS 的失败事件,断言英文视图无 CJK 且含 code

p2-1 复制:on_account 返回 Task,page_messages 把 iced::clipboard::write 交回 runtime
p2-2 可见失败态:新增 AccountNotice(added/removed/switched/名字非法/重名/落盘失败/
      找不到),页面用 M3 内联 alert 画(不是 toast);离线名字先按 3–16 位规则校验,
      失败不再清空输入
p2-3 重新登录按账户类型分派:微软→授权码流;第三方→预填服务器/登录名并提示补密码;
      离线→明确说明没有可重登的服务器账户
p2-4 入口收敛:删 chip 行;空态=三卡片;有账号时三卡收进 Add an account(按钮不再绑死微软轨)

看图意见/tower 三条:
- 失败横幅改成 M3 alert(SurfaceContainerHigh + 描边,errorContainer 只作图标块,
  不再通栏实心红);色值本身经复审像素采样确认是 90/30 档,未改角色
- 当前账户卡补齐与列表一致的「服务器/登录名」行;无皮肤时明说 No skin available
- 页面级 scrollable 加 8px 右 gutter,滚动条不再压卡片
- 倒计时改 mm:ss(15:00 left),数字与冒号语言无关

p3(顺带):AuthlibRequest / Form 手写 Debug 脱敏密码;HTTP 响应体与 base64 输出加上限;
离线提示文案与行为对齐;删除无人使用的 key;皮肤测试断言帽子层真的 blend

测试:app-iced lib 418 绿、presenter 151 / i18n 24 绿(workspace 全量另跑)
This commit is contained in:
AskaEth committed 2026-11-05 05:09:51 +08:00
1 parent 1ff00bbe50
commit 8d8552113a
31 files changed
+1540 -497

No files matched your search

@@ -0,0 +1,129 @@
//! authlib 轨的 HTTP 基座与纯函数小工具:`clientToken` 生成、GET 文本/字节、base64 解码、按已知值脱敏。
//!
//! 从 `authlib.rs` 拆出来守 400 行硬线。网络只走**既有下载引擎**的客户端
//! (`host::engine::engine(env).client()`):代理/超时/证书栈与下载器同源,本 crate 不新增依赖。
use unlucky_core::auth::microsoft::now_ms;
use unlucky_core::download::blocking::block_on;
use crate::dto::REDACTED;
use super::super::super::engine;
use super::super::super::env::HostEnv;
/// 一次 GET 的超时上限(秒):服务器不可达时要能失败,而不是挂住 UI 的登录面板。
pub(super) const HTTP_TIMEOUT_SECS: u64 = 15;
/// 单次 GET 的响应体上限(字节):用户能填任意 URL,服务发现 JSON/皮肤 PNG 都远小于它。
pub(super) const MAX_BODY_BYTES: usize = 512 * 1024;
/// base64 解码输出上限(textures 属性是个小 JSON)。
pub(super) const MAX_BASE64_BYTES: usize = 64 * 1024;
/// 启动器侧生成的 `clientToken`(Yggdrasil 要求刷新/校验都带,且服务端回传必须一致)。
pub(super) fn client_token() -> String {
use std::hash::{BuildHasher, Hasher};
let mut out = String::with_capacity(32);
for salt in 0..2u64 {
// `RandomState` 每个实例自带随机种子(标准库给的是进程级随机),不引第三方随机库。
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(now_ms() ^ salt);
hasher.write_u32(std::process::id());
out.push_str(&format!("{:016x}", hasher.finish()));
}
out
}
/// GET 文本(状态码 + 正文)。
///
/// 客户端来自**既有的下载引擎**(`host::engine::engine`):代理/超时/证书栈与本仓的下载器
/// 完全同源,不在这里另起一套 HTTP 配置(本 crate 也没有 reqwest 依赖,拿到的只是它的 `&Client`)。
pub(super) fn get_text(env: &HostEnv, url: &str) -> Result<(u16, String), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("服务器不可达:{error}"))?;
let status = reply.status().as_u16();
let text = reply
.text()
.await
.map_err(|error| format!("读取响应失败:{error}"))?;
Ok((status, text))
})
}
/// GET 字节(皮肤图片)。
pub(super) fn get_bytes(env: &HostEnv, url: &str) -> Result<(u16, Vec<u8>), String> {
let engine = engine::engine(env);
let client = engine.client();
let url = url.to_string();
block_on(async move {
let reply = client
.get(&url)
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.send()
.await
.map_err(|error| format!("皮肤图片不可达:{error}"))?;
let status = reply.status().as_u16();
let bytes = reply
.bytes()
.await
.map_err(|error| format!("读取皮肤图片失败:{error}"))?
.to_vec();
if bytes.len() > MAX_BODY_BYTES {
return Err(format!(
"图片过大({} 字节,上限 {MAX_BODY_BYTES})",
bytes.len()
));
}
Ok((status, bytes))
})
}
/// base64 解码(标准字母表,容忍 URL-safe 变体与缺失填充)。
///
/// 为什么自己写:textures 属性是 base64 编码的 JSON,而本 crate 不能引第三方解码库
/// (依赖最小化);这段是 40 行的确定性算法,配单测比拖一个依赖更划算。
pub(super) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(input.len() / 4 * 3);
let mut buffer: u32 = 0;
let mut bits = 0u32;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b'\n' | b'\r' | b' ' => continue,
other => return Err(format!("base64 里有非法字符:{:?}", other as char)),
};
buffer = (buffer << 6) | u32::from(value);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or(0));
}
if out.len() > MAX_BASE64_BYTES {
return Err(format!("base64 输出超过上限 {MAX_BASE64_BYTES} 字节"));
}
}
if out.is_empty() {
return Err("base64 内容为空".to_string());
}
Ok(out)
}
/// 错误文案里可能混进已知凭据值:按值再兜一遍(形状脱敏由 `AuthlibLoginView::failed` 做)。
#[must_use]
pub fn scrub_known(message: &str, secret: &str) -> String {
if secret.is_empty() {
return message.to_string();
}
message.replace(secret, REDACTED)
}