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:
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)
|
||||
}
|
||||
Reference in new issue
Block a user