Merge branch 'main' into feat/m182-maccorners-follow-compositor-rounde

冲突只有一个 hunk:`crates/app-iced/src/platform/mod.rs` 的模块声明表。
- 我方(M182):`pub mod host;` / `pub mod linux;` / `pub mod macos;` + `pub use host::HostOs;`
  + `plan_corner_for` 重导出;
- 主线(M168 `449ded2`):`pub mod data;`(字体缓存数据根派生)。

两边都要,全部保留并按字母序排:`data, host, linux, locale, macos, win32`。
另把模块分册表补一行 `data`(M168 的产品在表里原本缺位),首段「四件事」随之改成「五件事」。
`pub use win32::corner::{…}` 取我方超集(main 的四个名字 + `plan_corner_for`),逐个名字比对无丢失。

非冲突文件零改动:我方 11 个文件(host.rs / linux/ / macos/ / win32/ / boot/diagnostics.rs /
design/shape.rs)合并后与 tip 28762ec 逐字相同;`decorations: false` 仍在
`app/boot/diagnostics.rs:43`(裁决 B 未被越过)。
This commit is contained in:
AskaEth committed 2026-11-08 21:13:20 +08:00
commit c198a3c995
139 files changed
+16616 -1390

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
}; };
pub use exec::{ToolExecOutcome, ToolExecutor}; pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind}; pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 一轮对话最多几轮工具调用。 /// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8; pub const DEFAULT_MAX_STEPS: usize = 8;
+70 -86
View File
@@ -14,6 +14,11 @@ use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。 /// Ollama 默认端点(research/02 §5.2)。
@@ -54,6 +59,35 @@ impl ProviderKind {
} }
} }
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。 /// 设置页展示名。
#[must_use] #[must_use]
pub fn display_name(self) -> &'static str { pub fn display_name(self) -> &'static str {
@@ -174,18 +208,22 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> { pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() { if !self.is_enabled() {
return Err(ProviderError::new( return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
)); ));
} }
if self.model.trim().is_empty() { if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)")); return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
} }
if self.resolved_base_url().trim().is_empty() { if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要填写端点(base_url)", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要填写端点(base_url)", self.kind.display_name()),
))); ));
} }
if self.kind.requires_api_key() if self.kind.requires_api_key()
&& self && self
@@ -193,10 +231,10 @@ impl ProviderConfig {
.as_deref() .as_deref()
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
{ {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要 API key", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要 API key", self.kind.display_name()),
))); ));
} }
Ok(()) Ok(())
} }
@@ -239,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
} }
} }
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 /// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError { pub struct ProviderError {
kind: ProviderFailure,
message: String, message: String,
} }
impl ProviderError { impl ProviderError {
/// 从可读文本构造。 /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
kind: ProviderFailure::Other,
message: message.into(), message: message.into(),
} }
} }
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
} }
impl fmt::Display for ProviderError { impl fmt::Display for ProviderError {
@@ -264,77 +320,5 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {} impl std::error::Error for ProviderError {}
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "provider/tests.rs"]
use super::*; mod tests;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
@@ -0,0 +1,62 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
+107
View File
@@ -0,0 +1,107 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+60 -83
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt; use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError; use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::message::{ use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture, ProviderFuture,
}; };
use super::provider::{ProviderConfig, ProviderError, ProviderKind}; use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0"; pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,9 +275,63 @@ fn to_reply(
}) })
} }
/// rig 错误 → 本方错误(文案面向用户)。 /// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
fn map_error(error: RigError) -> ProviderError { fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string()) let report = error.report();
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
} }
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "rig/tests.rs"]
use super::*; mod tests;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
+82
View File
@@ -0,0 +1,82 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
+6
View File
@@ -130,6 +130,12 @@ pub struct App {
pub(super) refreshes: u64, pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool, pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32, pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
@@ -86,6 +86,10 @@ impl App {
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some()); let lang = LangState::boot(&bridge, options.screenshot.is_some());
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
crate::fonts::install::set_offline(options.screenshot.is_some());
let mut view = pages::Model::new( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
@@ -114,6 +118,9 @@ impl App {
frames: 0, frames: 0,
refreshes: 0, refreshes: 0,
auto_selected: false, auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height, height,
appearance, appearance,
appearance_prefs, appearance_prefs,
@@ -142,6 +149,10 @@ impl App {
}; };
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log(); app.lang.log();
+2
View File
@@ -87,6 +87,8 @@ impl App {
self.has_screenshot() self.has_screenshot()
|| !self.state.toasts.is_empty() || !self.state.toasts.is_empty()
|| !self.state.running().is_empty() || !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self || self
.watch_until .watch_until
.is_some_and(|deadline| Instant::now() < deadline) .is_some_and(|deadline| Instant::now() < deadline)
+25 -2
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request)); self.dispatch(AppCommand::AddAuthlibAccount(request));
} }
} }
Panel::Microsoft => return self.on_account(Account::StartBrowser), Panel::Microsoft => return self.on_account(Account::StartDevice),
Panel::None => {} Panel::None => {}
}, },
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,7 +136,8 @@ impl App {
match account.kind { match account.kind {
AccountKind::Microsoft => { AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft; self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin); // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
self.dispatch(AppCommand::StartDeviceLogin);
} }
AccountKind::Authlib => { AccountKind::Authlib => {
self.account_form.open(Track::Authlib); self.account_form.open(Track::Authlib);
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started = app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
} }
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
//
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
"msa-fallback" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
app.state.apply_device_login(
unlucky_presenter::dto::DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
)
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
);
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)` // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => { "authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib; app.account_form.panel = crate::pages::account::Panel::Authlib;
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => { "closed" => {
let _ = app.on_account(crate::pages::account::Message::Close); let _ = app.on_account(crate::pages::account::Message::Close);
} }
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
"msa-entry" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态) // 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => { "add" => {
app.account_form.section_open = true; app.account_form.section_open = true;
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
let _ = app.on_account(Account::StartDevice); let _ = app.on_account(Account::StartDevice);
match &app.account.device { match &app.account.device {
DevicePanel::Code { DevicePanel::Code {
code, remaining, .. code, countdown, ..
} => { } => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
} }
other => panic!("进行中状态必须可见,实际 {other:?}"), other => panic!("进行中状态必须可见,实际 {other:?}"),
} }
+14
View File
@@ -42,6 +42,16 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true; self.view_dirty = true;
} }
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
let font_status = crate::fonts::install::status();
if font_status != self.font_seen {
self.font_seen = font_status;
self.view_dirty = true;
}
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -68,6 +78,10 @@ impl App {
self.apply_config_theme(); self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance(); self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
} }
if self.view_dirty { if self.view_dirty {
@@ -1,17 +1,170 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。 //! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
//! //!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。 //! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
//! //!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。 //! | 消息 | 做什么 |
//! |---|---|
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
//!
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
//!
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
//! (`SettingStore::set` + `save`),只是不经过那条通道。
//!
//! ## 为什么「测试」读文件而不是读草稿
//!
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
use super::super::*; use super::super::*;
use crate::pages::settings::ai::{Probe, ProbeResult};
impl App { impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。 /// 助手分组的一条消息。
pub(super) fn on_settings_ai_about( pub(super) fn on_settings_ai_about(
&mut self, &mut self,
message: pages::settings::ai_about::Message, message: pages::settings::ai_about::Message,
) -> Task<Message> { ) -> Task<Message> {
match message {} use pages::settings::ai_about::Message;
match message {
Message::ToggleEnabled(enabled) => {
self.settings.ai_about.enabled = enabled;
self.touch();
}
Message::PickProvider(provider) => {
self.settings.ai_about.provider = provider;
self.touch();
}
Message::EditBaseUrl(value) => {
self.settings.ai_about.base_url = value;
self.touch();
}
Message::EditModel(value) => {
self.settings.ai_about.model = value;
self.touch();
}
Message::EditKey(secret) => {
self.settings.ai_about.key = secret;
self.touch();
}
Message::Reveal(reveal) => {
self.settings.ai_about.reveal = reveal;
self.view_dirty = true;
}
Message::Save => return self.save_ai_settings(),
Message::Test => return self.test_ai_connection(),
Message::ProbeDone(result) => {
self.settings.ai_about.probe = Probe::Done(*result);
self.view_dirty = true;
}
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch(&mut self) {
self.settings.ai_about.dirty = true;
self.settings.ai_about.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
///
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
/// 否则外部一改文件就把用户正在输入的内容冲掉。
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
if self.options.screenshot.is_some() {
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
self.settings.ai_about = state;
}
return;
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
return;
};
let snapshot = pages::settings::ai::store::load(&path);
if self.settings.ai_about.hydrate(snapshot) {
self.view_dirty = true;
}
}
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
fn save_ai_settings(&mut self) -> Task<Message> {
use pages::settings::ai::{store, SaveNotice};
if let Err(refusal) = self.settings.ai_about.validate() {
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
self.view_dirty = true;
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let saved = store::save(&path, &self.settings.ai_about);
match saved {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.ai_about.dirty = false;
self.settings.ai_about.hydrate(snapshot);
self.settings.ai_about.key = pages::settings::ai::Secret::default();
self.settings.ai_about.notice = Some(SaveNotice::Saved);
}
Err(reason) => {
self.settings.ai_about.notice =
Some(pages::settings::ai::SaveNotice::Failed(reason));
}
}
self.view_dirty = true;
Task::none()
}
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
fn test_ai_connection(&mut self) -> Task<Message> {
if !self.settings.ai_about.can_test() {
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
},
0,
));
self.view_dirty = true;
return Task::none();
};
self.settings.ai_about.probe = Probe::Running;
self.view_dirty = true;
Task::perform(
async move {
let started = std::time::Instant::now();
let outcome = unlucky_cli::commands::agent::probe::probe_file(
&path,
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
);
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
},
|result| {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
),
))
},
)
} }
} }
+272 -14
View File
@@ -1,33 +1,291 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 //! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 //! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 //! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//! //!
//! 消息进来的通道有两条,都不经过共享文件: //! | 消息 | 做什么 |
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; //! |---|---|
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` //! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! (`app::Message::Page` 是既有通道,不用加新变体)。 //! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//! //!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线 //! ## 为什么「设为默认」不攒到「保存」里
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的 //!
//! `the_group_dispatch_has_no_catch_all_arm` 会红。 //! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*; use super::super::*;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
impl App { impl App {
/// Java 分组的一条消息。 /// Java 分组的一条消息。
pub(super) fn on_settings_java( pub(super) fn on_settings_java(
&mut self, &mut self,
message: pages::settings::java::Message, message: pages::settings::java::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::java::Message;
match message { match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 Message::Scan => return self.start_java_scan(),
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 Message::CancelScan => {
#[cfg(test)] self.settings.java.scan.cancel();
pages::settings::java::Message::Ping => { self.hydrate_java_scan();
}
Message::AddDraft(value) => {
self.settings.java.add_draft = value;
self.settings.java.notice = None;
self.view_dirty = true;
}
Message::Add => self.add_java_manual(),
Message::SelectRuntime(binary) => self.select_java_default(&binary),
Message::EditMemory(value) => {
self.settings.java.memory = value;
self.touch_java();
}
Message::EditJvm(value) => {
self.settings.java.jvm = value;
self.touch_java();
}
Message::Save => self.save_java_settings(),
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch_java(&mut self) {
self.settings.java.dirty = true;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
///
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
///
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
///
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some();
let mut live = false;
if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() == "live" {
live = true;
} else {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state;
}
return;
}
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java = pages::settings::java::State::default();
return;
};
let snapshot = pages::settings::java::store::load(&config);
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
let changed = self.settings.java.hydrate(snapshot);
let scan_changed = self.hydrate_java_scan();
self.settings.java.context = self.java_context();
if changed || scan_changed {
self.view_dirty = true;
}
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
if !cfg!(test) {
self.auto_scan_java_if_due(seeded && !live);
}
}
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
///
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
let report = self.settings.java.scan.report();
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
let _ = self.start_java_scan();
return true;
}
false
}
/// 把后台扫描的快照灌进状态。返回是否变了。
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
let report = self.settings.java.scan.report();
let changed = self.settings.java.apply_scan(&report);
if changed {
self.view_dirty = true;
}
changed
}
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
fn start_java_scan(&mut self) -> Task<Message> {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let manual = pages::settings::java::store::load_manual(&config)
.into_iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let roots = pages::settings::java::store::managed_roots(&config);
self.settings.java.notice = None;
if self.settings.java.scan.start(roots, manual, true) {
self.hydrate_java_scan();
}
self.view_dirty = true; self.view_dirty = true;
Task::none() Task::none()
} }
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
fn add_java_manual(&mut self) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
let draft = self.settings.java.add_draft.clone();
match pages::settings::java::store::add_manual(&config, &draft) {
Ok((binary, row)) => {
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
if !self
.settings
.java
.runtimes
.iter()
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
{
self.settings.java.runtimes.push(row);
self.settings
.java
.runtimes
.sort_by_key(|runtime| runtime.major);
}
self.settings.java.add_draft = String::new();
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
self.settings.java.scanned = true;
self.settings.java.notice = Some(Notice::Added(binary));
}
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
}
self.view_dirty = true;
}
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
fn select_java_default(&mut self, binary: &str) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::set_selected(&config, binary) {
Ok(snapshot) => {
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
fn save_java_settings(&mut self) {
let state = &self.settings.java;
if let Err(refusal) = state.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
self.view_dirty = true;
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::save(&config, &self.settings.java) {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.java.dirty = false;
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
///
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
/// (页面就不画门槛那一行,也不假装有要求)。
fn java_context(&self) -> Context {
let tiles = self.state.tiles();
let routed = match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => {
tiles.iter().find(|cell| &cell.instance.id == id)
}
_ => None,
};
let Some(cell) = routed.or_else(|| tiles.first()) else {
return Context::default();
};
let instance = cell.instance;
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
Context {
required: Some(Requirement {
instance: instance.name.clone(),
version: instance.mc_version.clone(),
minimum: requirement.minimum,
maximum: requirement.maximum,
}),
layer: Some(match instance.launch.memory_mb {
Some(mb) => Layer::Instance {
instance: instance.name.clone(),
mb,
},
None => Layer::Global,
}),
effective_memory_mb: Some(instance.launch.memory_effective_mb),
}
}
}
/// 写盘失败 → 回执:三档都走 i18n key。
///
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
use pages::settings::java::store::WriteError;
match error {
WriteError::Refused(refusal) => Notice::Refused(refusal),
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
WriteError::Schema(detail) => {
eprintln!("[java] 设置写盘失败:{detail}");
Notice::WriteFailed
} }
} }
} }
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless(); let mut app = headless();
app.view_dirty = false; app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings( let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping), pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
"4096".to_string(),
)),
))); )));
assert!( assert!(
@@ -265,3 +269,128 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了" "java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
); );
} }
// --------------------------------------------------------------- M174:助手配置的活路径
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-ai-app-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let app = App::with_presenter(
Options::default(),
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, path)
}
fn ai(message: pages::settings::ai_about::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(message),
))
}
/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。
///
/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。
/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」
/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。
#[test]
fn saving_the_ai_form_writes_the_real_settings_file() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("save");
let _ = app.update(ai(Ai::PickProvider(
pages::settings::ai::ProviderId::OpenAiCompatible,
)));
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
"sk-live-app-path",
))));
let _ = app.update(ai(Ai::Save));
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
assert!(
text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""),
"{text}"
);
assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}");
assert!(
text.contains("\"provider\": \"openai_compatible\""),
"{text}"
);
assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}");
// 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。
let state = &app.settings.ai_about;
assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved));
assert!(!state.dirty);
assert!(state.key.is_empty());
assert_eq!(state.key_chars, "sk-live-app-path".chars().count());
let printed = format!("{state:?}");
assert!(
!printed.contains("sk-live-app-path"),
"UI 状态泄漏密钥:{printed}"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。
#[test]
fn refusing_to_save_leaves_the_file_untouched() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("refuse");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen".to_string())));
let _ = app.update(ai(Ai::Save));
let saved = std::fs::read_to_string(&path).expect("第一次应当写成");
let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string())));
let _ = app.update(ai(Ai::Save));
assert_eq!(
app.settings.ai_about.notice,
Some(pages::settings::ai::SaveNotice::Refused(
pages::settings::ai::state::Refusal::BaseUrl
)),
"没有协议头的端点必须被拦下,且要说清原因"
);
let after = std::fs::read_to_string(&path).expect("读文件");
assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。
#[test]
fn testing_without_configuration_does_nothing() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("no-test");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
// 模型名空着 ⇒ `can_test()` 为假
assert!(app.settings.ai_about.missing().is_some());
let _ = app.update(ai(Ai::Test));
assert_eq!(
app.settings.ai_about.probe,
pages::settings::ai::Probe::Idle,
"未配置时点测试不该有任何探测态(更不该发请求)"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
#[path = "tests/java.rs"]
mod java;
@@ -0,0 +1,163 @@
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
//!
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
use super::bridged_app;
use crate::app::Message;
use crate::pages;
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
/// 一条 Java 分组消息 → 页面消息。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
///
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
/// 一个字节都没落盘、也没有任何提示。
///
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
/// JVM 回到空、`dirty` 变假)。
#[test]
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
let (mut app, path) = bridged_app("java-select");
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
"C:/jdk-21/bin/java.exe".to_string(),
)));
assert_eq!(
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
"设为默认必须真的写进去"
);
assert_eq!(
app.settings.java.memory, "6144",
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert_eq!(
app.settings.java.jvm, "-XX:+UseG1GC",
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert!(
app.settings.java.dirty,
"内存 / JVM 参数还没保存,不许标成已保存"
);
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 一个会一直卡住的扫描作业(直到被取消)。
fn held_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<pages::settings::java::RuntimeRow> {
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Vec::new()
}
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
///
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
///
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
#[test]
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel");
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。
let slot = app.settings.java.scan.clone();
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
slot.cancel();
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!slot.report().running, "取消之后线程必须退出");
assert!(slot.report().cancelled);
assert!(!slot.report().finished);
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
app.hydrate_java_scan();
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none());
let started = app.auto_scan_java_if_due(false);
assert!(
!started,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
);
assert!(
!slot.report().running,
"不许起第二个扫描线程:{:?}",
slot.report()
);
app.hydrate_java_scan();
assert!(
app.settings.java.scanning.is_none(),
"状态行不许回到「正在扫描」"
);
assert!(
app.settings.java.cancelled,
"取消标记要保持到下一次手动扫描为止"
);
HOLD.store(true, Ordering::SeqCst);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
///
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。
///
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
let (mut app, path) = bridged_app("java-schema");
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::WriteFailed),
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
+2
View File
@@ -10,6 +10,7 @@
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法 //! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
//! divider.rs ← COMPBASE(15 §22) //! divider.rs ← COMPBASE(15 §22)
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
//! inputs/ ← 同上(§9/§20/§11/§8) //! inputs/ ← 同上(§9/§20/§11/§8)
//! feedback/ ← 同上(§12/§14/§7/§15) //! feedback/ ← 同上(§12/§14/§7/§15)
@@ -52,6 +53,7 @@ pub mod inputs;
pub mod interaction; pub mod interaction;
pub mod layout; pub mod layout;
pub mod navigation; pub mod navigation;
pub mod scroll;
pub mod selection; pub mod selection;
#[cfg(test)] #[cfg(test)]
+224
View File
@@ -0,0 +1,224 @@
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
//!
//! ## 根因(读 iced 0.14 的源码,不是猜测)
//!
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
//!
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
//! 默认 `None`,右内边距**恒 0**;
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
//! width.max(scroller_width) + 2 * margin`。
//!
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
//! 「滚动条骑在卡片右边缘」。
//!
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
//!
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
//!
//! ## 本模块的做法:恒定槽位
//!
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
//!
//! ```text
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
//! │ 内容(可滚) │ 轨道 │
//! ```
//!
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
//!
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
//!
//! ## 谁该用
//!
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
use iced::widget::{container, scrollable};
use iced::{Element, Length, Padding, Theme};
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
///
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
rail_width + 2.0 * margin
}
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
///
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
#[cfg(test)]
pub fn content_width(viewport: f32, slot: f32) -> f32 {
(viewport - slot).max(0.0)
}
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
///
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
pub fn vertical<'a, Message, Renderer>(
rail_width: f32,
margin: f32,
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::text::Renderer + 'a,
{
scrollable(beside(rail_slot(rail_width, margin), content))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(rail_width)
.scroller_width(rail_width)
.margin(margin),
))
.height(Length::Fill)
.style(style)
.into()
}
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
fn beside<'a, Message, Renderer>(
slot: f32,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
container(content)
.width(Length::Fill)
.padding(Padding {
right: slot,
..Padding::ZERO
})
.into()
}
#[cfg(test)]
mod tests {
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
//!
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
//!
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
//!
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, scrollable, space};
use iced::{Rectangle, Size};
use super::*;
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
const RAIL: f32 = 6.0;
const MARGIN: f32 = 2.0;
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
const VIEWPORT: f32 = 424.0;
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
const LONG: f32 = 4000.0;
const SHORT: f32 = 120.0;
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
///
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let filler: Element<'_, (), Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
let mut element: Element<'_, (), Theme, ()> =
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
#[test]
fn the_content_width_leaves_the_rail_its_own_slot() {
let slot = rail_slot(RAIL, MARGIN);
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(
long.width,
content_width(VIEWPORT, slot),
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
);
assert_eq!(
short.width,
content_width(VIEWPORT, slot),
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
);
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
// 所以「内容右缘 + margin」就是滑块的左缘。
let content_right = long.x + long.width;
let pill_left = content_right + MARGIN;
let pill_right = pill_left + RAIL;
assert_eq!(
pill_right + MARGIN,
VIEWPORT,
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
);
}
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
#[test]
fn the_slot_wrapper_keeps_the_full_viewport_width() {
let (gutter, _) = laid_out(LONG);
assert_eq!(gutter.width, VIEWPORT);
}
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
#[test]
fn long_and_short_pages_share_the_same_left_edge_and_width() {
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(long.x, short.x, "左边界必须一致");
assert_eq!(long.width, short.width, "宽度必须一致");
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
}
#[test]
fn content_width_never_goes_negative() {
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
}
}
@@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> {
Self::new(label, ChipVariant::Input, false, on_press) Self::new(label, ChipVariant::Input, false, on_press)
} }
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
///
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
pub fn badge(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
}
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。 /// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
/// ///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
+56 -15
View File
@@ -18,18 +18,31 @@
//! ## 不做什么 //! ## 不做什么
//! //!
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半, //! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。 //! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
//! //!
//! ## 中文那一半(M92 / D28 后半) //! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
//! //!
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), //! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), //! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、 //! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是 //! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
//! cosmic-text 的全局状态,不是本模块能管的事)。 //! cosmic-text 的全局状态,不是本模块能管的事)。
//!
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
//!
//! ```text
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
//! ```
//!
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
pub mod cdn;
mod cjk; mod cjk;
pub mod install;
use cjk::CjkReport; use cjk::CjkReport;
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch}; use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
@@ -173,16 +186,30 @@ fn register_termux_fonts() {
} }
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
///
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts(); register_termux_fonts();
resolve_with_faces(setting, system_faces().as_deref())
}
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
///
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
///
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
///
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces();
let is_installed = |family: &str| -> bool { let is_installed = |family: &str| -> bool {
faces faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family))
.as_ref()
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
}; };
let families = [ let families = [
@@ -206,11 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
let weights = [Weight::Regular, Weight::Medium, Weight::Bold] let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref())); .map(|weight| weight_report(platform, families[0].family, weight, faces));
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
let cjk_report = cjk::probe_with(setting, faces.as_deref()); let cjk_report = cjk::probe_with(setting, faces);
Resolved { Resolved {
platform, platform,
@@ -220,20 +247,34 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
} }
} }
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值** /// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 /// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
/// ///
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 /// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形, /// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`, /// 那就是白块。
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。 ///
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
/// 会白下一次 17 MiB。
///
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
pub fn cjk_for(setting: Option<&str>) -> CjkReport { pub fn cjk_for(setting: Option<&str>) -> CjkReport {
cjk::probe_with(setting, system_faces().as_deref()) let report = cjk::probe_with(setting, system_faces().as_deref());
install::after_probe(report.strategy);
report
} }
/// 一条系统字面(族名 + 字重)。 /// 一条系统字面(族名 + 字重)。
///
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct Face { pub struct Face {
family: String, family: String,
weight: u16, weight: u16,
} }
+173
View File
@@ -0,0 +1,173 @@
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
//!
//! ## 一句话
//!
//! ```text
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
//! core 的 FontSpec(值)
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
//! ```
//!
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
//! 单测)。
//!
//! ## 「不联网」的三条路
//!
//! | 情形 | 谁拦住 |
//! |---|---|
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
//!
//! ## 设置改写只换 URL,不换哈希
//!
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
use std::cell::Cell;
use std::path::PathBuf;
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
use unlucky_i18n::font::{CjkFont, FetchError, Source};
use crate::platform::data;
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
#[derive(Debug, Clone)]
pub struct CdnFetcher {
cache_root: PathBuf,
spec: FontSpec,
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
///
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
last_from_cache: Cell<Option<bool>>,
}
impl CdnFetcher {
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
///
/// # Errors
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
Ok(Self {
cache_root,
spec: spec_of(font)?,
last_from_cache: Cell::new(None),
})
}
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
///
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
#[must_use]
pub fn has_cached(&self) -> bool {
core_font::cache_path(&self.cache_root, &self.spec).is_file()
}
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
///
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
#[must_use]
pub fn was_cached(&self) -> Option<bool> {
self.last_from_cache.get()
}
/// 缓存文件落点(诊断日志用)。
#[must_use]
pub fn cache_file(&self) -> PathBuf {
core_font::cache_path(&self.cache_root, &self.spec)
}
}
impl unlucky_i18n::font::Fetcher for CdnFetcher {
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
self.last_from_cache
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
outcome.map(|fetch| fetch.bytes)
}
}
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
///
/// # Errors
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
let Source::Pinned {
urls,
sha256,
bytes,
} = font.source;
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
if let Some(override_url) = data::font_cdn_override() {
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
candidates.insert(0, override_url);
}
FontSpec::new(candidates, sha256, bytes)
.map_err(|error| FetchError::Integrity(error.to_string()))
}
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
fn translate(error: FontFetchError) -> FetchError {
match error {
FontFetchError::Network(message) => FetchError::Network(message),
FontFetchError::Integrity(message) => FetchError::Integrity(message),
FontFetchError::Disk(message) => FetchError::Disk(message),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::font::NOTO_SANS_SC;
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
///
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
#[test]
fn the_pin_survives_the_trip_to_core() {
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
let Source::Pinned {
urls,
sha256,
bytes,
} = NOTO_SANS_SC.source;
assert!(
spec.urls.len() >= urls.len(),
"候选不该变少:{:?}",
spec.urls
);
assert_eq!(spec.sha256, sha256);
assert_eq!(spec.bytes, bytes);
assert_eq!(
spec.urls[spec.urls.len() - urls.len()],
urls[0],
"内置首选不许被挤掉"
);
}
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
#[test]
fn an_empty_cache_root_is_not_a_hit() {
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
assert!(!fetcher.has_cached());
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
assert!(fetcher
.cache_file()
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
}
}
+3 -2
View File
@@ -19,8 +19,9 @@
//! //!
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); //! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。 //! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
//! //!
//! ## `ui.language` 的接线(M106) //! ## `ui.language` 的接线(M106)
//! //!
+361
View File
@@ -0,0 +1,361 @@
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
//!
//! ## 一句话
//!
//! ```text
//! i18n::font::decide(判据) → Strategy
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
//! fonts::install::register(注册) → iced 的字体系统里多一个族
//! ```
//!
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
//!
//! | 入口 | 时机 | 谁来 |
//! |---|---|---|
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
//!
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
//!
//! ## 为什么下载在后台线程而不是启动那一拍
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
//!
//! ## 失败**不是**致命错误
//!
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
//! (「下载失败不崩」)。
//!
//! ## 状态是一份进程级快照
//!
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
//!
//! ## 截图通道
//!
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
use std::sync::{Mutex, OnceLock};
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchClass {
/// 网络层。
Network,
/// 校验层(哈希 / 魔数 / 大小上限)。
Integrity,
/// 磁盘层。
Disk,
}
impl FetchClass {
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Integrity => "integrity",
Self::Disk => "disk",
}
}
}
impl From<&FetchError> for FetchClass {
fn from(error: &FetchError) -> Self {
match error {
FetchError::Network(_) => Self::Network,
FetchError::Integrity(_) => Self::Integrity,
FetchError::Disk(_) => Self::Disk,
}
}
}
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CjkInstall {
/// 还没判定(启动前 / 判据没跑过)。
Unknown,
/// 英文环境:不需要中文字体(D28 的第一条)。
NotNeeded,
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
System {
/// 命中的族名。
family: String,
},
/// 探测不出(拿不到系统字体库)——**不下载**。
Unprobeable,
/// 正在下载。
Downloading {
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
bytes: u64,
},
/// 已就绪(下载完成并注册进字体系统)。
Ready {
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
family: String,
/// 是否零网络命中缓存。
from_cache: bool,
},
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
Failed {
/// 类别。
class: FetchClass,
/// 人话原因(日志/诊断用;UI 只按类别选词)。
message: String,
},
}
impl CjkInstall {
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
#[must_use]
pub const fn is_downloading(&self) -> bool {
matches!(self, Self::Downloading { .. })
}
/// 一行日志片段(`cjk-install=…`)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Unknown => "cjk-install=unknown".to_owned(),
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
Self::Ready { family, from_cache } => {
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
}
Self::Failed { class, message } => {
format!("cjk-install=failed({},{message})", class.as_str())
}
}
}
}
/// 进程级状态(一份)。
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
}
/// 当前状态(设置页那一行、心跳、日志都读它)。
#[must_use]
pub fn status() -> CjkInstall {
if let Some(forced) = capture_override() {
return forced;
}
slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
fn capture_override() -> Option<CjkInstall> {
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
FORCED
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
.clone()
}
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
#[must_use]
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
let value = raw.trim();
Some(match value {
"not-needed" => CjkInstall::NotNeeded,
"unprobeable" => CjkInstall::Unprobeable,
"system" => CjkInstall::System {
family: "Noto Sans SC".to_owned(),
},
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
"ready" => CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
"failed" | "failed:network" => CjkInstall::Failed {
class: FetchClass::Network,
message: "capture fixture".to_owned(),
},
"failed:integrity" => CjkInstall::Failed {
class: FetchClass::Integrity,
message: "capture fixture".to_owned(),
},
"failed:disk" => CjkInstall::Failed {
class: FetchClass::Disk,
message: "capture fixture".to_owned(),
},
_ => return None,
})
}
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
///
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
pub fn ensure_for(
strategy: Strategy,
fetcher: &dyn Fetcher,
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
) -> CjkInstall {
match font::ensure(strategy, fetcher) {
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
family: family.to_owned(),
},
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
Ok(family) => CjkInstall::Ready {
family,
from_cache: false,
},
Err(message) => CjkInstall::Failed {
class: FetchClass::Disk,
message,
},
},
Err(error) => CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
},
}
}
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
///
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// 什么都不做(已经在下载 / 已就绪)。
Skip,
/// 只把状态改成这个(零网络)。
Apply(CjkInstall),
/// 真的发起一次下载。
Start(&'static font::CjkFont),
}
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
///
/// | 当前状态 | 策略 | 这一步 |
/// |---|---|---|
/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) |
/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) |
/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) |
/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) |
///
/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②)
///
/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
/// 常见情形。
///
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
///
/// | 档 | panic 语义 | 线程 panic 之后 |
/// |---|---|---|
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
///
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
///
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
/// 产生网络流量并可能在出图过程中注册新字体。
#[must_use]
pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
return Step::Skip;
}
match strategy {
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
Strategy::System { family } => Step::Apply(CjkInstall::System {
family: family.to_owned(),
}),
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
// 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话,
// 写「正在下载」也是谎话(并没有在下载)。
Strategy::NeedsDownload(_) if offline => Step::Skip,
Strategy::NeedsDownload(font) => Step::Start(font),
}
}
/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。
#[must_use]
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
plan_with_offline(strategy, current, offline())
}
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
///
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
/// * 出图通道([`set_offline`])→ 永不发起。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
match plan(strategy, &status()) {
Step::Skip => status(),
Step::Apply(state) => {
update(state.clone());
state
}
Step::Start(font) => {
worker::start(font);
status()
}
}
}
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
///
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
pub fn set_offline(offline: bool) {
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
}
/// 当前是否处于 [`set_offline`] 的状态。
#[must_use]
pub fn offline() -> bool {
OFFLINE.load(std::sync::atomic::Ordering::Relaxed)
}
/// 进程级联网开关(默认允许)。
static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
pub(super) fn update(state: CjkInstall) {
let previous = {
let mut guard = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *guard, state.clone())
};
if previous != state {
eprintln!("[font] {}", state.describe());
}
}
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
mod worker;
#[cfg(test)]
#[path = "install/tests.rs"]
mod tests;
+369
View File
@@ -0,0 +1,369 @@
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
//!
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
use std::cell::RefCell;
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
use super::*;
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
struct FakeFetcher {
calls: RefCell<usize>,
seen: RefCell<Option<&'static str>>,
result: Result<Vec<u8>, FetchError>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
Self {
calls: RefCell::new(0),
seen: RefCell::new(None),
result,
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
*self.calls.borrow_mut() += 1;
*self.seen.borrow_mut() = Some(font.family);
self.result.clone()
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
recorder.borrow_mut().push(bytes.len());
Ok("Noto Sans SC".to_owned())
};
(seen, register)
}
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
///
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
/// `CdnFetcher` 的缓存判定承担)。
#[test]
fn the_three_idle_strategies_never_touch_the_fetcher() {
let (registered, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(
ensure_for(Strategy::NotNeeded, &idle, &register),
CjkInstall::NotNeeded
);
assert_eq!(
ensure_for(Strategy::System { family: "SimSun" }, &idle, &register),
CjkInstall::System {
family: "SimSun".to_owned()
}
);
assert_eq!(
ensure_for(Strategy::Unprobeable, &idle, &register),
CjkInstall::Unprobeable
);
assert_eq!(
idle.calls(),
0,
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
);
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
let working = FakeFetcher::new(Ok(vec![9; 4096]));
assert_eq!(
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, &register),
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false
}
);
assert_eq!(working.calls(), 1);
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
assert_eq!(*registered.borrow(), vec![4096]);
}
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
///
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
#[test]
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
let (registered, register) = recorder();
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, &register);
assert_eq!(
state,
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned()
}
);
assert!(registered.borrow().is_empty(), "校验失败不许注册");
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
}
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
#[test]
fn every_failure_class_survives_the_trip() {
let (_, register) = recorder();
for (error, class) in [
(FetchError::Network("超时".to_owned()), FetchClass::Network),
(
FetchError::Integrity("哈希不符".to_owned()),
FetchClass::Integrity,
),
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
] {
let fetcher = FakeFetcher::new(Err(error));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, &register);
match state {
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
other => panic!("应当是 Failed:{other:?}"),
}
}
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
let state = ensure_for(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&fetcher,
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
);
assert!(matches!(
state,
CjkInstall::Failed {
class: FetchClass::Disk,
..
}
));
}
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
///
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
///
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
#[test]
fn a_started_or_finished_install_is_not_restarted() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
let ready = CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
};
for current in [downloading, ready] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan_with_offline(
Strategy::System { family: "SimSun" },
&CjkInstall::Unknown,
false
),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
// 不存在停在 Downloading 的状态。
assert_eq!(
plan_with_offline(
Strategy::Unprobeable,
&CjkInstall::Downloading { bytes: 1 },
false
),
Step::Skip,
"在飞的下载不许被别的策略改写状态"
);
}
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
///
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
#[test]
fn every_strategy_skips_while_a_download_is_in_flight() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
for strategy in [
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &downloading, false),
Step::Skip,
"{strategy:?}:下载中不许改状态、不许起第二条线程"
);
}
}
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
///
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
#[test]
fn the_screenshot_channel_never_starts_a_download() {
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
true
),
Step::Skip,
"出图通道不许联网"
);
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
false
),
Step::Start(&NOTO_SANS_SC),
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
);
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
for strategy in [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &CjkInstall::Unknown, false),
plan_with_offline(strategy, &CjkInstall::Unknown, true),
"{strategy:?}:不联网的策略与通道无关"
);
}
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
///
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
#[test]
fn the_capture_switch_parses_every_state() {
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
assert!(matches!(
parse_capture("system"),
Some(CjkInstall::System { .. })
));
assert!(matches!(
parse_capture("downloading"),
Some(CjkInstall::Downloading { .. })
));
assert!(matches!(
parse_capture("ready"),
Some(CjkInstall::Ready {
from_cache: true,
..
})
));
for (raw, class) in [
("failed", FetchClass::Network),
("failed:network", FetchClass::Network),
("failed:integrity", FetchClass::Integrity),
("failed:disk", FetchClass::Disk),
] {
match parse_capture(raw) {
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
other => panic!("{raw} 应解析成 Failed:{other:?}"),
}
}
assert_eq!(parse_capture("bogus"), None);
assert_eq!(parse_capture(""), None);
}
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
#[test]
fn every_state_describes_itself_on_one_line() {
let states = [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::System {
family: "SimSun".to_owned(),
},
CjkInstall::Unprobeable,
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false,
},
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned(),
},
];
for state in states {
let line = state.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk-install="), "{line}");
}
}
+164
View File
@@ -0,0 +1,164 @@
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
//!
//! ## 为什么拆出来
//!
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
//!
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
//!
//! ## 为什么下载在后台线程
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
//!
//! ## 线程里做了什么(顺序即判据)
//!
//! ```text
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
//! ```
//!
//! ## panic 这件事分两档说(复审 r3 P2-1)
//!
//! | 档 | panic 语义 | 线程 panic 之后 |
//! |---|---|---|
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
//!
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
//! 那属于发布 profile 的决定,不在本 mission)。
//!
//! ## 诚实边界
//!
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
//! (那要真跑一个 `--release` 的 panic 用例)。
use unlucky_i18n::font::{self, Strategy};
use super::{ensure_for, update, CjkInstall, FetchClass};
use crate::fonts::cdn::CdnFetcher;
use crate::platform::data;
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
///
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
pub(super) fn start(font: &'static font::CjkFont) {
let font::Source::Pinned { bytes, .. } = font.source;
let Some(cache_root) = data::cache_root() else {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: "数据根解析不出来,字体无处可落".to_owned(),
});
return;
};
let fetcher = match CdnFetcher::new(font, cache_root) {
Ok(fetcher) => fetcher,
Err(error) => {
update(CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
});
return;
}
};
let cache_file = fetcher.cache_file();
update(CjkInstall::Downloading { bytes });
eprintln!(
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
font.family,
cache_file.display(),
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
fetcher.has_cached()
);
let spawned = std::thread::Builder::new()
.name("ull-cjk-font".to_owned())
.spawn(move || run(font, fetcher));
if let Err(error) = spawned {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: format!("起不了字体下载线程({error})"),
});
}
}
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
)
})) {
Ok(state) => state,
Err(_) => CjkInstall::Failed {
class: FetchClass::Disk,
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
},
};
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
(中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
}
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
///
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
///
/// # Errors
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock
.write()
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
font_system.load_font(std::borrow::Cow::Owned(bytes));
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
// 那个借用活过守卫(E0597)。
let families: Vec<String> = font_system
.raw()
.db()
.faces()
.flat_map(|face| face.families.iter())
.map(|(family, _)| family.clone())
.collect();
families
.iter()
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
.cloned()
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
}
+39
View File
@@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_text() {
); );
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text); assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
} }
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
///
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
///
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
#[test]
fn resolving_fonts_never_starts_a_download() {
use unlucky_i18n::font::Strategy;
use crate::fonts::install;
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
.iter()
.map(|family| Face {
family: (*family).to_owned(),
weight: 400,
})
.collect();
let before = install::status();
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
let after = install::status();
assert_eq!(
resolved.cjk.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
);
assert!(
!after.is_downloading(),
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
);
}
+3
View File
@@ -38,6 +38,9 @@ pub mod widgets;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
#[path = "account/tests_msa.rs"]
mod tests_msa;
#[cfg(test)]
#[path = "account/tests_text.rs"] #[path = "account/tests_text.rs"]
mod tests_text; mod tests_text;
+88 -55
View File
@@ -2,9 +2,15 @@
//! //!
//! ## 为什么不是「一个转圈」 //! ## 为什么不是「一个转圈」
//! //!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, //! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 //! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 //!
//! ## 文案一律**借用模型里的串**(M178)
//!
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
use iced::widget::{column, row}; use iced::widget::{column, row};
use iced::{Alignment, Element}; use iced::{Alignment, Element};
@@ -13,7 +19,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::LoginFailure; use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
use crate::pages::frame; use crate::pages::frame;
@@ -36,34 +42,40 @@ pub fn panel<'a>(
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
remaining, countdown,
expired, expired,
} => device_code(ui, code, link, *remaining, *expired, catalog), note,
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
DevicePanel::Browser { DevicePanel::Browser {
link, link,
remaining, countdown,
expired, expired,
} => browser(ui, link, *remaining, *expired, catalog), } => browser(ui, link, countdown, *expired, catalog),
DevicePanel::Failed { failure } => failed(ui, *failure, catalog), DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
} }
} }
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。 /// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
/// 没登记时在预检处直接回退设备码)。
///
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![ let body = column![row![
buttons::filled( buttons::filled(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceBrowser), catalog.text(Key::AccountDeviceSignIn),
Account::StartBrowser.into_page(), Account::StartDevice.into_page(),
) )
), ),
buttons::outlined( buttons::outlined(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceUseCode), catalog.text(Key::AccountDeviceBrowser),
Account::StartDevice.into_page(), Account::StartBrowser.into_page(),
) )
), ),
buttons::text( buttons::text(
@@ -76,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.align_y(Alignment::Center),] .align_y(Alignment::Center),]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3))
.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceFallbackHint),
ui.color(Role::OnSurfaceVariant),
));
frame::card(ui, body) frame::card(ui, body)
} }
@@ -105,13 +124,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body) frame::card(ui, body)
} }
/// 设备码等待态:**码 + 链接 + 倒计时**。 /// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
fn device_code<'a>( fn device_code<'a>(
ui: &'a Ui, ui: &'a Ui,
code: &'a str, code: &'a str,
link: &'a str, link: &'a str,
remaining: Option<u64>, countdown: &'a str,
expired: bool, expired: bool,
note: Option<BrowserLoginNote>,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let mut actions = row![ let mut actions = row![
@@ -150,9 +170,7 @@ fn device_code<'a>(
), ),
)); ));
let countdown = countdown_line(ui, remaining, expired, catalog); let mut body = column![
let body = column![
frame::text_role( frame::text_role(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
@@ -174,7 +192,7 @@ fn device_code<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown, countdown_line(ui, countdown, expired),
frame::text_role( frame::text_role(
ui, ui,
TypeRole::BodySmall, TypeRole::BodySmall,
@@ -182,17 +200,21 @@ fn device_code<'a>(
catalog.text(Key::AccountDeviceHint), catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
frame::card(ui, body) // 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
if let Some(text) = note_text(ui, note, catalog) {
body = body.push(text);
}
frame::card(ui, body.push(actions))
} }
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。 /// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>( fn browser<'a>(
ui: &'a Ui, ui: &'a Ui,
link: &'a str, link: &'a str,
remaining: Option<u64>, countdown: &'a str,
expired: bool, expired: bool,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
@@ -237,37 +259,19 @@ fn browser<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown_line(ui, remaining, expired, catalog), countdown_line(ui, countdown, expired),
actions, actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
frame::card(ui, body) frame::card(ui, body)
} }
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 /// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
fn countdown_line<'a>( fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
if expired { if expired {
return frame::text_role( return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
} }
let text = remaining.map_or_else(String::new, |secs| { frame::text_role(
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
false, false,
@@ -276,16 +280,45 @@ fn countdown_line<'a>(
) )
} }
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
catalog.text(super::model::note_key(note))
}
/// 回退说明那一行(`None` = 没有这回事)。
fn note_text<'a>(
ui: &'a Ui,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Option<Element<'a, PageMessage>> {
let note = note?;
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
Some(frame::text_role(
ui,
TypeRole::BodySmall,
false,
browser_note_text(note, catalog),
ui.color(Role::OnSurfaceVariant),
))
}
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
/// ///
/// 三点修订叠在这里: /// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, /// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); /// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 fn failed<'a>(
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 ui: &'a Ui,
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { failure: LoginFailure,
let body = catalog.text(failure_body_key(failure)).to_string(); note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut body = catalog.text(failure_body_key(failure)).to_string();
if let Some(note) = note {
body.push_str("\n\n");
body.push_str(browser_note_text(note, catalog));
}
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new(); let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
if !matches!(failure, LoginFailure::MsaNotConfigured) { if !matches!(failure, LoginFailure::MsaNotConfigured) {
+17 -2
View File
@@ -7,7 +7,9 @@
use iced::widget::image; use iced::widget::image;
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView}; use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
};
use unlucky_presenter::state::AppState; use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache}; use super::avatar::{Avatar, Cache};
@@ -164,6 +166,18 @@ impl Form {
} }
} }
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
///
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
#[must_use]
pub const fn note_key(note: BrowserLoginNote) -> Key {
match note {
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
}
}
/// 列表行 / 当前账户卡片用的数据。 /// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Row { pub struct Row {
@@ -253,7 +267,8 @@ impl Page {
.map(|account| row_of(account, avatars, lang)) .map(|account| row_of(account, avatars, lang))
.collect(); .collect();
let active = rows.iter().position(|row| row.active); let active = rows.iter().position(|row| row.active);
let device = device_panel(&state.device_login, state.device_code_remaining()); let remaining = state.device_code_remaining();
let device = device_panel(&state.device_login, remaining, lang.catalog());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、 // 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。 // 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) { let panel = if matches!(device, DevicePanel::Hidden) {
+40 -9
View File
@@ -10,7 +10,10 @@
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 //! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。 /// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,17 +28,23 @@ pub enum DevicePanel {
code: String, code: String,
/// 验证链接 /// 验证链接
link: String, link: String,
/// 剩余秒数 /// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
remaining: Option<u64>, ///
/// 是否已过期 /// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
countdown: String,
/// 是否已过期(决定颜色与「重试」按钮)
expired: bool, expired: bool,
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
note: Option<BrowserLoginNote>,
}, },
/// 授权码流:等浏览器回调 /// 授权码流:等浏览器回调
Browser { Browser {
/// 授权链接(可复制) /// 授权链接(可复制)
link: String, link: String,
/// 剩余秒数 /// 倒计时那行已渲染好的文案(同上)
remaining: Option<u64>, countdown: String,
/// 是否已过期 /// 是否已过期
expired: bool, expired: bool,
}, },
@@ -43,6 +52,8 @@ pub enum DevicePanel {
Failed { Failed {
/// 原因码(页面据此选 i18n) /// 原因码(页面据此选 i18n)
failure: LoginFailure, failure: LoginFailure,
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
note: Option<BrowserLoginNote>,
}, },
/// 成功 /// 成功
Succeeded { Succeeded {
@@ -76,8 +87,13 @@ pub enum AuthlibPanel {
} }
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel { pub(super) fn device_panel(
view: &DeviceLoginView,
remaining: Option<u64>,
catalog: Catalog,
) -> DevicePanel {
let expired = remaining == Some(0); let expired = remaining == Some(0);
let countdown = countdown_text(remaining, expired, catalog);
match view.phase { match view.phase {
DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Idle => DevicePanel::Hidden,
DeviceLoginPhase::Requesting => DevicePanel::Requesting, DeviceLoginPhase::Requesting => DevicePanel::Requesting,
@@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
if view.user_code.trim().is_empty() { if view.user_code.trim().is_empty() {
DevicePanel::Browser { DevicePanel::Browser {
link: view.verification_uri.clone(), link: view.verification_uri.clone(),
remaining, countdown,
expired, expired,
} }
} else { } else {
DevicePanel::Code { DevicePanel::Code {
code: view.user_code.clone(), code: view.user_code.clone(),
link: view.qr_link.clone(), link: view.qr_link.clone(),
remaining, countdown,
expired, expired,
note: view.browser_note,
} }
} }
} }
DeviceLoginPhase::Failed => DevicePanel::Failed { DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other), failure: view.failure.unwrap_or(LoginFailure::Other),
note: view.browser_note,
}, },
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(), name: view.account_name.clone(),
@@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
} }
} }
/// 倒计时那一行的文案(`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) { pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase { match view.phase {
+8 -5
View File
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
} }
} }
fn build(state: &AppState, form: &Form) -> Page { pub(super) fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0) Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
@@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
code, code,
remaining, countdown,
expired, expired,
.. ..
} => { } => {
assert_eq!(code, "ABCD-EFGH"); assert_eq!(code, "ABCD-EFGH");
assert_eq!(*remaining, Some(900)); assert!(
countdown.contains("15:00"),
"倒计时要画成 mm:ss:{countdown}"
);
assert!(!expired); assert!(!expired);
} }
other => panic!("等待态应该是设备码面板,实际 {other:?}"), other => panic!("等待态应该是设备码面板,实际 {other:?}"),
@@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
let page = build(&state, &Form::new()); let page = build(&state, &Form::new());
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
remaining, expired, .. countdown, expired, ..
} => { } => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
} }
other => panic!("过期态仍是设备码面板,实际 {other:?}"), other => panic!("过期态仍是设备码面板,实际 {other:?}"),
} }
@@ -0,0 +1,126 @@
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
//!
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::login::browser_note_text;
use super::model::{DevicePanel, Form, Page};
use super::tests::build;
use super::tests_text::{has_cjk, on_screen};
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
fn build_zh(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
///
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
#[test]
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
application."
.to_string(),
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
}
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
)));
let page = build(&state, &Form::new());
// 1. 面板态带着说明码(不是只剩一个用户码)
match &page.device {
DevicePanel::Code { note, code, .. } => {
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
assert_eq!(code, "ABCD-EFGH");
}
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
}
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
assert!(
text.contains("http://localhost/auth"),
"提示里要给出可照抄的重定向 URI:{text}"
);
assert!(
text.to_lowercase().contains("redirect uri"),
"提示里要点名「重定向 URI」这一项:{text}"
);
assert!(
!text.contains("invalid_request"),
"原始报文不许上屏:{text}"
);
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
let zh = build_zh(&state, &Form::new());
let zh_text = on_screen(&zh);
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
let note_text = browser_note_text(
BrowserLoginNote::RedirectNotRegistered,
Lang::ZhCn.catalog(),
);
assert!(
note_text.contains("http://localhost/auth"),
"中文文案也要给出可照抄的重定向 URI:{note_text}"
);
assert!(note_text.contains("重定向 URI"), "{note_text}");
assert!(note_text.contains("设备码"), "{note_text}");
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
assert!(
!zh_text.contains(forbidden),
"中文界面混进了原始报文:{zh_text}"
);
assert!(
!note_text.contains(forbidden),
"词条混进了原始报文:{note_text}"
);
}
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
assert_eq!(
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
"Could not confirm that browser sign-in is usable, so it switched to a device code."
);
}
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
#[test]
fn a_failed_device_login_keeps_the_fallback_note() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(
LoginFailure::Unreachable,
"连不上 login.microsoftonline.com",
)
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Failed { failure, note } => {
assert_eq!(*failure, LoginFailure::Unreachable);
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
}
other => panic!("应是失败面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
}
+20 -10
View File
@@ -16,7 +16,7 @@
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode; use unlucky_presenter::dto::AccountNoticeCode;
use super::login::{failure_body_key, failure_title_key}; use super::login::{browser_note_text, failure_body_key, failure_title_key};
use super::model::{AuthlibPanel, DevicePanel, Hint, Page}; use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
@@ -60,23 +60,33 @@ pub(super) fn on_screen(page: &Page) -> String {
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
remaining, countdown,
expired, note,
..
} => { } => {
out.push_str(code); out.push_str(code);
out.push_str(link); out.push_str(link);
if *expired { // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
out.push_str(catalog.text(Key::AccountDeviceExpired)); out.push_str(countdown);
} else if let Some(secs) = remaining { // M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
let clock = format!("{:02}:{:02}", secs / 60, secs % 60); // 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])); if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
} }
} }
DevicePanel::Browser { link, .. } => out.push_str(link), DevicePanel::Browser {
DevicePanel::Failed { failure } => { link, countdown, ..
} => {
out.push_str(link);
out.push_str(countdown);
}
DevicePanel::Failed { failure, note } => {
out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_title_key(*failure)));
out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
}
} }
DevicePanel::Succeeded { name } => { DevicePanel::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
+4 -2
View File
@@ -20,7 +20,7 @@
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件) | 无 | //! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 |
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
//! //!
//! ## 偏离登记(D30 要求逐条写清) //! ## 偏离登记(D30 要求逐条写清)
@@ -65,10 +65,12 @@ pub mod background;
mod palette; mod palette;
mod picture; mod picture;
#[cfg(test)]
pub(crate) mod probe;
mod section; mod section;
mod state; mod state;
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 /// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
/// ///
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 /// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 /// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
@@ -0,0 +1,46 @@
//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。
//!
//! ## 为什么需要这一层
//!
//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的
//! 公开接口:
//!
//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree`
//! (不是子控件),没有 `as_any` 之类的向下转型口;
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
//!
//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文
//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
//!
//! ## 为什么是 thread-local
//!
//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台,
//! 也不需要加锁或串行化。
//!
//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。
use std::cell::RefCell;
use unlucky_i18n::catalog::Key;
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。
thread_local! {
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
// `thread_local!{ static X: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }`
// —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。
#[allow(clippy::missing_const_for_thread_local)]
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
}
/// 记一条(由 [`super::section::t`] 调用;不对外)。
pub(crate) fn record(key: Key, text: &'static str) {
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 取走并清空本线程记录下的取词(测试用)。
pub(crate) fn take() -> Vec<(Key, &'static str)> {
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
@@ -1,395 +0,0 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row, space};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner};
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
use super::background::{self, BG_DIM_MAX};
use super::state::State;
use super::Change;
/// 「外观」分组整块(一张卡)。
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows = rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
let corner = state.window_corner();
rows = rows.push(choice_row(
ui,
t(Key::SettingsAppearanceCorner),
vec![
choice(
ui,
t(Key::SettingsAppearanceCornerSystem),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
),
],
));
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
if corner == WindowCorner::System && state.window_opacity < 1.0 {
rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended)));
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
rows = rows.push(gap);
}
let tone = ui.color(Role::OnSurfaceVariant);
rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone));
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows = rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
// ④ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows = rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows = rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
let resolved = format!("Colours: {}", state.palette_level());
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows = rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
// ⑨ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
card(ui, rows)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission,
/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**,
/// 接线时只换这一行,不用回来重写控件。
fn t(key: Key) -> &'static str {
unlucky_i18n::Lang::En.catalog().text(key)
}
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -0,0 +1,109 @@
//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。
//!
//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3:
//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_presenter::prefs::BgFill;
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::background::{self, BG_DIM_MAX};
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, label, note_line, slider_row};
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
// ④ 背景图开关
rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into(),
);
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
let resolved = format!("Colours: {}", state.palette_level());
rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
}
@@ -0,0 +1,41 @@
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
use iced::Element;
use unlucky_presenter::prefs::PaletteSource;
use crate::pages::Message;
use crate::theme::Ui;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row};
/// 把 ⑨ 配色来源推进行 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
// ⑨ 配色来源
rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
}
@@ -0,0 +1,258 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线)
//!
//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`),
//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它:
//!
//! | 文件 | 是什么 |
//! |---|---|
//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 取词口 [`t`] |
//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) |
//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) |
//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 |
//! | [`colour_source`] | ⑨ 配色来源(三档) |
//!
//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/
//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动):
//!
//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec<Element>`);
//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线);
//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块);
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
//!
//! ## 语言(M179 只接管子,不补文案)
//!
//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective`
//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、
//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)——
//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::state::State;
use super::Change;
mod bg_image;
mod colour_source;
mod panel;
mod window;
/// 「外观」分组整块(一张卡)。
///
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let mut rows: Vec<Element<'a, Message>> = Vec::new();
window::push(ui, state, lang, &mut rows);
panel::push(ui, state, &mut rows);
bg_image::push(ui, state, &mut rows);
colour_source::push(ui, state, &mut rows);
card(
ui,
column(rows)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
//
// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。
pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是
/// 下一条 mission。
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
let text = lang.catalog().text(key);
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
// 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。
#[cfg(test)]
super::probe::record(key, text);
text
}
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
pub(super) fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
pub(super) fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
pub(super) fn choice<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
change: Change,
) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -0,0 +1,45 @@
//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。
use iced::Element;
use crate::app::components::inputs::{slider, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction};
use super::super::state::State;
use super::super::Change;
use super::slider_row;
/// 把 ③ 面板透明度推进 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
}
@@ -0,0 +1,86 @@
//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。
//!
//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效
//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里——
//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。
use iced::widget::space;
use iced::Element;
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::WindowCorner;
use crate::app::components::inputs::{slider, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR};
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, note_line, slider_row, t};
/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
let corner = state.window_corner();
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceCorner, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceCornerSystem, lang),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare, lang),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
),
],
));
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
if corner == WindowCorner::System && state.window_opacity < 1.0 {
rows.push(super::super::notice(
ui,
t(Key::SettingsAppearanceCornerSuspended, lang),
));
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
rows.push(gap);
}
let tone = ui.color(Role::OnSurfaceVariant);
rows.push(note_line(
ui,
t(Key::SettingsAppearanceCornerHint, lang),
tone,
));
}
+1 -1
View File
@@ -268,7 +268,7 @@ pub fn app<'a>(
/// 同 [`app`],但可以带上**外观运行期状态**。 /// 同 [`app`],但可以带上**外观运行期状态**。
/// ///
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`); /// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`);
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支: /// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**—— /// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。 /// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
@@ -0,0 +1,30 @@
//! 设置页「助手(AI)」分组(M174):提供商 / 端点 / 模型 / 密钥 + 「测试连接」。
//!
//! ## 为什么单独一个目录
//!
//! `pages/settings/mod.rs` 里只挂一行 `mod ai;`(与 M173 的版面改动不碰面),本组的状态 / 读写 /
//! 渲染 / 用例都在这四个文件里:[`state`](草稿 + 探测结论 + 校验)、[`store`](真读写
//! `config/launcher.json` 的 `ai.*` 五键)、[`view`](一张卡片)与 `tests`。
//!
//! ## 三条纪律(本组的全部安全面)
//!
//! 1. **密钥不进 DTO、不进日志、不进错误文案**:草稿是 [`state::Secret`](`Debug` 手写成掩码),
//! 落盘走 [`store::save`](在进程内直接过 `core::setting` 的 schema,**不经过**
//! `AppCommand::SetSetting` 那条可序列化的指令通道——那条通道会把明文带进事件流/日志);
//! 界面上默认掩码,只有用户点「显示」才明文。已存密钥的**长度**是唯一允许上屏的数值。
//! 2. **空/非法值不保存**:[`state::State::validate`] 先拦(提供商词表、`http(s)://` 前缀、模型名),
//! schema 再拦(越界/类型),两次都过才写盘。失败时界面上给的是拒绝原因,不是「已保存」。
//! 3. **不配置就不可能发请求**:「测试连接」按钮在 [`state::State::can_test`] 为假时是禁用的,
//! 真发不发由 `unlucky_cli::commands::agent::probe` 里的 `to_config()` 再拦一次
//! (假 provider 构造计数 == 0 的守卫在那里)。
pub mod outcome;
pub mod state;
pub mod store;
pub mod view;
#[cfg(test)]
mod tests;
pub use outcome::ProbeResult;
pub use state::{Probe, ProviderId, SaveNotice, Secret, State};
@@ -0,0 +1,197 @@
//! 「测试连接」的结论(**界面要画的那一份**):缺项 / 分类 / 目标 / 耗时 / 脱敏详情。
//!
//! 为什么从 `state.rs` 拆出来:那个文件被 400 行硬线卡着(`components/gate.rs` 的
//! `every_source_file_stays_under_the_line_limit` 按全仓判红),而这几件是**纯数据 + 翻译**,
//! 与草稿状态机没有耦合。分类判断不在本文件:那是
//! `unlucky_cli::commands::agent::probe` 的事(四类各有真 TCP 假服务器用例),这里只翻译。
use unlucky_i18n::Key;
/// 缺哪一项(「未配置」时界面上要说清差什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingField {
/// 已关闭 / 提供商是 disabled
Disabled,
/// 提供商取值不认识
Provider,
/// 端点为空
BaseUrl,
/// 模型名为空
Model,
/// Anthropic 缺密钥
ApiKey,
}
impl MissingField {
/// 界面文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Disabled => Key::SettingsAiMissingDisabled,
Self::Provider => Key::SettingsAiReasonProvider,
Self::BaseUrl => Key::SettingsAiMissingBaseUrl,
Self::Model => Key::SettingsAiMissingModel,
Self::ApiKey => Key::SettingsAiMissingApiKey,
}
}
}
/// 「测试连接」的结论大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeKind {
/// 通过
Passed,
/// 鉴权失败
Auth,
/// 模型不存在
ModelNotFound,
/// 端点不可达
Unreachable,
/// 超时
Timeout,
/// 其它失败
Failed,
/// 配置不完整,**没有发请求**
NotConfigured,
}
impl ProbeKind {
/// 界面文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Passed => Key::SettingsAiTestPassed,
Self::Auth => Key::SettingsAiTestAuth,
Self::ModelNotFound => Key::SettingsAiTestModel,
Self::Unreachable => Key::SettingsAiTestUnreachable,
Self::Timeout => Key::SettingsAiTestTimeout,
Self::Failed => Key::SettingsAiTestOther,
Self::NotConfigured => Key::SettingsAiTestNotConfigured,
}
}
/// 是不是「不通」那一类(界面用错误色)。
#[must_use]
pub const fn is_failure(self) -> bool {
!matches!(self, Self::Passed)
}
}
/// 一次探测的结论(数值 + 分类 + 脱敏后的详情)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeResult {
/// 大类。
pub kind: ProbeKind,
/// 未配置时缺的那一项。
pub missing: Option<MissingField>,
/// 实际请求的端点(`resolved_base_url`)。
pub base_url: String,
/// 实际请求的模型名。
pub model: String,
/// 耗时(毫秒)。
pub elapsed_ms: u128,
/// 脱敏后的原因(可能为空)。
pub detail: String,
}
impl ProbeResult {
/// 「测试连接」的结论(`unlucky_cli` 的探测器产出)→ 界面要画的那一份。
///
/// 只做**翻译**:分类判断全在 `unlucky_cli::commands::agent::probe`(那边有四条真 TCP 假
/// 服务器用例逐个钉住),这里不重新猜一遍。`elapsed_ms` 由发起处计时(含建连 + 首 token)。
#[must_use]
pub fn from_outcome(
outcome: unlucky_cli::commands::agent::probe::ProbeOutcome,
elapsed_ms: u128,
) -> Self {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
let missing = match &outcome {
ProbeOutcome::NotConfigured { missing } => Some(match missing {
Missing::Disabled => MissingField::Disabled,
Missing::Provider => MissingField::Provider,
Missing::BaseUrl => MissingField::BaseUrl,
Missing::Model => MissingField::Model,
Missing::ApiKey => MissingField::ApiKey,
}),
_ => None,
};
// r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上
// 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。
let (kind, target, detail) = match outcome {
ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()),
ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()),
ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail),
ProbeOutcome::ModelNotFound { target, detail } => {
(ProbeKind::ModelNotFound, Some(target), detail)
}
ProbeOutcome::Unreachable { target, detail } => {
(ProbeKind::Unreachable, Some(target), detail)
}
ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail),
ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail),
};
Self {
kind,
missing,
base_url: target
.as_ref()
.map_or_else(String::new, |t| t.base_url.clone()),
model: target.map_or_else(String::new, |t| t.model.clone()),
elapsed_ms,
detail,
}
}
/// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。
///
/// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态
/// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于
/// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。
#[must_use]
pub fn screenshot_seed(
kind: ProbeKind,
base_url: String,
model: String,
elapsed_ms: u128,
) -> Self {
use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target};
let target = Target { base_url, model };
let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}";
let outcome = match kind {
ProbeKind::Passed => ProbeOutcome::Passed { target },
ProbeKind::Auth => ProbeOutcome::Auth {
target,
detail: detail.to_string(),
},
ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound {
target,
detail: detail.to_string(),
},
ProbeKind::Unreachable => ProbeOutcome::Unreachable {
target,
detail: "Http client error: error sending request".to_string(),
},
ProbeKind::Timeout => ProbeOutcome::Timeout {
target,
detail: "8 s".to_string(),
},
ProbeKind::Failed => ProbeOutcome::Failed {
target,
detail: detail.to_string(),
},
// 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。
ProbeKind::NotConfigured => ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::Model,
},
};
Self::from_outcome(outcome, elapsed_ms)
}
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
#[must_use]
pub fn sent_a_request(&self) -> bool {
self.kind != ProbeKind::NotConfigured
}
}
@@ -0,0 +1,387 @@
//! 「助手」分组的运行期状态:四条草稿 + 一颗探测结论 + 校验。
//!
//! 状态住在 `pages::settings::Groups.ai_about`([`State`]),由 `App` 持有:
//! 用户正在输入的值**不能**放进 `pages::Model`(那个模型每次事件都整棵重建,见
//! `app/state.rs` 里 `download_draft` 的同一条理由)。
use unlucky_i18n::Key;
use super::outcome::{MissingField, ProbeKind, ProbeResult};
/// 四个提供商(值 = `ai.provider` 的词表,真源是 `core::setting::keys::AI_PROVIDERS`,
/// `ai/tests.rs` 里对表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProviderId {
/// OpenAI 兼容(含 llama.cpp / LM Studio / DeepSeek / Qwen)
#[default]
OpenAiCompatible,
/// Anthropic 原生
Anthropic,
/// Ollama 原生
Ollama,
/// 禁用
Disabled,
}
impl ProviderId {
/// 四态(界面上的顺序 = 这张表)。
pub const ALL: [Self; 4] = [
Self::OpenAiCompatible,
Self::Anthropic,
Self::Ollama,
Self::Disabled,
];
/// `ai.provider` 的取值。
#[must_use]
pub const fn setting(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 取值 → 四态(未知/空值回落 OpenAI 兼容,与 schema 的默认值同一档)。
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim() {
"anthropic" => Self::Anthropic,
"ollama" => Self::Ollama,
"disabled" => Self::Disabled,
_ => Self::OpenAiCompatible,
}
}
/// 该形态有没有统一默认端点(没有 ⇒ base_url 必填)。
#[must_use]
pub const fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => "https://api.anthropic.com",
Self::Ollama => "http://127.0.0.1:11434",
Self::Disabled => "",
}
}
/// 界面标签 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::OpenAiCompatible => Key::SettingsAiProviderOpenAi,
Self::Anthropic => Key::SettingsAiProviderAnthropic,
Self::Ollama => Key::SettingsAiProviderOllama,
Self::Disabled => Key::SettingsAiProviderDisabled,
}
}
}
/// 用户刚输入的密钥草稿:`Debug` 掩码,只有 [`Secret::expose`] 能拿到明文。
///
/// 为什么不用裸 `String`:`Groups` / `State` 都 `derive(Debug)`,`App` 里任何一处 `{:?}`(日志、
/// panic 消息、测试断言失败输出)都会把明文打出来。类型自己守住这一条,比每次记得手写便宜。
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
/// 从输入框的内容构造。
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
/// 是否空(空白串也算空)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.trim().is_empty()
}
/// 明文(**只给落盘用**;不要拿它拼任何上屏/日志文本)。
#[must_use]
pub fn expose(&self) -> &str {
self.0.trim()
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
f.write_str("Secret(空)")
} else {
f.write_str("Secret(***)")
}
}
}
/// 设置文件里那一份真值(`ai.*` 五键)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Snapshot {
/// `ai.enabled`
pub enabled: bool,
/// `ai.provider`
pub provider: ProviderId,
/// `ai.base_url`
pub base_url: String,
/// `ai.model`
pub model: String,
/// 已存密钥的字符数(0 = 没存)。**只带长度不带值**:DTO 里不该有密钥。
pub key_chars: usize,
}
/// 探测三态。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Probe {
/// 还没测
#[default]
Idle,
/// 正在测
Running,
/// 测完了
Done(ProbeResult),
}
/// 保存结果(用户点「保存」之后的回执)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SaveNotice {
/// 已落盘
Saved,
/// 被校验拦下(没写盘)
Refused(Refusal),
/// schema/磁盘拒绝(没写盘)
Failed(String),
/// 设置文件不可用(内存态运行)
NoStorage,
}
/// 拒绝保存的原因(本地校验,先于 schema)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// 端点不是 http(s)
BaseUrl,
/// 缺模型名
Model,
}
impl Refusal {
/// 界面文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::BaseUrl => Key::SettingsAiReasonBaseUrl,
Self::Model => Key::SettingsAiReasonModel,
}
}
}
/// 「助手」分组的运行期状态。
#[derive(Debug)]
pub struct State {
/// `ai.enabled`
pub enabled: bool,
/// `ai.provider`
pub provider: ProviderId,
/// `ai.base_url` 草稿
pub base_url: String,
/// `ai.model` 草稿
pub model: String,
/// 新密钥草稿(空 = 不改已存的那把)
pub key: Secret,
/// 已存密钥的字符数(0 = 没存)
pub key_chars: usize,
/// 是否明文显示密钥输入框
pub reveal: bool,
/// 有未保存的改动
pub dirty: bool,
/// 设置文件可用(内存态运行时置假:保存与测试都禁用)
pub storage: bool,
/// 上一次保存的回执
pub notice: Option<SaveNotice>,
/// 探测结论
pub probe: Probe,
}
impl Default for State {
fn default() -> Self {
Self {
enabled: true,
provider: ProviderId::OpenAiCompatible,
base_url: String::new(),
model: String::new(),
key: Secret::default(),
key_chars: 0,
reveal: false,
dirty: false,
storage: false,
notice: None,
probe: Probe::Idle,
}
}
}
impl State {
/// 用设置文件里的真值重置草稿(**只在没脏的时候**:不许冲掉用户正在输入的内容)。
///
/// 返回是否真的改了(调用方据此决定要不要重建视图模型)。
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
self.storage = true;
self.key_chars = snapshot.key_chars;
if self.dirty {
return false;
}
let changed = self.enabled != snapshot.enabled
|| self.provider != snapshot.provider
|| self.base_url != snapshot.base_url
|| self.model != snapshot.model;
self.enabled = snapshot.enabled;
self.provider = snapshot.provider;
self.base_url = snapshot.base_url;
self.model = snapshot.model;
self.keep_probe_if_targeted();
changed
}
/// 端点/模型变了之后,「已通过」的旧结论不再是关于现在这套配置的——清掉,免得说谎。
fn keep_probe_if_targeted(&mut self) {
if let Probe::Done(result) = &self.probe {
if result.base_url != self.resolved_base_url() || result.model != self.model.trim() {
self.probe = Probe::Idle;
}
}
}
/// 实际生效的端点(空 = 用该形态的默认值)。
#[must_use]
pub fn resolved_base_url(&self) -> String {
let draft = self.base_url.trim();
if draft.is_empty() {
self.provider.default_base_url().to_string()
} else {
draft.to_string()
}
}
/// 配置齐了没有(不齐时「测试连接」禁用,界面上说缺哪一项)。
#[must_use]
pub fn missing(&self) -> Option<MissingField> {
if !self.enabled || self.provider == ProviderId::Disabled {
return Some(MissingField::Disabled);
}
if self.resolved_base_url().is_empty() {
return Some(MissingField::BaseUrl);
}
if self.model.trim().is_empty() {
return Some(MissingField::Model);
}
if self.provider == ProviderId::Anthropic && self.key.is_empty() && self.key_chars == 0 {
return Some(MissingField::ApiKey);
}
None
}
/// 现在能不能点「测试连接」。
#[must_use]
pub fn can_test(&self) -> bool {
self.storage && self.missing().is_none() && !matches!(self.probe, Probe::Running)
}
/// 状态矩阵的三档(未配置 / 已配置 / 已通过 / 测试失败)。
#[must_use]
pub fn status(&self) -> Status {
match &self.probe {
Probe::Done(result) if result.kind == ProbeKind::Passed => Status::Verified,
Probe::Done(result) if result.kind.is_failure() => Status::Failed,
_ if self.missing().is_some() => Status::Unconfigured,
_ => Status::Configured,
}
}
/// 保存前校验(用户输入侧);过不了就不写盘。
///
/// # Errors
/// 端点不是 `http(s)://` 或该形态缺模型名时返回原因。
pub fn validate(&self) -> Result<(), Refusal> {
if self.provider == ProviderId::Disabled || !self.enabled {
return Ok(());
}
let base_url = self.resolved_base_url();
if base_url.is_empty()
|| !(base_url.starts_with("http://") || base_url.starts_with("https://"))
{
return Err(Refusal::BaseUrl);
}
if self.model.trim().is_empty() {
return Err(Refusal::Model);
}
Ok(())
}
/// 取证开关 `ULL_AI_STATE`(只给 `--screenshot` 用,见 `app/state/construct.rs`)。
///
/// 为什么要它:D30 的状态矩阵要出「未配置 / 已配置并测过 / 测试失败」三张图,而真实路径要
/// 点按钮、要联网。语义与 `ULL_UPDATE_STATE` / `ULL_ACCOUNT_STATE` 一致:只影响这一次进程。
#[must_use]
pub fn screenshot_seed(raw: &str) -> Option<Self> {
let mut state = Self {
storage: true,
..Self::default()
};
match raw.trim() {
"unconfigured" => {}
"configured" => {
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = "deepseek-chat".to_string();
state.key_chars = 32;
}
other => {
let kind = match other {
"verified" => ProbeKind::Passed,
"failed-auth" => ProbeKind::Auth,
"failed-model" => ProbeKind::ModelNotFound,
"failed-unreachable" => ProbeKind::Unreachable,
"failed-timeout" => ProbeKind::Timeout,
_ => return None,
};
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = "deepseek-chat".to_string();
state.key_chars = 32;
// r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定,
// 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」
// 这类缺陷只在产品上出现、截图永远看不见。
state.probe = Probe::Done(ProbeResult::screenshot_seed(
kind,
state.resolved_base_url(),
state.model.clone(),
412,
));
}
}
Some(state)
}
}
/// 页头的状态档(D30 的状态矩阵:四档各自一句文案 + 一种颜色)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// 未配置(缺项,按钮禁用)
Unconfigured,
/// 已配置(还没测或测过但目标改了)
Configured,
/// 连接已通过
Verified,
/// 测试失败
Failed,
}
impl Status {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Unconfigured => Key::SettingsAiStatusUnconfigured,
Self::Configured => Key::SettingsAiStatusConfigured,
Self::Verified => Key::SettingsAiStatusVerified,
Self::Failed => Key::SettingsAiStatusTestFailed,
}
}
}
@@ -0,0 +1,100 @@
//! 「助手」分组的**真读写**:`config/launcher.json` 的 `ai.enabled` / `ai.provider` /
//! `ai.base_url` / `ai.model` / `ai.api_key` 五键。
//!
//! ## 为什么在进程内直接过 `SettingStore`,不走 `AppCommand::SetSetting`
//!
//! `AppCommand` 是可序列化的 DTO(`dto/events.rs` 的 `derive(Serialize, Deserialize)`),
//! 把 `ai.api_key` 塞进去就等于让明文进了「命令通道」——事件日志、调试转储、mock 后端的事件
//! 回放都会看到它。M174 的守卫①要求密钥**不出进程内**,所以本组写盘走
//! `core::setting::store::SettingStore`:schema 校验 + 原子写照旧(不自己拼 JSON、不绕 schema),
//! 只是不经过那条可序列化的通道。这与 `SettingsBridge::persist` 写外观是同一条做法。
//!
//! ## 为什么每次现读现写
//!
//! `SettingStore::load` 每次从磁盘读一份(`HostEnv::store()` 也是这个口径),所以「读 → 改 → 存」
//! 不会用启动时的旧快照盖掉别人刚写的键(AI / `ull config set` 是同一个文件的外部写者)。
use std::path::Path;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{ProviderId, Snapshot, State};
/// 读一份真值。文件缺失/坏掉时 `SettingStore::load` 自己降级,这里只会读到 schema 默认值。
#[must_use]
pub fn load(path: &Path) -> Snapshot {
let store = SettingStore::load(path, launcher_schema());
snapshot(store.doc())
}
/// 把草稿落盘,返回**落盘后**再读回来的真值(界面据此刷新,而不是相信自己的草稿)。
///
/// # Errors
/// schema 拒绝(越界/类型/只读文件)或写盘失败时返回人话(已过 [`super::state::State::validate`],
/// 所以这里只剩「schema/磁盘」这一类)。
pub fn save(path: &Path, state: &State) -> Result<Snapshot, String> {
let mut store = SettingStore::load(path, launcher_schema());
// `Value` 是 `set` 的形参类型:用 `.into()` 让目标类型从签名推出,本 crate 就不必直接依赖
// `serde_json`(`Cargo.toml` 不在本 mission 的 scope,加依赖要单独走)。
store
.set("ai.enabled", state.enabled.into())
.and_then(|()| store.set("ai.provider", state.provider.setting().to_string().into()))
.and_then(|()| store.set("ai.base_url", state.base_url.trim().to_string().into()))
.and_then(|()| store.set("ai.model", state.model.trim().to_string().into()))
.map_err(|error| format!("{error}"))?;
// 密钥:草稿非空才写(空 = 保留已存的那一把,界面上写着这条规则)。
if !state.key.is_empty() {
store
.set("ai.api_key", state.key.expose().to_string().into())
.map_err(|error| format!("{error}"))?;
}
// **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)`
// ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码
// 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
match store.save() {
Ok(SaveOutcome::Saved(_)) => {}
Ok(SaveOutcome::Skipped(reason)) => {
return Err(format!("未写入磁盘({})", skip_reason(reason)));
}
Err(error) => return Err(format!("{error}")),
}
// 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里
// 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。
let reloaded = SettingStore::load(path, launcher_schema());
Ok(snapshot(reloaded.doc()))
}
/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。
fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str {
use unlucky_core::setting::store::SkipReason;
match reason {
SkipReason::NotDirty => "没有改动",
SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信",
SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)",
}
}
/// 文档 → 快照(密钥只取长度,值不出本函数)。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
let key_chars = doc
.effective_str("ai.api_key")
.map(str::trim)
.filter(|value| !value.is_empty())
.map_or(0, |value| value.chars().count());
Snapshot {
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
provider: ProviderId::from_setting(&text("ai.provider")),
base_url: text("ai.base_url"),
model: text("ai.model"),
key_chars,
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
@@ -0,0 +1,211 @@
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。
//!
//! | 用例 | 守什么 | 变异 |
//! |---|---|---|
//! | `five_keys_round_trip` | 四项 + 密钥都真的落进 `launcher.json` | 少写一个键 / 写错键名 |
//! | `snapshot_only_reports_key_length` | 快照里没有密钥值,只有长度 | 把 `api_key` 值放进 `Snapshot` |
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok |
use std::path::PathBuf;
use super::{load, save};
use crate::pages::settings::ai::state::{ProviderId, Secret, State};
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-ai-store-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
path
}
fn draft() -> State {
State {
enabled: true,
provider: ProviderId::Anthropic,
base_url: "https://api.anthropic.com".to_string(),
model: "claude-sonnet-4".to_string(),
key: Secret::new("sk-ant-secret"),
dirty: true,
storage: true,
..State::default()
}
}
#[test]
fn five_keys_round_trip() {
let path = temp_config("roundtrip");
let saved = save(&path, &draft()).expect("应当能保存");
assert_eq!(saved.provider, ProviderId::Anthropic);
assert_eq!(saved.base_url, "https://api.anthropic.com");
assert_eq!(saved.model, "claude-sonnet-4");
assert_eq!(saved.key_chars, "sk-ant-secret".chars().count());
// 文件里真的是这五个键(不是我们内存里的对象)。
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("\"base_url\": \"https://api.anthropic.com\""),
"{text}"
);
assert!(text.contains("\"model\": \"claude-sonnet-4\""), "{text}");
assert!(text.contains("\"provider\": \"anthropic\""), "{text}");
assert!(text.contains("\"api_key\": \"sk-ant-secret\""), "{text}");
// 新开一份读回来(= 重启语义)。
let reloaded = load(&path);
assert_eq!(reloaded, saved);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn snapshot_only_reports_key_length() {
let path = temp_config("length");
let saved = save(&path, &draft()).expect("保存");
// `Snapshot` 里唯一与密钥有关的字段是长度:类型上就没有 `String` 可以装明文。
let printed = format!("{saved:?}");
assert!(
!printed.contains("sk-ant-secret"),
"快照泄漏密钥:{printed}"
);
assert_eq!(saved.key_chars, "sk-ant-secret".len());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn empty_draft_keeps_the_stored_key() {
let path = temp_config("keep-key");
save(&path, &draft()).expect("先存一把");
let mut next = draft();
next.key = Secret::default();
next.model = "claude-opus-4".to_string();
let saved = save(&path, &next).expect("再存一次(密钥留空)");
assert_eq!(saved.model, "claude-opus-4");
assert_eq!(
saved.key_chars,
"sk-ant-secret".chars().count(),
"密钥输入框留空时必须保留旧密钥"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn schema_rejects_and_nothing_is_written() {
let path = temp_config("reject");
save(&path, &draft()).expect("先存一份合法的");
let before = std::fs::read_to_string(&path).expect("读文件");
// `ai.model` 的上限是 128 字符:越界的值必须被 schema 拒绝,**且文件不动**。
let mut bad = draft();
bad.model = "m".repeat(400);
bad.base_url = "https://example.invalid/v1".to_string();
let error = save(&path, &bad).expect_err("越界模型名必须被拒");
assert!(!error.is_empty());
let after = std::fs::read_to_string(&path).expect("读文件");
assert_eq!(before, after, "被拒绝的保存不该改动文件:{after}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_read_back() {
let path = temp_config("external");
save(&path, &draft()).expect("先存一份");
// 外部写者(`ull config set ai.model …`)改一个键:我们不缓存,下一次读就是新值。
std::fs::write(
&path,
r#"{"ai":{"enabled":true,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3.1"}}"#,
)
.expect("写文件");
let reloaded = load(&path);
assert_eq!(reloaded.provider, ProviderId::Ollama);
assert_eq!(reloaded.model, "llama3.1");
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。
///
/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved`
/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与
/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。
#[test]
fn appearance_write_back_keeps_the_ai_keys() {
use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode};
use unlucky_presenter::settings_bridge::SettingsBridge;
let path = temp_config("appearance-keeps-ai");
// 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。
let mut bridge = SettingsBridge::with_path(&path);
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
// 用户在「助手」页保存配置(真落盘、含密钥)。
let mut draft = draft();
draft.key = Secret::new("sk-must-survive");
save(&path, &draft).expect("保存 AI 配置");
// 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"密钥被外观写回盖掉了:{text}"
);
assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}");
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。
///
/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。
/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()`
/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
///
/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。
/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。
#[test]
fn read_only_config_is_reported_as_a_failure() {
let path = temp_config("read-only");
// `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。
std::fs::write(
&path,
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#,
)
.expect("写配置");
let before = std::fs::read_to_string(&path).expect("读配置");
let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败");
assert!(
error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"),
"错误文案要说得清为什么没写:{error}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("读配置"),
before,
"被只读闸门拦下的保存不许改动文件"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。
#[test]
fn skip_reasons_are_named() {
use super::skip_reason;
use unlucky_core::setting::store::SkipReason;
for reason in [
SkipReason::NotDirty,
SkipReason::ReadOnly,
SkipReason::Unreadable,
] {
assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案");
}
}
@@ -0,0 +1,390 @@
//! 「助手」分组状态机的用例(M174)。
//!
//! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---|
//! | `provider_vocabulary_is_the_schema_vocabulary` | 四个取值 == `core::setting::keys::AI_PROVIDERS` | 词表里写 `open_ai_compatible` |
//! | `secret_debug_is_masked` | 密钥不进 `{:?}` | 把 `Secret` 改成 `String` |
//! | `validate_refuses_bad_endpoint_and_empty_model` | 空/非法值不保存 | 删掉 `starts_with("http")` 检查 |
//! | `missing_names_exactly_one_field` | 「未配置」说清缺哪一项 | 所有缺项都返回 `BaseUrl` |
//! | `can_test_is_false_until_configured` | 未配置时按钮禁用 | `can_test` 恒 `true` |
//! | `dirty_draft_is_not_overwritten` | 外部改动不冲掉用户输入 | `hydrate` 先无条件赋值 |
//! | `saving_a_new_target_drops_the_old_pass` | 端点/模型变了,旧「已通过」作废 | `keep_probe_if_targeted` 删掉 |
//! | `not_configured_outcome_carries_no_target` | 「没发请求」的结论里没有目标/详情 | 把 `ProbeOutcome::NotConfigured` 当失败类 |
use unlucky_core::setting::keys::AI_PROVIDERS;
use unlucky_i18n::{Catalog, Lang};
use super::outcome::{MissingField, ProbeKind, ProbeResult};
use super::state::{Probe, ProviderId, Refusal, Secret, Snapshot, State, Status};
fn configured() -> State {
State {
enabled: true,
provider: ProviderId::OpenAiCompatible,
base_url: "https://api.deepseek.com/v1".to_string(),
model: "deepseek-chat".to_string(),
key: Secret::new("sk-test"),
key_chars: 0,
dirty: false,
storage: true,
..State::default()
}
}
#[test]
fn provider_vocabulary_is_the_schema_vocabulary() {
let ours: Vec<&str> = ProviderId::ALL.iter().map(|id| id.setting()).collect();
assert_eq!(
ours, AI_PROVIDERS,
"四个取值必须与 schema 的允许值表逐条相等"
);
for id in ProviderId::ALL {
assert_eq!(ProviderId::from_setting(id.setting()), id);
}
// 未知值回落 OpenAI 兼容(与 schema 默认值同一档),不是 panic 也不是禁用。
assert_eq!(
ProviderId::from_setting("open_ai_compatible"),
ProviderId::OpenAiCompatible
);
}
#[test]
fn secret_debug_is_masked() {
let secret = Secret::new("sk-live-LEAK-MARKER");
let printed = format!("{secret:?}");
assert!(!printed.contains("sk-live"), "Debug 漏了密钥:{printed}");
assert!(printed.contains("***"), "应当显示掩码:{printed}");
// 空草稿的 Debug 也要能分辨(否则「留空 = 保留旧密钥」这条规则在日志里看不出)。
assert!(format!("{:?}", Secret::default()).contains("空"));
assert!(Secret::new(" ").is_empty());
}
/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。
///
/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒
/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。
///
/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。
#[test]
fn the_key_message_never_prints_the_key() {
use crate::pages::settings::ai_about::Message;
const SECRET: &str = "sk-live-MSG-LEAK-7c2f";
let message = Message::EditKey(Secret::new(SECRET));
let printed = format!("{message:?}");
assert!(
!printed.contains(SECRET),
"页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}"
);
assert!(printed.contains("***"), "应当看得见掩码标记:{printed}");
// 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。
let page =
crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message));
let printed = format!("{page:?}");
assert!(
!printed.contains(SECRET),
"页面消息漏斗泄漏了密钥:{printed}"
);
}
#[test]
fn validate_refuses_bad_endpoint_and_empty_model() {
let mut state = configured();
assert_eq!(state.validate(), Ok(()));
state.base_url = "api.deepseek.com/v1".to_string();
assert_eq!(
state.validate(),
Err(Refusal::BaseUrl),
"没有协议头不算端点"
);
state.base_url = String::new();
assert_eq!(
state.validate(),
Err(Refusal::BaseUrl),
"OpenAI 兼容形态没有默认端点,空值必须拒绝(不保存)"
);
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = " ".to_string();
assert_eq!(state.validate(), Err(Refusal::Model));
// Ollama 有默认端点:空 base_url 是合法的(生效值回落默认)。
state.provider = ProviderId::Ollama;
state.base_url = String::new();
state.model = "llama3".to_string();
assert_eq!(state.validate(), Ok(()));
assert_eq!(state.resolved_base_url(), "http://127.0.0.1:11434");
// 禁用档:什么都不填也能保存(那正是「关掉助手」)。
state.provider = ProviderId::Disabled;
state.model = String::new();
assert_eq!(state.validate(), Ok(()));
}
#[test]
fn missing_names_exactly_one_field() {
let mut state = configured();
assert_eq!(state.missing(), None);
state.enabled = false;
assert_eq!(state.missing(), Some(MissingField::Disabled));
let mut state = configured();
state.base_url = String::new();
assert_eq!(state.missing(), Some(MissingField::BaseUrl));
let mut state = configured();
state.model = String::new();
assert_eq!(state.missing(), Some(MissingField::Model));
// Anthropic 必须带密钥:草稿空 + 已存 0 字符 = 缺密钥。
let mut state = configured();
state.provider = ProviderId::Anthropic;
state.base_url = String::new();
state.model = "claude-sonnet-4".to_string();
state.key = Secret::default();
state.key_chars = 0;
assert_eq!(state.missing(), Some(MissingField::ApiKey));
// 已存过密钥(只有长度,没有值)就算配置齐了。
state.key_chars = 40;
assert_eq!(state.missing(), None);
}
#[test]
fn can_test_is_false_until_configured() {
let mut state = configured();
assert!(state.can_test());
state.model = String::new();
assert!(!state.can_test(), "缺模型名时「测试连接」必须禁用");
let mut state = configured();
state.storage = false;
assert!(!state.can_test(), "没有设置文件时也禁用");
let mut state = configured();
state.probe = Probe::Running;
assert!(!state.can_test(), "正在测的时候不许再点一次");
}
#[test]
fn dirty_draft_is_not_overwritten() {
let mut state = configured();
state.base_url = "https://typed.example/v1".to_string();
state.dirty = true;
let changed = state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::Ollama,
base_url: "http://127.0.0.1:11434".to_string(),
model: "llama3".to_string(),
key_chars: 12,
});
assert!(!changed);
assert_eq!(
state.base_url, "https://typed.example/v1",
"脏草稿不许被冲掉"
);
assert_eq!(state.provider, ProviderId::OpenAiCompatible);
// 密钥长度不属于草稿:外部改了密钥,长度照样要更新(界面上的「已存 N 字符」不能骗人)。
assert_eq!(state.key_chars, 12);
// 不脏的时候照常灌入。
let mut state = State::default();
let changed = state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::Ollama,
base_url: "http://127.0.0.1:11434".to_string(),
model: "llama3".to_string(),
key_chars: 0,
});
assert!(changed);
assert_eq!(state.provider, ProviderId::Ollama);
assert!(state.storage, "灌过真值之后设置文件就是可用的");
}
#[test]
fn saving_a_new_target_drops_the_old_pass() {
let mut state = configured();
state.probe = Probe::Done(ProbeResult {
kind: ProbeKind::Passed,
missing: None,
base_url: state.resolved_base_url(),
model: state.model.clone(),
elapsed_ms: 120,
detail: String::new(),
});
assert_eq!(state.status(), Status::Verified);
// 同一个目标重复灌入:结论仍然有效。
let snapshot = Snapshot {
enabled: true,
provider: state.provider,
base_url: state.base_url.clone(),
model: state.model.clone(),
key_chars: 0,
};
state.hydrate(snapshot);
assert_eq!(
state.status(),
Status::Verified,
"目标没变,别无故作废旧结论"
);
// 换了端点:旧结论不再关于现在这套配置。
state.dirty = false;
state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::OpenAiCompatible,
base_url: "https://other.example/v1".to_string(),
model: "deepseek-chat".to_string(),
key_chars: 0,
});
assert_eq!(state.probe, Probe::Idle, "目标变了,旧「已通过」必须作废");
assert_eq!(state.status(), Status::Configured);
}
/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。
///
/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷——
/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。
#[test]
fn failures_carry_the_target_that_was_dialed() {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target};
let target = || Target {
base_url: "https://api.deepseek.com/v1".to_string(),
model: "deepseek-chat".to_string(),
};
let not_configured = ProbeResult::from_outcome(
ProbeOutcome::NotConfigured {
missing: Missing::Model,
},
0,
);
assert_eq!(not_configured.kind, ProbeKind::NotConfigured);
assert_eq!(not_configured.missing, Some(MissingField::Model));
assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址");
assert!(not_configured.model.is_empty());
assert!(!not_configured.sent_a_request());
for (outcome, expected) in [
(
ProbeOutcome::Auth {
target: target(),
detail: "401".to_string(),
},
ProbeKind::Auth,
),
(
ProbeOutcome::ModelNotFound {
target: target(),
detail: "404".to_string(),
},
ProbeKind::ModelNotFound,
),
(
ProbeOutcome::Unreachable {
target: target(),
detail: "refused".to_string(),
},
ProbeKind::Unreachable,
),
(
ProbeOutcome::Timeout {
target: target(),
detail: "8 s".to_string(),
},
ProbeKind::Timeout,
),
(
ProbeOutcome::Failed {
target: target(),
detail: "?".to_string(),
},
ProbeKind::Failed,
),
(ProbeOutcome::Passed { target: target() }, ProbeKind::Passed),
] {
let result = ProbeResult::from_outcome(outcome, 5);
assert_eq!(result.kind, expected);
assert_eq!(
result.base_url, "https://api.deepseek.com/v1",
"{expected:?}"
);
assert_eq!(result.model, "deepseek-chat", "{expected:?}");
if expected != ProbeKind::Passed {
assert!(result.sent_a_request());
}
}
}
#[test]
fn screenshot_seed_covers_the_state_matrix() {
// 未配置 / 已配置 / 已通过 / 失败四档都要能出图(D30 的矩阵)。
let unconfigured = State::screenshot_seed("unconfigured").expect("种子");
assert_eq!(unconfigured.status(), Status::Unconfigured);
assert!(unconfigured.storage, "截图里也要如实显示「有设置文件」");
let configured = State::screenshot_seed("configured").expect("种子");
assert_eq!(configured.status(), Status::Configured);
assert_eq!(configured.model, "deepseek-chat");
let verified = State::screenshot_seed("verified").expect("种子");
assert_eq!(verified.status(), Status::Verified);
let failed = State::screenshot_seed("failed-auth").expect("种子");
assert_eq!(failed.status(), Status::Failed);
// P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。
// 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。
if let Probe::Done(result) = &failed.probe {
assert_eq!(result.base_url, "https://api.deepseek.com/v1");
assert_eq!(result.model, "deepseek-chat");
assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情");
} else {
panic!("失败种子没带上探测结论");
}
assert!(
State::screenshot_seed("nonsense").is_none(),
"未知种子不伪造状态"
);
}
#[test]
fn every_key_used_here_exists_in_the_catalog() {
// 中英各一遍,逐条取词:目录里没这条 key 就编译不过(`Key` 是枚举),
// 但**译文为空**这件事只有这里抓得住。
let mut keys: Vec<unlucky_i18n::Key> = ProviderId::ALL.iter().map(|id| id.label()).collect();
keys.extend([
MissingField::Disabled.label(),
MissingField::Provider.label(),
MissingField::BaseUrl.label(),
MissingField::Model.label(),
MissingField::ApiKey.label(),
Refusal::BaseUrl.label(),
Refusal::Model.label(),
Status::Unconfigured.label(),
Status::Configured.label(),
Status::Verified.label(),
Status::Failed.label(),
ProbeKind::Passed.label(),
ProbeKind::Auth.label(),
ProbeKind::ModelNotFound.label(),
ProbeKind::Unreachable.label(),
ProbeKind::Timeout.label(),
ProbeKind::Failed.label(),
ProbeKind::NotConfigured.label(),
]);
for lang in [Lang::ZhCn, Lang::En] {
let catalog = Catalog::of(lang);
for key in &keys {
assert!(
!catalog.text(*key).trim().is_empty(),
"{key:?} 在 {lang:?} 下没有译文"
);
}
}
}
@@ -0,0 +1,360 @@
//! 「助手」分组的一张卡片(M174)。
//!
//! ## 版面(D30 的状态矩阵 + 数值)
//!
//! | 行 | 内容 | 数值 |
//! |---|---|---|
//! | 状态 | 未配置 / 已配置 / 连接已通过 / 测试失败 | 生效端点 + 模型名 |
//! | 开关 | 启用助手(`ai.enabled`) | — |
//! | 提供商 | 四态 filter chip(`ai.provider`) | 存的值 |
//! | 端点 | `base_url` 文本框 | 生效值(空 = 该形态默认) |
//! | 模型 | `ai.model` 文本框 | — |
//! | 密钥 | 密码框(默认掩码)+「显示」 | **只显示字符数**,不显示值 |
//! | 动作 | 保存 / 测试连接 | 测试结论带耗时(毫秒) |
//!
//! 「未配置」时「测试连接」是**禁用**的,并在下面直接说清缺哪一项——不是灰着什么都不说。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::settings::ai::outcome::ProbeKind;
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
use crate::pages::settings::{ai_about, GroupMessage, States};
use crate::pages::Message;
use crate::theme::Ui;
/// 一条本组消息 → 页面消息(左列/正文都用这一处,别在下面各写一遍)。
fn msg(message: ai_about::Message) -> Message {
Message::Settings(GroupMessage::AiAbout(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.ai_about, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(enable_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(provider_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(endpoint_field(ui, catalog, state));
stacked = stacked.push(model_field(ui, catalog, state));
stacked = stacked.push(key_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
if let Probe::Done(result) = &state.probe {
stacked = stacked.push(probe_line(ui, catalog, result.kind));
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
if !(result.base_url.is_empty() && result.model.is_empty()) {
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestTarget,
&[
("base_url", result.base_url.as_str()),
("model", result.model.as_str()),
("ms", result.elapsed_ms.to_string().as_str()),
],
),
ui.color(Role::OnSurfaceVariant),
));
}
if !result.detail.trim().is_empty() {
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestDetail,
&[("detail", result.detail.as_str())],
),
ui.color(Role::OnSurfaceVariant),
));
}
}
card(ui, stacked)
}
/// 状态行:四档文案 + 生效端点 / 模型(数值直接上屏,不藏在提示里)。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let status = state.status();
let color = match status {
Status::Failed => ui.color(Role::Error),
Status::Verified => ui.color(Role::Primary),
_ => ui.color(Role::OnSurfaceVariant),
};
let target = if state.model.trim().is_empty() {
state.resolved_base_url()
} else {
format!("{} · {}", state.resolved_base_url(), state.model.trim())
};
let line = catalog.render(
Key::SettingsAiStatusLabel,
&[("status", catalog.text(status.label()))],
);
row![
container(text_role_owned(ui, TypeRole::BodyMedium, true, line, color)).width(Length::Fill),
text_role_owned(ui, TypeRole::BodySmall, false, target, color),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
fn enable_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
column![
switch(
ui,
Switch::new(
catalog.text(Key::SettingsAiEnable),
state.enabled,
msg(ai_about::Message::ToggleEnabled(!state.enabled)),
),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsAiEnableHint).to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn provider_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
// `.wrap()`:四颗 chip 在 424 宽的正文列里放不下一行时**整颗换行**,而不是把最后一颗
// 压成竖排(「已禁 / 用」那种断字)。`Wrapping` 只提供 `push`,所以先建后 wrap。
let mut chips = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for provider in ProviderId::ALL {
chips = chips.push(chip(
ui,
Chip::filter(
catalog.text(provider.label()),
state.provider == provider,
msg(ai_about::Message::PickProvider(provider)),
),
));
}
row![
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsAiProviderLabel).to_string(),
ui.color(Role::OnSurfaceVariant),
),
chips.wrap(),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
fn endpoint_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.base_url)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiBaseUrl))
.placeholder(catalog.text(Key::SettingsAiBaseUrlPlaceholder))
.supporting(catalog.text(Key::SettingsAiBaseUrlHint));
// 「生效:…」只在**回落默认值**(草稿为空、该形态有默认端点)时才值得说一句:
// 草稿就是生效值时它是废话,空着且没有默认值时它是空串(比不说更糟)。
let effective = state.resolved_base_url();
let mut block = column![text_field(ui, field, |value| msg(
ai_about::Message::EditBaseUrl(value)
))]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !effective.is_empty() && effective != state.base_url.trim() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsAiEffective, &[("value", effective.as_str())]),
ui.color(Role::OnSurfaceVariant),
));
}
block.into()
}
fn model_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.model)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiModel))
.placeholder(catalog.text(Key::SettingsAiModelPlaceholder));
text_field(ui, field, |value| msg(ai_about::Message::EditModel(value)))
}
/// 密钥行:密码框 + 「显示 / 隐藏」chip + 已存状态(**只有字符数**)。
fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let chars = state.key_chars.to_string();
let supporting = if state.key_chars > 0 {
catalog.render(
Key::SettingsAiApiKeyConfigured,
&[("chars", chars.as_str())],
)
} else {
catalog.text(Key::SettingsAiApiKeyMissing).to_string()
};
let field = Field::new(state.key.expose())
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiApiKey))
.placeholder(catalog.text(Key::SettingsAiApiKeyPlaceholder))
.secure(!state.reveal);
let toggle_label = if state.reveal {
catalog.text(Key::SettingsAiHide)
} else {
catalog.text(Key::SettingsAiShow)
};
let toggle = chip(
ui,
Chip::filter(
toggle_label,
state.reveal,
msg(ai_about::Message::Reveal(!state.reveal)),
),
);
let field = container(text_field(ui, field, |value| {
msg(ai_about::Message::EditKey(Secret::new(value)))
}))
.width(Length::Fill);
column![
row![field, toggle]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),
// 「已存 N 字符 / 还没有密钥」这一行是**格式化出来的**(`Field::supporting` 借的是
// `&'a str`,临时 String 借不进去),所以单独一行;它只讲长度,不讲值。
text_role_owned(
ui,
TypeRole::BodySmall,
false,
supporting,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let save_label = catalog.text(Key::SettingsAiSave);
let save = if state.dirty && state.storage {
buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save)))
} else {
buttons::filled(ui, Button::disabled(save_label))
};
let test_label = if matches!(state.probe, Probe::Running) {
catalog.text(Key::SettingsAiTesting)
} else {
catalog.text(Key::SettingsAiTest)
};
let test = if state.can_test() {
buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test)))
} else {
buttons::tonal(ui, Button::disabled(test_label))
};
row![save, test]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.into()
}
/// 按钮为什么是灰的 / 下一步该做什么——**永远说清**,不留「点了没反应」。
fn hint_line<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, error) = if !state.storage {
(catalog.text(Key::SettingsAiNoStorage).to_string(), true)
} else if let Some(missing) = state.missing() {
(
catalog.render(
Key::SettingsAiTestDisabled,
&[("missing", catalog.text(missing.label()))],
),
false,
)
} else if state.dirty {
(catalog.text(Key::SettingsAiUnsaved).to_string(), false)
} else {
(String::new(), false)
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
}),
)
}
fn notice_line<'a>(ui: &'a Ui, catalog: Catalog, notice: &SaveNotice) -> Element<'a, Message> {
let (text, error) = match notice {
SaveNotice::Saved => (catalog.text(Key::SettingsAiSaved).to_string(), false),
SaveNotice::Refused(reason) => (
catalog.render(
Key::SettingsAiRefused,
&[("reason", catalog.text(reason.label()))],
),
true,
),
SaveNotice::Failed(reason) => (
catalog.render(Key::SettingsAiRefused, &[("reason", reason.as_str())]),
true,
),
SaveNotice::NoStorage => (catalog.text(Key::SettingsAiNoStorage).to_string(), true),
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error { Role::Error } else { Role::OnSurface }),
)
}
/// 测试结论一行:分类一句话(目标/耗时/详情在它下面三行)。
fn probe_line<'a>(ui: &'a Ui, catalog: Catalog, kind: ProbeKind) -> Element<'a, Message> {
let error = kind.is_failure();
text_role_owned(
ui,
TypeRole::BodyMedium,
true,
catalog.text(kind.label()).to_string(),
ui.color(if error { Role::Error } else { Role::Primary }),
)
}
+71 -30
View File
@@ -1,39 +1,80 @@
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。 //! 设置页「助手(AI)」分组(M174):左列第 6 组的**挂载点**与本线的消息口。
//! //!
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance / //! 本文件只留三样东西,正文/状态/读写都在 `pages/settings/ai/` 里:
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
//! //!
//! ## 接线步骤(ai+about 线照做) //! | 这里 | 内容 |
//! |---|---|
//! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) |
//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组尚未迁到 `Group.body` 槽位,见下节) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) |
//! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) |
//! //!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`; //! ## 为什么 `body` 是 `None` 而不是 `Some(..)`
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//! //!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。 //! `Group.body` 的槽位(M179 起)拿的是 [`States`](super::States) 与**生效语言** `Lang`——本组要的
//! 两件它其实都装得下。本组仍走按下标分发,是因为**还没迁过去**:槽位要求返回 `Option<Element>`,
//! 而 `ai::view::body` 返回 `Element`,把本组(以及 Java 组)统一迁到槽位上是一次独立重构。
//! M106 的语言行与 M159 的更新组当初也是同一个理由走上这条路,代价是 `settings/mod.rs` 多一行 `else if`。
//! //!
//! ## M101 起本文件还装本线的状态与消息口 //! ## 密钥纪律
//! //!
//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件); //! [`Message::EditKey`] 的载荷是用户刚敲的明文(输入框必须有它),它**只**活在这个进程里:
//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。 //! 落盘走 `ai::store::save`(不经 `AppCommand::SetSetting` 那条可序列化通道),
//! //! 上屏默认掩码(`State::reveal` 才明文),`Debug` 由 `ai::state::Secret` 掩码。
//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配):
//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1);
//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译),
//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。
//!
//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。
/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。 pub use super::ai::State;
///
/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。
#[derive(Debug, Default)]
pub struct State {}
/// AI / About 线的消息(**只归本线**)。 use unlucky_design::icons::IconKind;
///
/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是 use super::Group;
/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报
/// E0004,不会静默。 /// 本组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(crate) const AI_GROUP: usize = 5;
/// 「助手」分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "Assistant",
icon: IconKind::Ai,
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 `ai::view::body`。
rows: &[
("Provider", "OpenAI compatible"),
("Endpoint", "https://api.deepseek.com/v1"),
("Model", "deepseek-chat"),
],
body: None,
};
/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/ai_about.rs`。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message {} pub enum Message {
/// 启用开关(`ai.enabled`)
ToggleEnabled(bool),
/// 选提供商(`ai.provider`)
PickProvider(super::ai::ProviderId),
/// 端点草稿(`ai.base_url`)
EditBaseUrl(String),
/// 模型名草稿(`ai.model`)
EditModel(String),
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。
///
/// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness
/// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里,
/// 用裸 `String` 等于给密钥开了一条到日志的路。
EditKey(super::ai::Secret),
/// 密钥输入框明文/掩码
Reveal(bool),
/// 保存(校验 + 落盘)
Save,
/// 测试连接(异步;结果走 [`Message::ProbeDone`])
Test,
/// 测试连接的结果(分类 + 数值 + 脱敏详情)
ProbeDone(Box<super::ai::ProbeResult>),
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::AiAbout(self)
}
}
@@ -5,20 +5,25 @@
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP` //! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。 //! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
//! //!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责 //! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。 //! 把它挂进注册表),所以改控件时不用碰设置页目录。
//! //!
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」 //! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。 //! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
//!
//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的
//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`,
//! 因为渲染器拿不到「这一次生效的语言」。
use iced::Element; use iced::Element;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_i18n::Lang;
use crate::theme::Ui; use crate::theme::Ui;
use super::{Group, Message, States}; use super::{Group, Message, States};
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件); /// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件);
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。 /// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
pub(super) const GROUP: Group = Group { pub(super) const GROUP: Group = Group {
name: "Appearance", name: "Appearance",
@@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group {
}; };
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。 /// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> { ///
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, Message>> {
states states
.appearance .appearance
.map(|state| crate::pages::appearance::section(ui, state)) .map(|state| crate::pages::appearance::section(ui, state, lang))
} }
+34 -15
View File
@@ -12,8 +12,8 @@ pub(super) use super::super::frame::{
text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
}; };
use iced::widget::{column, container, mouse_area, row, scrollable}; use iced::widget::{column, container, mouse_area, row};
use iced::{Alignment, Background, Border, Element, Length, Padding}; use iced::{Alignment, Background, Border, Element, Length, Padding, Theme};
use unlucky_design::color::Role; use unlucky_design::color::Role;
use unlucky_design::component_map::Component; use unlucky_design::component_map::Component;
@@ -137,20 +137,39 @@ fn group_item<'a>(
/// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。 /// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。
/// ///
/// 「外观」分组有 7 个控件 + 最多 3 行诊断,1180×760 下会超出可视区,所以这一层是必需的。 /// 「外观」分组有 7 个控件 + 最多 3 行诊断,1180×760 下会超出可视区,所以这一层是必需的。
pub(super) fn scroll<'a>( ///
/// ## M173:滚动条**并排**在内容右侧
///
/// 原来这里是 `scrollable(content)` 裸调 + 轨宽 6 / 内缩 2。iced 的竖直滚动条是浮层:轨道按
/// 视口右边缘定位(`iced_widget-0.14.2/src/scrollable.rs:1970`),而内容拿到的横向约束是**整个
/// 视口宽**(同文件 `:437`,`Scrollbar::spacing` 默认 `None` ⇒ 右内边距 0)。于是滚动条占的那条
/// 10dp 竖带(轨 6 + 两侧内缩 2×2)整条压在表单列最右边——用户看到的就是「滚动条骑在卡片上」。
///
/// 改法在 [`crate::app::components::scroll::vertical`]:内容右侧恒留一条 10dp 的槽,轨道画在槽里。
/// 槽宽来自同一个式子 `轨宽 + 2 × margin`,所以:
///
/// - 内容可用宽度 = 视口宽(424)− 槽宽(10)= **414**;
/// - 长页面(出滑块)与短页面(不出滑块)**内容宽度一致**——槽是内边距,不依赖 iced 的
/// `is_scrollbar_visible`(那正是 `Scrollbar::spacing` 不能用在这里的理由:它只在可见时预留,
/// 滑块一出现内容就横跳 10dp)。
///
/// 数值由 `components/scroll.rs` 与 `tests/scroll_fit.rs` 的**布局级**断言钉住(`Renderer = ()`
/// 真跑 iced 的 `layout()`),不是看图。
///
/// 只泛型到 `Renderer` 是为了让上面那条断言能在 `()` 渲染器上跑;产品调用点不写泛型。
pub(super) fn scroll<'a, Renderer>(
ui: &'a Ui, ui: &'a Ui,
content: impl Into<Element<'a, Message>>, content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message> { ) -> Element<'a, Message, Theme, Renderer>
scrollable(content) where
.direction(scrollable::Direction::Vertical( Renderer: iced::advanced::text::Renderer + 'a,
scrollable::Scrollbar::new() {
.width(SCROLLBAR_WIDTH) crate::app::components::scroll::vertical(
.scroller_width(SCROLLBAR_WIDTH) SCROLLBAR_WIDTH,
.margin(SCROLLBAR_MARGIN), SCROLLBAR_MARGIN,
)) scroll_style(ui),
.height(Length::Fill) content,
.style(scroll_style(ui)) )
.into()
} }
/// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。 /// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
+115 -5
View File
@@ -4,10 +4,11 @@
//! //!
//! ## 这一组为什么特殊 //! ## 这一组为什么特殊
//! //!
//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有 //! 它**不走** `Group::body`:那个渲染器拿的是 [`States`](super::States) 与**生效语言** `Lang`
//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在 //! (M179 起;此前连 `Lang` 都没有),而语言行要的是「当前语言**档位** `LangChoice` + 生效语言」
//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标 //! 这一对——档位不在 `States` 里。所以它由 `pages::settings::view` 的 `language` 形参一路递进来、
//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。 //! `form` 按下标转交 [`body`];`GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」
//! 照样成立。
//! //!
//! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记) //! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记)
//! //!
@@ -46,6 +47,7 @@ use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key; use unlucky_i18n::catalog::Key;
use unlucky_i18n::LangChoice; use unlucky_i18n::LangChoice;
use crate::fonts::install::{CjkInstall, FetchClass};
use crate::pages::{frame, Language, Message}; use crate::pages::{frame, Language, Message};
use crate::theme::Ui; use crate::theme::Ui;
@@ -71,6 +73,10 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog(); let catalog = language.effective.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill); let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(language_row(ui, language)); stacked = stacked.push(language_row(ui, language));
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
stacked = stacked.push(divider(ui));
stacked = stacked.push(font_row(ui, language));
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 // 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
for (label, value) in [ for (label, value) in [
@@ -85,6 +91,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
card(ui, stacked) card(ui, stacked)
} }
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
///
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
///
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
pub(super) fn font_status(
catalog: &unlucky_i18n::catalog::Catalog,
status: &CjkInstall,
) -> (String, Option<String>) {
match status {
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
CjkInstall::System { family } => (
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
None,
),
CjkInstall::Downloading { bytes } => (
catalog.render(
Key::FontCjkStatusDownloading,
&[("size", &format_size(*bytes))],
),
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
),
CjkInstall::Ready { family, .. } => (
catalog.render(Key::FontCjkReady, &[("family", family)]),
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
),
CjkInstall::Failed { class, .. } => {
let reason = catalog.text(match class {
FetchClass::Network => Key::FontCjkReasonNetwork,
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
FetchClass::Disk => Key::FontCjkReasonDisk,
});
(
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
None,
)
}
}
}
/// 字节数 → 人话体积(17 772 300 B → `16.9 MB`,按 1 MiB = 1048576 B)。只用于「≈」级别的量。
fn format_size(bytes: u64) -> String {
const MIB: f64 = 1024.0 * 1024.0;
let mib = bytes as f64 / MIB;
if mib >= 1.0 {
format!("{mib:.1} MB")
} else {
format!("{:.0} KB", bytes as f64 / 1024.0)
}
}
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
///
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let status = crate::fonts::install::status();
let (value, hint) = font_status(&catalog, &status);
let color = match status {
CjkInstall::Failed { .. } => ui.color(Role::Error),
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
_ => ui.color(Role::OnSurfaceVariant),
};
let mut stacked = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
catalog.text(Key::FontCjkStatusLabel),
ui.color(Role::OnSurface),
),
container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
color
))
.width(Length::Fill),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]));
if let Some(hint) = hint {
stacked = stacked.push(container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
)));
}
stacked.into()
}
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。 /// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog(); let catalog = language.effective.catalog();
@@ -175,7 +285,7 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 /// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。
/// ///
/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。 /// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> { fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
let height = ui.metrics().control_height; let height = ui.metrics().control_height;
let content_color = if selected { let content_color = if selected {
+67 -27
View File
@@ -1,44 +1,84 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。 //! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件), //! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里:
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//! //!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架; //! | 这里 | 内容 |
//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。 //! |---|---|
//! | [`GROUP`] | 左列名 / 图标(正文走 [`view::body`],`body` 槽位保持 `None`) |
//! | [`JAVA_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update / Assistant 同一条**按下标分发**:本组要 `Lang`,`Group.body` 的签名里没有它) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/java.rs`) |
//!
//! ## M101 的演示行去哪了
//!
//! M101 留的 `rows: &[("Runtime","Temurin 21.0.4"), …]` 与绊线测试
//! `the_java_group_is_still_demo_rows`(断言 `body.is_none()`)在 M175 一起删掉了:
//! 那三个值是**写死的演示字符串**,用户实测把它们当成了真检测结果
//! (「它默认显示 Temurin 21.0.4 我甚至不能确定它是否是一个硬编码的值」)。
//! 现在的真值来自三段真读盘:`core::game::java::staged`(扫描)、`core::game::java::manual`
//! (手动路径严格探测)、`config/launcher.json`(`java.*` 三键)。
//!
//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中
//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。
mod fields;
mod row;
mod seed;
mod state;
pub(crate) mod store;
pub(super) mod view;
#[cfg(test)]
mod tests;
pub use state::{same_path, Context, Layer, Notice, Requirement, State};
/// 测试里的注入作业要写 `Vec<RuntimeRow>`(App 层在生产路径上只用 `State`,不需要这个名字)。
#[cfg(test)]
pub use row::RuntimeRow;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use super::Group; use super::Group;
/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。
///
/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。
pub(crate) const JAVA_GROUP: usize = 2;
/// Java 分组。 /// Java 分组。
pub(super) const GROUP: Group = Group { pub(super) const GROUP: Group = Group {
name: "Java", name: "Java",
icon: IconKind::Folder, icon: IconKind::Folder,
rows: &[ // 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。
("Runtime", "Temurin 21.0.4"), rows: &[],
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
body: None, body: None,
}; };
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。 /// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
#[derive(Debug, Default)]
pub struct State {}
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 → /// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来)
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。 Scan,
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的 /// 取消正在跑的扫描
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。 CancelScan,
#[cfg(test)] /// 手动路径输入框
Ping, AddDraft(String),
/// 提交手动添加(严格校验:真跑 `java -version`)
Add,
/// 把某个运行时设为默认(写 `java.selected_path`)
SelectRuntime(String),
/// 默认内存草稿(MB 文本)
EditMemory(String),
/// JVM 额外参数草稿
EditJvm(String),
/// 保存内存 + JVM 参数(校验 + 落盘 + 回读)
Save,
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::Java`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::Java(self)
}
} }
@@ -0,0 +1,246 @@
//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。
//!
//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是
//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口,
//! 所以「一条控件对应哪条消息」不会各写一遍。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::pages::frame::text_role_owned;
use crate::pages::Message;
use crate::theme::Ui;
use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::view::msg;
use super::Message as JavaMessage;
/// 手动添加一行:输入框 + 「添加」按钮 + 原因。
pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.add_draft)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaAdd))
.placeholder(catalog.text(Key::SettingsJavaAddPlaceholder))
.supporting(catalog.text(Key::SettingsJavaAddHint));
let add = if state.add_draft.trim().is_empty() || !state.storage {
buttons::outlined(
ui,
Button::disabled(catalog.text(Key::SettingsJavaAddAction)),
)
} else {
buttons::outlined(
ui,
Button::new(
catalog.text(Key::SettingsJavaAddAction),
msg(JavaMessage::Add),
),
)
};
row![
container(text_field(ui, field, |value| msg(JavaMessage::AddDraft(
value
))))
.width(Length::Fill),
add,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。
pub(super) fn memory_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let range = catalog.render(
Key::SettingsJavaMemoryHint,
&[
("min", MIN_MEMORY_MB.to_string().as_str()),
("max", MAX_MEMORY_MB.to_string().as_str()),
],
);
let field = Field::new(&state.memory)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaMemory));
// 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。
let field = match state.validate() {
Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => {
field.error()
}
_ => field,
};
let effective = match state.layer() {
Layer::Global => catalog.render(
Key::SettingsJavaLayerGlobal,
&[("mb", state.saved_memory.to_string().as_str())],
),
Layer::Instance { instance, mb } => catalog.render(
Key::SettingsJavaLayerInstance,
&[
("mb", mb.to_string().as_str()),
("instance", instance.as_str()),
],
),
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
effective,
ui.color(Role::OnSurfaceVariant),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
range,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。
pub(super) fn jvm_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let field = Field::new(&state.jvm)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaJvm))
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
.supporting(catalog.text(Key::SettingsJavaJvmHint));
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
field.error()
} else {
field
};
// 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。
let saved = if state.saved_jvm.trim().is_empty() {
catalog.text(Key::SettingsJavaJvmSavedNone).to_string()
} else {
catalog.render(
Key::SettingsJavaJvmSaved,
&[("value", state.saved_jvm.as_str())],
)
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
saved,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
pub(super) fn actions_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let label = catalog.text(Key::SettingsJavaSave);
if state.can_save() {
buttons::filled(ui, Button::new(label, msg(JavaMessage::Save)))
} else {
buttons::filled(ui, Button::disabled(label))
}
}
/// 按钮为什么灰着 / 下一步做什么——**永远说清**。
pub(super) fn hint_line<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let (text, error) = if !state.storage {
(catalog.text(Key::SettingsJavaNoStorage).to_string(), true)
} else if state.dirty {
(catalog.text(Key::SettingsJavaUnsaved).to_string(), false)
} else {
(String::new(), false)
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
}),
)
}
pub(super) fn notice_line<'a>(
ui: &'a Ui,
catalog: Catalog,
notice: &Notice,
) -> Element<'a, Message> {
let (text, error) = match notice {
Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false),
Notice::Refused(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(refusal_key(*reason)))],
),
true,
),
Notice::Unwritable(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(reason.label()))],
),
true,
),
Notice::WriteFailed => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))],
),
true,
),
Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true),
Notice::DefaultSet(path) => (
catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]),
false,
),
Notice::Added(path) => (
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
false,
),
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error { Role::Error } else { Role::OnSurface }),
)
}
/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。
pub(super) fn refusal_key(refusal: Refusal) -> Key {
refusal.label()
}
@@ -0,0 +1,191 @@
//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。
//!
//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线):
//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。
use unlucky_i18n::Key;
/// 一个扫到的 / 手动加的运行时(页面要的那几格)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeRow {
/// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。
pub binary: String,
/// `java -version` 的首行(探测失败时为空串)。
pub version: String,
/// 主版本号(探测不出为 `None`)。
pub major: Option<u32>,
/// 发行方(认不出为 `None`)。
pub vendor: Option<String>,
/// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。
pub source: &'static str,
}
impl RuntimeRow {
/// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。
///
/// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`):
/// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`);
/// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`);
/// 3. 整行原样(原始版本串比一个大版本号有用);
/// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。
#[must_use]
pub fn version_label(&self) -> String {
if let Some(quoted) = quoted_version(&self.version) {
return quoted;
}
if let Some(bare) = bare_version(&self.version) {
return bare;
}
let line = self.version.trim();
if !line.is_empty() {
return line.to_string();
}
self.major
.map_or_else(|| "?".to_string(), |major| major.to_string())
}
/// 版本一句话:`Java 21.0.11 · Microsoft`。
#[must_use]
pub fn summary(&self) -> String {
let version = self.version_label();
match &self.vendor {
Some(vendor) => format!("Java {version} · {vendor}"),
None => format!("Java {version}"),
}
}
/// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。
///
/// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`,
/// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。
#[must_use]
pub fn home(&self) -> String {
let path = std::path::Path::new(self.binary.trim());
let parent = path.parent();
match parent.filter(|dir| {
dir.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
}) {
Some(bin) => bin
.parent()
.map_or_else(|| self.binary.clone(), |home| home.display().to_string()),
None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()),
}
}
/// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。
///
/// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接
/// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名
/// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。
#[must_use]
pub fn display_home(&self) -> String {
const LIMIT: usize = 39;
let home = self.home();
if home.chars().count() <= LIMIT {
return home;
}
let tail: String = home
.chars()
.rev()
.take(LIMIT - 1)
.collect::<Vec<char>>()
.into_iter()
.rev()
.collect();
// 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。
let tail = tail
.find(['\\', '/'])
.map_or(tail.clone(), |at| tail[at + 1..].to_string());
format!("…{}{}", std::path::MAIN_SEPARATOR, tail)
}
/// 来源文案 key。
#[must_use]
pub fn source_key(&self) -> Key {
match self.source {
"managed" => Key::SettingsJavaSourceManaged,
"env" => Key::SettingsJavaSourceEnv,
"path" => Key::SettingsJavaSourcePath,
"manual" => Key::SettingsJavaSourceManual,
_ => Key::SettingsJavaSourceSystem,
}
}
}
/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。
fn quoted_version(line: &str) -> Option<String> {
let start = line.find('"')? + 1;
let rest = &line[start..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。
fn bare_version(line: &str) -> Option<String> {
line.split_whitespace().find_map(|token| {
let candidate = token.trim_matches([',', '(', ')', '"']);
let mut parts = candidate.split('.');
let first = parts.next()?;
let looks_like_version = first.chars().all(|c| c.is_ascii_digit())
&& !first.is_empty()
&& parts.next().is_some();
looks_like_version.then(|| candidate.to_string())
})
}
#[cfg(test)]
mod tests {
use super::RuntimeRow;
fn row(version: &str, major: Option<u32>) -> RuntimeRow {
RuntimeRow {
binary: "C:/jdk/bin/java.exe".to_string(),
version: version.to_string(),
major,
vendor: Some("Microsoft".to_string()),
source: "system",
}
}
/// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。
///
/// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。
#[test]
fn the_version_label_keeps_the_minor_digits() {
assert_eq!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(),
"17.0.13"
);
assert_eq!(
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(),
"17.0.9"
);
assert_ne!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(),
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(),
"两份同大版本 JDK 的摘要必须不同"
);
assert_eq!(
row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(),
"25.0.3",
"无引号形态也要认"
);
assert_eq!(
row("something unexpected 21", Some(21)).version_label(),
"something unexpected 21",
"解析不出来时给原始版本串,不给光秃秃的大版本"
);
assert_eq!(row("", Some(21)).version_label(), "21");
assert_eq!(row("", None).version_label(), "?");
}
#[test]
fn the_summary_carries_version_and_vendor() {
assert_eq!(
row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(),
"Java 21.0.11 · Microsoft"
);
}
}
@@ -0,0 +1,75 @@
//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。
//!
//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
//!
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。
///
/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与
/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn build(raw: &str) -> Option<State> {
let mut state = State {
storage: true,
scanned: true,
..State::default()
};
match raw.trim() {
"empty" => {
state.memory = "2048".to_string();
state.saved_memory = 2048;
}
"two" => {
state.runtimes = vec![
RuntimeRow {
binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe"
.to_string(),
version: "openjdk version \"17.0.13\" 2024-10-15".to_string(),
major: Some(17),
vendor: Some("Temurin".to_string()),
source: "system",
},
RuntimeRow {
binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(),
version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(),
major: Some(21),
vendor: Some("Microsoft".to_string()),
source: "system",
},
];
state.selected = state.runtimes[1].binary.clone();
state.manual = vec!["D:/jdk/zulu-21".to_string()];
state.memory = "4096".to_string();
state.saved_memory = 4096;
state.jvm = "-XX:+UseG1GC".to_string();
state.saved_jvm = "-XX:+UseG1GC".to_string();
state.context = Context {
required: Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
}),
layer: Some(Layer::Global),
effective_memory_mb: Some(4096),
};
}
"editing" => {
let mut seeded = build("two")?;
seeded.memory = "128".to_string();
seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string();
seeded.dirty = true;
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
return Some(seeded);
}
_ => return None,
}
Some(state)
}
@@ -0,0 +1,374 @@
//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。
//!
//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界:
//!
//! | 东西 | 住哪 | 为什么 |
//! |---|---|---|
//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 |
//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 |
//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 |
//!
//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值)
//!
//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。
//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。
//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。
//!
//! ## 版本门槛从哪来
//!
//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照
//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。
//! 页面自己不去读盘、也不复制版本表。
pub use super::row::RuntimeRow;
use unlucky_i18n::Key;
/// `java.default_memory_mb` 的下限(MB)。
///
/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值,
/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const MIN_MEMORY_MB: u32 = 512;
/// `java.default_memory_mb` 的上限(MB)。
pub const MAX_MEMORY_MB: u32 = 65536;
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const DEFAULT_MEMORY_MB: u32 = 2048;
/// 扫描进度(`None` = 没在扫)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
/// 已探测的候选数
pub done: usize,
/// 候选总数
pub total: usize,
}
/// 当前实例对 Java 的版本要求(app 层算好塞进来)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
/// 实例名(「为什么选它」那句话里要指名道姓)
pub instance: String,
/// 实例绑的版本 id(`1.20.1`)
pub version: String,
/// 最低主版本
pub minimum: u32,
/// 最高主版本(老版本才有;`None` = 不封顶)
pub maximum: Option<u32>,
}
impl Requirement {
/// 某个大版本满不满足。
#[must_use]
pub fn satisfied_by(&self, major: u32) -> bool {
major >= self.minimum && self.maximum.is_none_or(|max| major <= max)
}
}
/// 生效内存来自哪一层。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Layer {
/// 全局 `java.default_memory_mb`
Global,
/// 某个实例的 `launch.memoryMb` 覆盖了全局
Instance {
/// 实例名
instance: String,
/// 实例层的值
mb: u32,
},
}
/// 上下文(app 层每次 hydrate 重算)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Context {
/// 当前实例的版本要求(没有实例 ⇒ `None`)
pub required: Option<Requirement>,
/// 当前生效内存来自哪一层
pub layer: Option<Layer>,
/// 当前生效内存(三级回落之后的数值)
pub effective_memory_mb: Option<u32>,
}
/// 保存 / 操作的回执。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
/// 已落盘并读回
Saved,
/// 本地校验拒绝(没写盘)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串)
Unwritable(Unwritable),
/// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面,
/// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。
WriteFailed,
/// 没有可写文件(内存态运行)
NoStorage,
/// 默认 JDK 已写进 `java.selected_path`
DefaultSet(String),
/// 手动路径已加入名单
Added(String),
}
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unwritable {
/// 没有任何脏键(我们以为改了、其实没有)
NotDirty,
/// 文件只读(被更新版本的启动器写过 / 被标记不可信)
ReadOnly,
/// 文件不可信(加载时就降级成只读)
Unreadable,
}
impl Unwritable {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::NotDirty => Key::SettingsJavaSkipNotDirty,
Self::ReadOnly => Key::SettingsJavaSkipReadOnly,
Self::Unreadable => Key::SettingsJavaSkipUnreadable,
}
}
}
/// 本地校验的拒绝原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// 内存草稿为空
MemoryEmpty,
/// 内存草稿不是整数
MemoryNotNumber,
/// 内存越界
MemoryRange,
/// JVM 参数里的引号没配成对
JvmQuotes,
/// 手动路径为空
AddEmpty,
/// 手动路径在盘上找不到
AddMissing,
/// 有这个东西,但不是 java 可执行文件
AddNotJava,
/// 跑不出 `-version`
AddNoVersion,
/// 路径太长(粘了一整段日志)
AddTooLong,
/// 名单文件写不进去(磁盘 / 权限)
AddWriteFailed,
/// 已经加过了
AddDuplicate,
}
impl Refusal {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty,
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion,
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
}
}
}
/// 「Java」分组的运行期状态。
#[derive(Debug)]
pub struct State {
/// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后)
pub runtimes: Vec<RuntimeRow>,
/// 至少完整扫过一次(区分「未检测到」与「还没扫」)
pub scanned: bool,
/// 扫描进度
pub scanning: Option<Progress>,
/// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫)
pub cancelled: bool,
/// 扫描状态槽(快照 + 取消位;线程与界面共享同一份)
pub scan: super::store::ScanSlot,
/// 用户手动加的路径(原样,持久化在 `manual_file`)
pub manual: Vec<String>,
/// `java.selected_path` 的真值(空 = 没指定,启动时自动挑)
pub selected: String,
/// 内存草稿(MB 文本)
pub memory: String,
/// 落盘的内存真值(MB)
pub saved_memory: u32,
/// JVM 参数草稿
pub jvm: String,
/// 落盘的 JVM 参数真值
pub saved_jvm: String,
/// 手动路径输入框
pub add_draft: String,
/// 有未保存的改动
pub dirty: bool,
/// 这一次运行有没有可写的设置文件
pub storage: bool,
/// 上一次操作的回执
pub notice: Option<Notice>,
/// 当前实例的上下文(版本门槛 / 生效内存层级)
pub context: Context,
}
impl Default for State {
fn default() -> Self {
Self {
runtimes: Vec::new(),
scanned: false,
scanning: None,
cancelled: false,
scan: super::store::ScanSlot::default(),
manual: Vec::new(),
selected: String::new(),
memory: String::new(),
saved_memory: DEFAULT_MEMORY_MB,
jvm: String::new(),
saved_jvm: String::new(),
add_draft: String::new(),
dirty: false,
storage: false,
notice: None,
context: Context::default(),
}
}
}
/// 从 `launcher.json` 读回来的三键真值。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
/// `java.selected_path`
pub selected: String,
/// `java.default_memory_mb`
pub memory_mb: u32,
/// `java.extra_args`
pub jvm: String,
}
impl State {
/// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。
///
/// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
self.storage = true;
let Snapshot {
selected,
memory_mb,
jvm,
} = snapshot;
let changed =
self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm;
self.selected = selected;
self.saved_memory = memory_mb;
if !self.dirty {
self.memory = memory_mb.to_string();
self.jvm = jvm.clone();
}
self.saved_jvm = jvm;
changed
}
/// 灌一次扫描结论(后台线程的快照)。返回是否变了。
pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool {
let scanning = report.running.then_some(Progress {
done: report.done,
total: report.total,
});
let changed = self.scanning != scanning
|| self.cancelled != report.cancelled
|| (report.finished && !self.scanned)
|| (report.finished && self.runtimes != report.runtimes);
self.scanning = scanning;
self.cancelled = report.cancelled;
if report.finished {
self.scanned = true;
self.runtimes = report.runtimes.clone();
}
changed
}
/// 某个大版本够不够当前实例用。
#[must_use]
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
return None;
};
Some(required.satisfied_by(major))
}
/// 保存前的本地校验(过不了就不写盘)。
///
/// # Errors
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
pub fn validate(&self) -> Result<u32, Refusal> {
let text = self.memory.trim();
if text.is_empty() {
return Err(Refusal::MemoryEmpty);
}
let Ok(mb) = text.parse::<u32>() else {
return Err(Refusal::MemoryNotNumber);
};
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) {
return Err(Refusal::MemoryRange);
}
if unbalanced_quotes(&self.jvm) {
return Err(Refusal::JvmQuotes);
}
Ok(mb)
}
/// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。
#[must_use]
pub fn layer(&self) -> Layer {
self.context.layer.clone().unwrap_or(Layer::Global)
}
/// 现在能不能点保存。
#[must_use]
pub fn can_save(&self) -> bool {
self.storage && self.dirty
}
/// 当前默认运行时那一行(`selected` 指向的)。
#[must_use]
pub fn default_row(&self) -> Option<&RuntimeRow> {
self.runtimes
.iter()
.find(|row| same_path(&row.binary, &self.selected))
}
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。
///
/// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、
/// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn screenshot_seed(raw: &str) -> Option<Self> {
super::seed::build(raw)
}
}
/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。
#[must_use]
pub fn same_path(left: &str, right: &str) -> bool {
let key = |text: &str| {
let text = text.replace('\\', "/");
if cfg!(windows) {
text.to_ascii_lowercase()
} else {
text
}
};
!left.trim().is_empty() && key(left.trim()) == key(right.trim())
}
/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。
#[must_use]
pub fn unbalanced_quotes(text: &str) -> bool {
text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1
}
@@ -0,0 +1,393 @@
//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。
//!
//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路
//!
//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是
//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。
//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档
//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。
//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**,
//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。
//!
//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform`
//!
//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`,
//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较,
//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs`
//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。
//!
//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5)
//!
//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的
//! `run_with_timeout` kill 掉,扫描线程不会永远不结束;
//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束;
//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」,
//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」
//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。
//!
//! ## 手动名单为什么是纯文本文件
//!
//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。
//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读),
//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use unlucky_core::game::java::{discover, manual, staged, JavaRuntime};
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
/// 扫描结果快照(后台线程写、界面读)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanReport {
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
pub running: bool,
/// 已探测
pub done: usize,
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
pub total: usize,
/// 至少完整扫完过一次
pub finished: bool,
/// 用户按过取消(线程还活着时是 `true`)
pub cancelled: bool,
/// 扫描结论(`finished` 且没被取消时有效)
pub runtimes: Vec<RuntimeRow>,
}
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
pub type Job =
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
///
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
///
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
#[derive(Debug, Clone, Default)]
pub struct ScanSlot {
inner: std::sync::Arc<ScanInner>,
}
#[derive(Debug, Default)]
struct ScanInner {
report: Mutex<ScanReport>,
cancel: AtomicBool,
}
impl ScanSlot {
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
#[must_use]
pub fn report(&self) -> ScanReport {
self.inner
.report
.lock()
.map(|report| report.clone())
.unwrap_or_default()
}
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
///
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
pub fn cancel(&self) {
self.inner.cancel.store(true, Ordering::SeqCst);
if let Ok(mut report) = self.inner.report.lock() {
if report.running {
report.cancelled = true;
}
}
}
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
self.start_with(roots, manual, system, run_staged)
}
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
pub fn start_with(
&self,
roots: Vec<PathBuf>,
manual: Vec<PathBuf>,
system: bool,
job: Job,
) -> bool {
{
let Ok(mut report) = self.inner.report.lock() else {
return false;
};
if report.running {
return false;
}
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
*report = ScanReport {
running: true,
..ScanReport::default()
};
self.inner.cancel.store(false, Ordering::SeqCst);
}
let slot = self.clone();
std::thread::spawn(move || {
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
let candidates = staged::candidates(&roots, &manual, system);
if let Ok(mut report) = slot.inner.report.lock() {
report.total = candidates.len();
}
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
let mut progress = |done: usize, _total: usize| {
if let Ok(mut report) = slot.inner.report.lock() {
report.done = done;
}
};
let runtimes = job(&candidates, &cancelled, &mut progress);
if let Ok(mut report) = slot.inner.report.lock() {
report.running = false;
report.done = candidates.len();
if !report.cancelled {
report.finished = true;
report.runtimes = runtimes;
}
}
});
true
}
}
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
fn run_staged(
candidates: &[staged::Candidate],
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
let found = staged::scan(
candidates,
|done, total| {
progress(done, total);
true
},
cancelled,
);
rows(&found, candidates)
}
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
found
.iter()
.map(|runtime| RuntimeRow {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: candidates
.iter()
.find(|candidate| candidate.binary == runtime.binary)
.map_or("system", |candidate| candidate.source.id()),
})
.collect()
}
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
///
/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么);
/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是
/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次
/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够——
/// 用户手动点「立即扫描」走的是 `start`,不看这里)。
///
/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关:
/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。
#[must_use]
pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool {
!screenshot && !report.running && !report.cancelled && !report.finished
}
/// 托管安装根(`<launcher home>/data/java-managed` + `java.installRoot` + 标准落点)。
///
/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式
/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。
#[must_use]
pub fn managed_roots(config: &Path) -> Vec<PathBuf> {
let Some(home) = config.parent().and_then(Path::parent) else {
return Vec::new();
};
let layout = unlucky_core::platform::layout::LauncherLayout::new(home);
let override_root = SettingStore::load(config, launcher_schema())
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
unlucky_core::game::java::location::scan_roots(
unlucky_core::platform::data_dir::DataMode::Portable,
&layout,
override_root.as_deref(),
Platform::current(),
&ProcessEnv,
)
}
/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。
#[must_use]
pub fn manual_file(config: &Path) -> PathBuf {
config.parent().map_or_else(
|| PathBuf::from("java-extra-paths.txt"),
|dir| dir.join("java-extra-paths.txt"),
)
}
/// 读三键真值。
#[must_use]
pub fn load(config: &Path) -> Snapshot {
snapshot(SettingStore::load(config, launcher_schema()).doc())
}
/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteError {
/// 页面自己的校验拒绝(i18n key)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key)
Unwritable(Unwritable),
/// schema / 磁盘给的原始句子(core 的文案,不属于本页)
Schema(String),
}
/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。
///
/// # Errors
/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`])
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
pub fn save(config: &Path, state: &State) -> Result<Snapshot, WriteError> {
let mb = state.validate().map_err(WriteError::Refused)?;
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.default_memory_mb", i64::from(mb).into())
.and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into()))
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。
///
/// # Errors
/// 文件只读/不可信或 schema 拒绝时返回原因。
pub fn set_selected(config: &Path, binary: &str) -> Result<Snapshot, WriteError> {
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.selected_path", binary.to_string().into())
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。
fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError> {
match store.save() {
Ok(SaveOutcome::Saved(_)) => Ok(load(config)),
Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))),
Err(error) => Err(WriteError::Schema(format!("{error}"))),
}
}
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
use unlucky_core::setting::store::SkipReason;
match reason {
SkipReason::NotDirty => Unwritable::NotDirty,
SkipReason::ReadOnly => Unwritable::ReadOnly,
SkipReason::Unreadable => Unwritable::Unreadable,
}
}
/// 文档 → 快照。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
Snapshot {
selected: doc
.effective_str("java.selected_path")
.unwrap_or_default()
.trim()
.to_string(),
memory_mb: doc
.effective_i64("java.default_memory_mb")
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(super::state::DEFAULT_MEMORY_MB),
jvm: doc
.effective_str("java.extra_args")
.unwrap_or_default()
.to_string(),
}
}
/// 读手动名单。
#[must_use]
pub fn load_manual(config: &Path) -> Vec<String> {
manual::load_paths(&manual_file(config))
.iter()
.map(|path| path.display().to_string())
.collect()
}
/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。
///
/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。
///
/// # Errors
/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。
pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> {
add_manual_with(config, input, &manual::probe_manual)
}
/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。
///
/// # Errors
/// 探测被拒或名单写盘失败时返回原因。
pub fn add_manual_with(
config: &Path,
input: &str,
probe: &dyn Fn(&str) -> Result<JavaRuntime, manual::ManualFailure>,
) -> Result<(String, RuntimeRow), Refusal> {
let runtime = probe(input.trim()).map_err(failure_refusal)?;
let binary = runtime.binary.display().to_string();
let mut paths = manual::load_paths(&manual_file(config));
let key = discover::path_key(&runtime.binary);
if paths.iter().any(|known| discover::path_key(known) == key) {
return Err(Refusal::AddDuplicate);
}
paths.push(runtime.binary.clone());
manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?;
Ok((
binary.clone(),
RuntimeRow {
binary,
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: "manual",
},
))
}
/// `core` 的拒绝原因 → 页面原因。
fn failure_refusal(failure: manual::ManualFailure) -> Refusal {
match failure {
manual::ManualFailure::Empty => Refusal::AddEmpty,
manual::ManualFailure::TooLong => Refusal::AddTooLong,
manual::ManualFailure::Missing => Refusal::AddMissing,
manual::ManualFailure::NotJava => Refusal::AddNotJava,
manual::ManualFailure::NoVersion => Refusal::AddNoVersion,
}
}
/// 设置文件路径(数据根解析不出来时没有)。
#[must_use]
pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option<PathBuf> {
bridge.path().map(Path::to_path_buf)
}
@@ -0,0 +1,242 @@
//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。
//!
//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫:
//!
//! | 守卫 | 断言 | 改回什么会红 |
//! |---|---|---|
//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 |
//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 |
//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 |
//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 |
//!
//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本),
//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)——
//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。
#[path = "tests/cmdline.rs"]
mod cmdline;
use std::path::{Path, PathBuf};
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::store;
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
pub(super) fn root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-page-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config");
dir
}
pub(super) fn config(dir: &Path) -> PathBuf {
dir.join("config").join("launcher.json")
}
/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。
pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf {
let jdk = dir.join(name);
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java");
jdk
}
pub(super) fn java_bin() -> &'static str {
if cfg!(windows) {
"java.exe"
} else {
"java"
}
}
// --------------------------------------------------------------- 守卫①:列表来自真扫盘
/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。
///
/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。
#[test]
fn the_runtime_list_comes_from_the_disk() {
let dir = root("scan");
let one = fake_jdk(&dir, "jdk-a");
let two = fake_jdk(&dir, "jdk-b");
let found =
unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false);
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
std::fs::remove_dir_all(&one).expect("删 jdk-a");
let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false);
assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}");
std::fs::remove_dir_all(&dir).ok();
}
/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。
#[test]
fn applying_a_scan_report_replaces_every_row() {
let mut state = State::default();
let report = store::ScanReport {
running: false,
done: 2,
total: 2,
finished: true,
cancelled: false,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-a/bin/java.exe".to_string(),
version: String::new(),
major: None,
vendor: None,
source: "manual",
},
RuntimeRow {
binary: "C:/jdk-b/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: Some("Temurin".to_string()),
source: "system",
},
],
};
assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」");
assert_eq!(state.runtimes.len(), 2);
assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开");
assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin");
}
// --------------------------------------------------------------- 守卫②:非法路径不污染设置
/// 非法路径被拒、**设置与名单一个字节都不写**。
///
/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`),
/// 「不存在的路径」就会被加进名单 ⇒ 红。
#[test]
fn an_invalid_manual_path_is_refused_and_writes_nothing() {
let dir = root("manual");
let file = config(&dir);
assert_eq!(
store::add_manual(&file, "").err(),
Some(Refusal::AddEmpty),
"空输入要被拒"
);
assert_eq!(
store::add_manual(&file, "Z:/definitely/not/here").err(),
Some(Refusal::AddMissing),
"不存在的路径要被拒"
);
let empty = dir.join("empty-jdk");
std::fs::create_dir_all(&empty).expect("空目录");
assert_eq!(
store::add_manual(&file, &empty.display().to_string()).err(),
Some(Refusal::AddNotJava),
"目录里没有 bin/java 要被拒"
);
let broken = fake_jdk(&dir, "broken-jdk");
assert_eq!(
store::add_manual(&file, &broken.display().to_string()).err(),
Some(Refusal::AddNoVersion),
"跑不出 java -version 要被拒"
);
assert!(
store::load_manual(&file).is_empty(),
"被拒的路径不许进名单:{:?}",
store::load_manual(&file)
);
let snapshot = store::load(&file);
assert!(
snapshot.selected.is_empty(),
"被拒的路径不许污染 java.selected_path:{snapshot:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫③:内存越界被拒
/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。
///
/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红;
/// 把 `parse::<u32>` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。
#[test]
fn the_memory_bounds_are_enforced_before_writing() {
let mut state = State {
storage: true,
..State::default()
};
state.memory = (MIN_MEMORY_MB - 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"低于下限"
);
state.memory = (MAX_MEMORY_MB + 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"高于上限"
);
state.memory = "abc".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryNotNumber),
"非数字"
);
state.memory = " ".to_string();
assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空");
state.memory = "4096".to_string();
assert!(state.validate().is_ok());
state.jvm = "-XX:+UseG1GC".to_string();
assert!(state.validate().is_ok());
state.jvm = "-Xss\"512k".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::JvmQuotes),
"引号没配对"
);
// 真的不写盘:一次越界保存之后,文件里还是出厂默认值。
let dir = root("memory");
let file = config(&dir);
let state = State {
storage: true,
memory: "128".to_string(),
..State::default()
};
assert!(store::save(&file, &state).is_err(), "越界必须写不进去");
assert_eq!(
store::load(&file).memory_mb,
super::state::DEFAULT_MEMORY_MB,
"被拒的保存不许落盘"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。
///
/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。
#[test]
fn the_memory_bounds_mirror_the_schema() {
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
let schema = launcher_schema();
let kind = schema
.spec("java.default_memory_mb")
.map(|spec| &spec.kind)
.expect("schema 里必须有 java.default_memory_mb");
let ValueKind::Int { min, max } = kind else {
panic!("java.default_memory_mb 应该是 Int:{kind:?}");
};
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
}
#[path = "tests/runtime.rs"]
mod runtime;
@@ -0,0 +1,262 @@
//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。
//!
//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」
//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。
//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。
use std::path::Path;
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
use super::super::store;
use super::{config, fake_jdk, root};
// --------------------------------------------------------------- 守卫④:内存真进命令行
/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。
///
/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve`
/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。
/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。
///
/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048),
/// 这条立刻红(argv 里还是 `-Xmx2048m`)。
#[test]
fn the_saved_memory_reaches_the_real_command_line() {
let dir = root("cmdline");
let file = config(&dir);
let state = State {
storage: true,
memory: "6144".to_string(),
jvm: "-Dfile.encoding=UTF-8".to_string(),
..State::default()
};
assert!(state.validate().is_ok());
let saved = store::save(&file, &state).expect("保存应成功");
assert_eq!(saved.memory_mb, 6144, "落盘后读回的值");
assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8");
let args = real_argv(&file);
assert!(
args.iter().any(|arg| arg == "-Xmx6144m"),
"内存字段必须长成 -Xmx6144m:{args:?}"
);
assert!(
args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"),
"java.extra_args 必须进命令行:{args:?}"
);
assert!(
!args.iter().any(|arg| arg == "-Xmx2048m"),
"出厂默认值不许还在里面:{args:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。
fn real_argv(config: &Path) -> Vec<String> {
use std::path::PathBuf;
use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults};
use unlucky_core::launch::args_table::assemble;
use unlucky_core::launch::overrides;
use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
use unlucky_core::launch::request::{
InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions,
};
use unlucky_core::launch::request::{
LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions,
};
let snapshot = store::load(config);
let resolved = resolve(
None,
&GlobalLaunchDefaults {
jvm_args: Some(snapshot.jvm.clone()),
memory_mb: Some(snapshot.memory_mb),
..GlobalLaunchDefaults::default()
},
);
let mut request = LaunchRequest {
instance: InstanceInfo {
id: "inst-1".to_string(),
name: "1.20.1".to_string(),
version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"),
version_type: "release".to_string(),
},
launcher: LauncherInfo {
name: "UnluckyLauncher".to_string(),
version: "0.2.3".to_string(),
},
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
memory: MemoryOptions {
min_mb: None,
max_mb: None,
},
manifest: ManifestArgs {
main_class: Some("net.minecraft.client.main.Main".to_string()),
..ManifestArgs::default()
},
dirs: LaunchDirs {
game_dir: PathBuf::from("C:/mc"),
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
..LaunchDirs::default()
},
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
log4j: Log4jOptions {
enabled: false,
..Log4jOptions::default()
},
optimization: OptimizationOptions {
enabled: false,
physical_memory_mb: None,
},
language: "zh-CN".to_string(),
..LaunchRequest::default()
};
overrides::apply(&mut request, &resolved);
assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64"))
.expect("组装应成功")
.args
}
// --------------------------------------------------------------- 落盘 / 读回 / 门槛
/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。
#[test]
fn the_settings_survive_a_restart() {
let dir = root("restart");
let file = config(&dir);
let state = State {
storage: true,
memory: "8192".to_string(),
jvm: "-XX:+UseG1GC".to_string(),
..State::default()
};
store::save(&file, &state).expect("保存");
let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认");
// 「重启」:全新的 State 从盘上灌一次。
let mut reborn = State::default();
assert!(reborn.hydrate(store::load(&file)));
assert_eq!(reborn.memory, "8192");
assert_eq!(reborn.saved_memory, 8192);
assert_eq!(reborn.jvm, "-XX:+UseG1GC");
assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe");
assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe");
// 手动名单也在(与设置同一个 config/ 目录下的纯文本)。
let jdk = fake_jdk(&dir, "keep-me");
std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单");
assert_eq!(store::load_manual(&file).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。
#[test]
fn the_requirement_explains_why_a_runtime_fits_or_not() {
let mut state = State {
scanned: true,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-8/bin/java.exe".to_string(),
version: String::new(),
major: Some(8),
vendor: None,
source: "system",
},
RuntimeRow {
binary: "C:/jdk-21/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: None,
source: "system",
},
],
selected: "C:/jdk-8/bin/java.exe".to_string(),
..State::default()
};
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
state.context.required = Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
});
assert_eq!(state.satisfied(Some(8)), Some(false));
assert_eq!(state.satisfied(Some(21)), Some(true));
assert_eq!(
state.default_row().map(|row| row.binary.clone()),
Some("C:/jdk-8/bin/java.exe".to_string()),
"默认那一行按 selected 找出来"
);
state.context.layer = Some(Layer::Instance {
instance: "生存服".to_string(),
mb: 8192,
});
assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. }));
}
/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。
#[test]
fn the_screenshot_seeds_cover_the_three_states() {
let empty = State::screenshot_seed("empty").expect("empty 档");
assert!(empty.runtimes.is_empty() && empty.scanned);
let two = State::screenshot_seed("two").expect("two 档");
assert_eq!(two.runtimes.len(), 2);
assert!(two.default_row().is_some(), "两个 JDK 里要选中其一");
let editing = State::screenshot_seed("editing").expect("editing 档");
assert!(editing.dirty, "编辑态要有未保存改动");
assert!(matches!(editing.notice, Some(Notice::Refused(_))));
assert!(State::screenshot_seed("nonsense").is_none());
}
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
///
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。
#[test]
fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() {
let fresh = store::ScanReport::default();
assert!(
store::auto_scan_due(&fresh, false),
"首屏(没扫过、没在扫)应该自动扫一次"
);
assert!(
!store::auto_scan_due(&fresh, true),
"截图通道不许读本机装了什么"
);
let running = store::ScanReport {
running: true,
..store::ScanReport::default()
};
assert!(!store::auto_scan_due(&running, false), "正在扫就不重入");
let finished = store::ScanReport {
finished: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&finished, false),
"扫过一次就不再自动扫(手动「立即扫描」仍可再扫)"
);
// 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则
// `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。
let cancelled = store::ScanReport {
cancelled: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&cancelled, false),
"取消过一次之后,本进程不再自动扫描"
);
// 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。
assert!(
store::auto_scan_due(&store::ScanReport::default(), false),
"全新进程(没扫过、没取消)仍然自动扫一次"
);
}
@@ -0,0 +1,318 @@
//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。
//!
//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与
//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。
use super::super::state::{Refusal, RuntimeRow, State, Unwritable};
use super::super::store;
use super::{config, fake_jdk, java_bin, root};
// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3)
/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。
///
/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK;
/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条
/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。
///
/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红
/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。
#[test]
fn a_manual_path_is_written_to_disk_and_survives_a_restart() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-ok");
let file = config(&dir);
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let runtime = JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()),
major: Some(21),
bits: 64,
vendor: Some("Temurin".to_string()),
};
let probe = |_input: &str| Ok(runtime.clone());
// 刚加之前:盘上什么都没有。
assert!(store::load_manual(&file).is_empty());
let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe)
.expect("合法路径必须加得进去");
assert_eq!(added, binary.display().to_string());
assert_eq!(row.major, Some(21));
// **从盘上新读一份**(不是看内存里的 vec)。
let listed = store::load_manual(&file);
assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}");
assert_eq!(listed[0], binary.display().to_string());
assert!(
store::manual_file(&file).is_file(),
"名单文件必须真的存在:{:?}",
store::manual_file(&file)
);
// 「重启」:一个全新的 State 从盘上灌,名单还在。
let reborn = State {
manual: store::load_manual(&file),
..State::default()
};
assert_eq!(reborn.manual.len(), 1);
// 再加同一份 ⇒ 重复(去重也走盘上的名单)。
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddDuplicate)
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5)
static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。
fn blocking_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
use std::sync::atomic::Ordering;
let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1;
JOB_PEAK.fetch_max(live, Ordering::SeqCst);
while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
JOB_LIVE.fetch_sub(1, Ordering::SeqCst);
Vec::new()
}
/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。
///
/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次
/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行
/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。
#[test]
fn only_one_scan_thread_can_run_and_cancel_finishes_it() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
JOB_RELEASE.store(false, Ordering::SeqCst);
JOB_PEAK.store(0, Ordering::SeqCst);
let slot = store::ScanSlot::default();
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"第一次扫描应该能起来"
);
assert!(
!slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"扫描进行中不许起第二个线程"
);
assert!(slot.report().running, "线程活着时 running 必须为真");
slot.cancel();
assert!(slot.report().cancelled, "取消之后要标记 cancelled");
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!slot.report().running,
"取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)"
);
assert!(!slot.report().finished, "被取消的扫描不许把结论当真");
assert_eq!(
JOB_PEAK.load(Ordering::SeqCst),
1,
"任何时刻只有一个扫描线程"
);
// 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。
JOB_RELEASE.store(true, Ordering::SeqCst);
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"上一个线程退出之后应该能重新扫"
);
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(slot.report().finished, "这次应该正常扫完");
JOB_RELEASE.store(false, Ordering::SeqCst);
}
// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2)
/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的
/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。
///
/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回
/// `store.rs`(源码扫描那一段)。
/// 抓不住:译文翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红;
/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。
#[test]
fn the_unwritable_reasons_have_distinct_text() {
use unlucky_i18n::{Key, Lang};
let en = Lang::En.catalog();
let zh = Lang::ZhCn.catalog();
let reasons = [
Unwritable::NotDirty,
Unwritable::ReadOnly,
Unwritable::Unreadable,
];
for reason in reasons {
assert_ne!(
en.text(reason.label()),
zh.text(reason.label()),
"{} 未翻译",
reason.label().name()
);
}
for (index, left) in reasons.iter().enumerate() {
for right in reasons.iter().skip(index + 1) {
assert_ne!(
en.text(left.label()),
en.text(right.label()),
"「写不进去」的两档用了同一句文案:{} / {}",
left.label().name(),
right.label().name()
);
}
}
// 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。
assert_eq!(
Refusal::AddTooLong.label(),
Key::SettingsJavaReasonAddTooLong
);
assert_eq!(
Refusal::AddWriteFailed.label(),
Key::SettingsJavaReasonAddWriteFailed
);
assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label());
assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label());
// 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。
let source = include_str!("../store.rs");
assert!(
!source.contains("format!(\"{refusal:?}\")"),
"不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案"
);
for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] {
// 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。
let literal = format!("\"{hardcoded}\"");
assert!(
!source.contains(&literal),
"store.rs 里还有硬编码中文字符串:{literal}"
);
}
}
/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。
///
/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说
/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。
/// 抓不住:文案翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红;
/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。
#[test]
fn every_manual_failure_maps_to_its_own_reason() {
use unlucky_core::game::java::manual::ManualFailure;
let dir = root("manual-map");
let file = config(&dir);
for (failure, expected) in [
(ManualFailure::Empty, Refusal::AddEmpty),
(ManualFailure::TooLong, Refusal::AddTooLong),
(ManualFailure::Missing, Refusal::AddMissing),
(ManualFailure::NotJava, Refusal::AddNotJava),
(ManualFailure::NoVersion, Refusal::AddNoVersion),
] {
let probe = move |_input: &str| Err(failure);
assert_eq!(
store::add_manual_with(&file, "whatever", &probe).err(),
Some(expected),
"{failure:?} 应该映射到 {expected:?}"
);
}
// 一个都没写进去。
assert!(store::load_manual(&file).is_empty());
std::fs::remove_dir_all(&dir).ok();
}
/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。
#[test]
fn a_failed_list_write_reports_the_write_not_the_path() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-write-fail");
// `<dir>/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。
// (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。)
let blocker = dir.join("config");
std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录");
std::fs::write(&blocker, b"not a directory").expect("写占位文件");
let file = blocker.join("launcher.json");
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let probe = |_input: &str| {
Ok(JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()),
major: Some(17),
bits: 64,
vendor: Some("Temurin".to_string()),
})
};
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddWriteFailed),
"磁盘写不进去要说「写不进名单」,不能说「路径不存在」"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页
/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。
///
/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页
/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings /
/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。
/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。
///
/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。
/// 抓不住:命令行的措辞好不好读(那是人工的事)。
///
/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。
#[test]
fn the_empty_state_points_at_a_real_entry() {
use unlucky_i18n::Lang;
for lang in [Lang::En, Lang::ZhCn] {
let text = lang
.catalog()
.text(unlucky_i18n::Key::SettingsJavaEmptyBody);
assert!(
text.contains("ull java install"),
"{}:空态要给一条真能走的路(命令行)——{text}",
lang.tag()
);
for dead in ["Java page", "Java 页"] {
assert!(
!text.contains(dead),
"{}:空态指向了不存在的入口「{dead}」——{text}",
lang.tag()
);
}
}
}
@@ -0,0 +1,284 @@
//! 「Java」分组的一张卡片(M175)。
//!
//! ## 版面(自上而下)
//!
//! | 行 | 内容 | 数值 / 证据 |
//! |---|---|---|
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
//! | 动作 | 保存 | 灰掉时下面是原因 |
//!
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::Message;
use crate::theme::Ui;
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
use super::state::{Progress, RuntimeRow, State};
use super::Message as JavaMessage;
use crate::pages::settings::{GroupMessage, States};
/// 一条本组消息 → 页面消息。
pub(super) fn msg(message: JavaMessage) -> Message {
Message::Settings(GroupMessage::Java(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(runtime_list(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(add_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(memory_field(ui, catalog, state));
stacked = stacked.push(jvm_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
card(ui, stacked)
}
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
///
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, scanning) = match (state.scanning, state.scanned) {
(Some(_), _) if state.cancelled => {
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
}
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
(Some(Progress { total: 0, .. }), _) => (
catalog.text(Key::SettingsJavaScanningStart).to_string(),
true,
),
(Some(Progress { done, total }), _) => (
catalog.render(
Key::SettingsJavaScanning,
&[
("done", done.to_string().as_str()),
("total", total.to_string().as_str()),
],
),
true,
),
(None, true) if state.runtimes.is_empty() => {
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
}
(None, true) => (
catalog.render(
Key::SettingsJavaScanDone,
&[("count", state.runtimes.len().to_string().as_str())],
),
false,
),
(None, false) => (
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
false,
),
};
let scan_label = catalog.text(Key::SettingsJavaScan);
let scan = if scanning || !state.storage {
buttons::filled(ui, Button::disabled(scan_label))
} else {
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
};
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
let cancel = buttons::tonal(
ui,
if scanning && !state.cancelled {
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
)
} else {
Button::disabled(catalog.text(Key::SettingsJavaCancel))
},
);
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsJavaCancelled).to_string(),
ui.color(Role::OnSurfaceVariant),
)
});
let mut block = column![row![
container(text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(if state.scanning.is_some() {
Role::Primary
} else {
Role::OnSurface
}),
))
.width(Length::Fill),
scan,
cancel,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(line) = cancelled {
block = block.push(line);
}
block.into()
}
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
if state.runtimes.is_empty() {
let body = if state.scanned {
catalog.text(Key::SettingsJavaEmptyBody)
} else {
catalog.text(Key::SettingsJavaNeverScannedBody)
};
return text_role_owned(
ui,
TypeRole::BodySmall,
false,
body.to_string(),
ui.color(Role::OnSurfaceVariant),
);
}
for runtime in &state.runtimes {
list = list.push(runtime_row(ui, catalog, state, runtime));
}
list.into()
}
fn runtime_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
runtime: &'a RuntimeRow,
) -> Element<'a, Message> {
let is_default = super::state::same_path(&runtime.binary, &state.selected);
let satisfied = state.satisfied(runtime.major);
let (verdict, verdict_color) = match satisfied {
Some(true) => (
catalog.text(Key::SettingsJavaFits).to_string(),
ui.color(Role::Primary),
),
Some(false) => {
let required = state
.context
.required
.as_ref()
.map(|required| required.minimum.to_string())
.unwrap_or_default();
(
catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]),
ui.color(Role::Error),
)
}
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
};
let action = if is_default {
chip(
ui,
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
// 「禁用态 chip 上还挂着一条真消息」的残留。
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
)
} else {
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaSetDefault),
false,
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
),
)
};
let mut block = column![row![
container(
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
is_default,
runtime.display_home(),
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!(
"{} · {}",
runtime.summary(),
catalog.text(runtime.source_key())
),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
.width(Length::Fill),
action,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !verdict.is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
verdict,
verdict_color,
));
}
block.into()
}
@@ -17,7 +17,7 @@
//! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key | //! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key |
//! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 | //! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 |
//! //!
//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。 //! 真控件那半边在 `pages/appearance/section/`(`appearance.rs` 只负责把 `body` 挂上去)。
/// 语言 + 外观线的状态(**只归本线**)。 /// 语言 + 外观线的状态(**只归本线**)。
/// ///
+55 -19
View File
@@ -25,10 +25,11 @@
//! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 | //! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 |
//! |---|---|---| //! |---|---|---|
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) | //! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) | //! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) |
//! | Java | [`java`] | java 线 | //! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 | //! | Downloads | [`download`] | download 线 |
//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 | //! | Updates | [`update`] | update 线(M159,按下标分发) |
//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) | //! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//! //!
//! ## 测试也按 section 分(M100) //! ## 测试也按 section 分(M100)
@@ -93,9 +94,11 @@
//! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key` //! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key`
//! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改 //! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改
//! `catalog/settings/mod.rs`(五个域已全部接进装配链)。 //! `catalog/settings/mod.rs`(五个域已全部接进装配链)。
//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。 //! 2. 本文件的 [`GROUPS`] —— 加一整组时加一行(M174 的助手组就是这么来的:`GROUPS` 与
//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;` //! [`GROUP_LABELS`] 各加一行、长度 +1,`tests/mod.rs` 的「左列顺序」随之更新)。
//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。 //! 3. `tests/mod.rs` —— 「左列顺序」那条字面量断言会连带红,需要人确认(M174 就是把它加了一个
//! `"Assistant"`)。加分组自己的测试文件时还要在 `tests/mod.rs` 顶部挂一行 `mod <名>;`;
//! M174 的用例走的是组内路径(`pages/settings/ai/tests.rs`),所以没有新增挂载行。
//! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到 //! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到
//! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置 //! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置
//! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测): //! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测):
@@ -114,7 +117,7 @@
//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, //! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价,
//! 不是永久性四路撞车。 //! 不是永久性四路撞车。
//! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是
//! `fn(&Ui, States) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! `fn(&Ui, States, Lang) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
//! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)`
//! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的 //! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的
//! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派 //! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派
@@ -132,6 +135,7 @@
// 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子 // 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子
// (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。 // (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。
// 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。 // 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。
pub(crate) mod ai;
pub(crate) mod ai_about; pub(crate) mod ai_about;
mod appearance; mod appearance;
pub(crate) mod download; pub(crate) mod download;
@@ -189,13 +193,16 @@ impl Default for Language {
} }
} }
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态), /// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态)
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态), /// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下
/// 由 [`form`] 退回演示行。 /// 没有外观状态),由 [`form`] 退回演示行。
/// ///
/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, /// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定,
/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 /// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。
type Body = for<'a> fn(&'a Ui, States<'a>) -> Option<Element<'a, Message>>; ///
/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个
/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。
type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option<Element<'a, Message>>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
/// ///
@@ -215,7 +222,7 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 /// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
/// ///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 /// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 5] = [ const GROUPS: [Group; 6] = [
general::GROUP, general::GROUP,
appearance::GROUP, appearance::GROUP,
java::GROUP, java::GROUP,
@@ -223,6 +230,8 @@ const GROUPS: [Group; 5] = [
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标 // M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。 // 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP, update::GROUP,
// M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。
ai_about::GROUP,
]; ];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 /// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -230,9 +239,9 @@ const SELECTED: usize = 0;
/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 /// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。
/// ///
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带 /// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带
/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal /// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下:
/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。 /// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。
/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` /// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None`
/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 /// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。
const GENERAL_GROUP: usize = 0; const GENERAL_GROUP: usize = 0;
@@ -241,13 +250,15 @@ const GENERAL_GROUP: usize = 0;
/// ///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— /// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 /// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [
unlucky_i18n::Key::SettingsGroupGeneral, unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance, unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava, unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads, unlucky_i18n::Key::SettingsGroupDownloads,
// M159 // M159
unlucky_i18n::Key::SettingsGroupUpdate, unlucky_i18n::Key::SettingsGroupUpdate,
// M174
unlucky_i18n::Key::SettingsGroupAssistant,
]; ];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。 /// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
@@ -256,6 +267,19 @@ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。 /// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP; const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:由 `view` 的 `language` 形参递到 [`form`]
/// 按本下标分发,`GROUP.body` 保持 `None`(本组**尚未**迁到 [`Body`] 槽位:槽位现在带 `Lang` 了,
/// 迁它要把 `ai::view::body` 的返回值改成 `Option<Element>`,是一次独立重构——M179 不做)。
const AI_GROUP: usize = ai_about::AI_GROUP;
/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。
///
/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照
/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。
const JAVA_GROUP: usize = java::JAVA_GROUP;
/// 整页。 /// 整页。
/// ///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -292,9 +316,15 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
/// ///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 /// **分发点(两条路,M106 起就并存)**:
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 ///
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 /// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言**
/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]);
/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang`
/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body`
/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。
///
/// 除此之外这里不认识任何一组的字段:分组名只作为 `GROUPS` 元素的 id 出现。
fn form<'a>( fn form<'a>(
ui: &'a Ui, ui: &'a Ui,
only: Option<usize>, only: Option<usize>,
@@ -321,10 +351,16 @@ fn form<'a>(
} else if index == UPDATE_GROUP { } else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。 // M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective) update::body(ui, update, language.effective)
} else if index == AI_GROUP {
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
ai::view::body(ui, states, language.effective)
} else if index == JAVA_GROUP {
// M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。
java::view::body(ui, states, language.effective)
} else { } else {
group group
.body .body
.and_then(|body| body(ui, states)) .and_then(|body| body(ui, states, language.effective))
.unwrap_or_else(|| frame::demo_card(ui, group.rows)) .unwrap_or_else(|| frame::demo_card(ui, group.rows))
}; };
@@ -2,12 +2,25 @@
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//! //!
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 //! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 //! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。
//! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除** //! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除**
//! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。 //! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。
//!
//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。
use super::super::appearance; use super::super::appearance;
use super::super::{demo_groups, view, Language, States};
use unlucky_design::color::SchemeFlavor;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::{Lang, LangChoice};
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use crate::cli::Backend;
use crate::pages::appearance::{probe, State as AppearanceState};
use crate::pages::layout::{frame_metrics, PaneKit};
use crate::pages::Update;
use crate::theme::{Families, Ui};
/// 这一组的**演示行**(没有外观运行期状态时才画)。 /// 这一组的**演示行**(没有外观运行期状态时才画)。
/// ///
@@ -50,3 +63,109 @@ fn the_appearance_group_keeps_a_real_control_body() {
`body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)" `body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)"
); );
} }
// --------------------------------------------------------------- 语言(M179 I18NSEAM)
/// **本组的语言绊线**:中文界面下,外观分组渲染出来的字里**必须含汉字**。
///
/// 走的是真路:`settings::view`(真语言分发)→ `Group.body(lang)` → `pages::appearance::section`
/// → `section::t(key, lang)`;取词口 `t` 把每条译文记进 `pages::appearance::probe`,测试断言这批字。
///
/// 为什么不在 `Element` 上遍历:iced 0.14 **没有**可遍历控件树的公开接口(`Widget::children()`
/// 返回的是状态 `Tree`;走 `operate` 要 `Tree` + 布局 + `Renderer`,单测里没有渲染器)。取证点
/// 因此退到「字进树之前」的唯一取词口,代价是**看不见像素**——字体能不能画出汉字要截图判
/// (M179 报告「没验的部分」列了这条)。
///
/// 抓得住:① `section::t` 退回写死 `Lang::En`(M179 之前正是这一行 ⇒ 中文界面上这一组 0/27
/// 条本地化);② `settings::view` 不把 `language.effective` 递给 `Group.body`(语言到不了渲染层);
/// ③ 五个 key 的译文在金表里丢了中文。
/// 抓不住:本组其余文案仍是英文**字面量**(没有 key,M172 口径的 27 条里占 22 条)——这条守的是
/// 语言的**管子**,不是文案;补文案是下一条 mission(M179 报告逐行列了那些字面量)。
#[test]
fn the_appearance_group_speaks_chinese_when_the_interface_does() {
let zh = appearance_texts(Lang::ZhCn);
assert!(
!zh.is_empty(),
"外观分组一条词表文案都没取到:渲染路径没走到 `section::t`(语言管子断了?)"
);
assert!(
zh.iter()
.any(|(key, _)| *key == Key::SettingsAppearanceCorner),
"渲染路径没画到「窗口圆角」那一行:取词记录里没有它的 key,{zh:?}"
);
let han: Vec<&str> = zh
.iter()
.map(|(_, text)| *text)
.filter(|text| text.chars().any(is_han))
.collect();
assert!(
!han.is_empty(),
"中文界面下外观分组渲染出来的字全是非汉字(`section::t` 没跟随语言):{zh:?}"
);
// 反向:英文界面下同一批字里不许出现汉字(否则「含汉字」可能来自写死的中文,而不是语言管子)。
let en = appearance_texts(Lang::En);
assert!(
en.iter().all(|(_, text)| !text.chars().any(is_han)),
"英文界面下外观分组出现了汉字:{en:?}"
);
let corner = |texts: &[(Key, &'static str)]| {
texts
.iter()
.find(|(key, _)| *key == Key::SettingsAppearanceCorner)
.map(|(_, text)| *text)
};
assert_ne!(
corner(&zh),
corner(&en),
"同一个 key 在中英两种语言下取到了同一条译文:语言没生效"
);
}
/// 画一帧设置页(选中「外观」分组)并取走这一帧从词表取到的全部译文。
fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
let state = AppearanceState::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
Backend::Auto,
SchemeFlavor::Light,
);
assert_eq!(
state.selected_group,
crate::pages::appearance::APPEARANCE_GROUP,
"工厂态默认选中「外观」分组(否则这一帧根本画不到本组)"
);
// 前面几条用例不取词,这里先清一次,避免把别的渲染留下的记录算进来。
probe::take();
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
let update = Update::default();
let _tree = view(
&ui,
metrics,
States {
appearance: Some(&state),
groups: demo_groups(),
},
Language {
choice: LangChoice::System,
effective: lang,
},
&update,
);
probe::take()
}
/// 有没有汉字(判据与 `app/view/tests/launch.rs` 的 `is_cjk` 同一段码位)。
fn is_han(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
@@ -8,8 +8,8 @@
//! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。 //! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。
//! //!
//! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP` //! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP`
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`, //! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是
//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的 //! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的
//! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。 //! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。
use super::super::general; use super::super::general;
@@ -35,6 +35,82 @@ fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
); );
} }
/// **M168(D28 后半)**:中文字体下载状态行的四种状态都得有话说,且两套目录都不许 `⟪missing⟫`。
///
/// 抓得住:①只画「下载成功」一种(失败的用户会以为程序坏了);②失败态的文案写成「保持英文」
/// (D28 的实际行为是**继续用系统字体**,界面语言不变);③占位符没被替换(用户会看到 `{family}`);
/// ④新 key 只进了英文表(中文界面上出现 `⟪missing⟫`)。
/// 抓不住:真下载时的样子(那要联网)、字体有没有真的出字(截图 + 真机,见报告)。
#[test]
fn the_font_status_row_speaks_in_four_states_and_two_languages() {
use crate::fonts::install::{CjkInstall, FetchClass};
let states = [
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
CjkInstall::Failed {
class: FetchClass::Network,
message: "timeout".to_owned(),
},
CjkInstall::System {
family: "Microsoft YaHei".to_owned(),
},
CjkInstall::NotNeeded,
CjkInstall::Unprobeable,
CjkInstall::Unknown,
];
for lang in Lang::ALL {
let catalog = lang.catalog();
assert!(
catalog.get(Key::FontCjkStatusLabel).is_some(),
"{} 目录里缺中文字体那一行的标签",
lang.tag()
);
for state in &states {
let (value, hint) = general::font_status(&catalog, state);
assert!(!value.is_empty(), "{state:?} 没话说");
assert!(!value.contains('{'), "{state:?} 占位符没替换:{value}");
assert!(
!value.contains(unlucky_i18n::catalog::MISSING),
"{state:?} 在 {} 目录里缺译:{value}",
lang.tag()
);
if let Some(hint) = hint {
assert!(!hint.contains('{'), "{state:?} 的提示没替换:{hint}");
}
}
}
// 失败态必须说清三类原因之一(而不是一句「失败了」)。
let zh = Lang::ZhCn.catalog();
for (class, reason) in [
(FetchClass::Network, "网络不通"),
(FetchClass::Integrity, "镜像内容校验不通过"),
(FetchClass::Disk, "写不进数据目录"),
] {
let (value, _) = general::font_status(
&zh,
&CjkInstall::Failed {
class,
message: "x".to_owned(),
},
);
assert!(value.contains(reason), "失败原因没进文案:{value}");
}
// 下载中写的是**真实**字节数(17 772 300 B → 16.9 MiB),不是假百分比。
let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 });
assert!(
value.contains("16.9 MB"),
"17 772 300 B ≈ 16.9 MiB:{value}"
);
assert!(!value.contains('%'), "没有真进度就不许写百分比:{value}");
}
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。 /// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
#[test] #[test]
fn the_general_group_keeps_its_icon() { fn the_general_group_keeps_its_icon() {
@@ -111,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() {
/// **绊线**:这一组仍不走 `Group.body`。 /// **绊线**:这一组仍不走 `Group.body`。
/// ///
/// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发), /// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发),
/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。 /// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。 /// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。
#[test] #[test]
fn the_general_group_does_not_use_the_appearance_body_slot() { fn the_general_group_does_not_use_the_appearance_body_slot() {
assert!( assert!(
@@ -1,33 +1,30 @@
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有, //! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
//! //!
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线, //! M101 的三条里有两条在 M175 换掉了:
//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明):
//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器);
//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。
//! //!
//! download / general 同型。 //! | 旧断言 | M175 之后 |
//! |---|---|
//! | `the_java_group_shows_runtime_memory_and_jvm_arguments`(逐字钉住 `Temurin 21.0.4` / `4 GiB`) | 换成「演示行必须是空的」——那三行假数据正是用户实测投诉的源头 |
//! | `the_java_group_is_still_demo_rows`(断言 `body.is_none()`) | 保留(`body` 仍是 `None`:本组走按下标分发,见 `settings/java.rs` 的文件头) |
//! | `the_java_group_keeps_the_folder_icon_from_the_visual_review` | 不动 |
//! //!
//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。 //! 真数据源的断言在 `pages/settings/java/tests.rs`(扫描 / 校验 / 落盘 / 真命令行),
//! 这里只守「左列那一格」——两处的分工与 M100 的 section 划分一致。
use super::super::java; use super::super::java;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 /// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。
/// ///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); /// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。 /// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。 /// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。
#[test] #[test]
fn the_java_group_shows_runtime_memory_and_jvm_arguments() { fn the_java_group_has_no_demo_rows_left() {
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec(); assert!(
assert_eq!( java::GROUP.rows.is_empty(),
rows, "Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}",
vec![ java::GROUP.rows
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
]
); );
} }
@@ -41,17 +38,15 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
assert_eq!(java::GROUP.icon, IconKind::Folder); assert_eq!(java::GROUP.icon, IconKind::Folder);
} }
/// **绊线**:这一组还没接真控件,画的是演示行。 /// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。
/// ///
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 /// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」
/// 抓不住:界面上真控件长什么样。 /// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。
/// /// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
#[test] #[test]
fn the_java_group_is_still_demo_rows() { fn the_java_group_is_dispatched_by_index_not_by_body() {
assert!( assert!(
java::GROUP.body.is_none(), java::GROUP.body.is_none(),
"Java 还没接真控件:接了就改本断言" "Java 组走按下标分发(要 Lang),body 必须保持 None"
); );
} }
@@ -40,6 +40,7 @@ mod appearance_section;
mod download_section; mod download_section;
mod general_section; mod general_section;
mod java_section; mod java_section;
mod scroll_fit;
mod update_section; mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH}; use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
@@ -87,18 +88,30 @@ fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
// --------------------------------------------------------------- 注册表(四组一起看) // --------------------------------------------------------------- 注册表(四组一起看)
/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。 /// 守的是**注册表的结构不变量**:每组都有名字、图标,且「有真控件分发」或「有演示行」至少占一样。
/// ///
/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组, /// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 rows、又没有分发」
/// 而「新增一组忘了填 rows」正是这条要抓的。 /// 正是这条要抓的。
///
/// M175 起 Java 组的 `rows` 是**空**的:它那三行演示值(`Temurin 21.0.4` / `4 GiB` /
/// `Automatic`)正是用户实测投诉的假数据源,真控件接上之后演示行就删干净了。所以判据从
/// 「必须有 rows」放宽成「必须有 rows **或** 有别的分发路径」——按下标分发的那几组
/// (general / update / assistant / java)在 `settings/mod.rs` 的 `form` 里各有 `else if`,
/// 本断言只对「既没有 rows 又不在这张名单里」的组报错。
#[test] #[test]
fn every_group_has_a_name_icon_and_rows() { fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3); assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len()); assert!(SELECTED < GROUPS.len());
// 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。
const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"];
for group in &GROUPS { for group in &GROUPS {
assert!(!group.name.is_empty()); assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name); assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name); assert!(
!group.rows.is_empty() || INDEX_DISPATCHED.contains(&group.name),
"{} 既没有设置行,也不在按下标分发的名单里",
group.name
);
for (label, value) in group.rows { for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty()); assert!(!label.is_empty() && !value.is_empty());
} }
@@ -131,7 +144,15 @@ fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect(); let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!( assert_eq!(
names, names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"] vec![
"General",
"Appearance",
"Java",
"Downloads",
"Updates",
// M174:助手(AI)配置只追加在末尾
"Assistant",
]
); );
} }
@@ -142,9 +163,10 @@ fn the_split_kept_the_left_column_order() {
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。 /// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
/// ///
/// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**—— /// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**——
/// 那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有 `appearance` + `groups` /// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option<Element>`(`Lang` 是 M179 加的),
/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 + /// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 +
/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。 /// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发,
/// `body` 仍是 `None`,所以本断言不动。
/// 判据没变:**`body: Some(..)` 仍然只有外观组**。 /// 判据没变:**`body: Some(..)` 仍然只有外观组**。
#[test] #[test]
fn the_settings_module_does_not_name_any_group_state() { fn the_settings_module_does_not_name_any_group_state() {
@@ -197,6 +219,10 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() { fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事—— // 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。 // ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组"); assert_eq!(
GROUPS.len(),
6,
"四条填页线 + M159 的更新组 + M174 的助手组"
);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内"); assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
} }
@@ -0,0 +1,130 @@
//! M173:设置页表单列的**滚动条槽**(滚动条并排,不压内容)。
//!
//! 这里守的是整页级别的数值契约,不是「哪一组有哪些行」——所以它不属于任何 section 文件。
//!
//! ## 怎么量
//!
//! iced 的控件树支持在测试里真跑布局:`iced_core` 在 debug 构建下给 `()` 实现了全套 `Renderer`
//! trait(`iced_core-0.14.0/src/renderer/null.rs`),于是 `frame::scroll::<()>(…)` 返回的
//! `Element<_, Message, Theme, ()>` 能被 `Widget::layout` 量出**真的**子节点尺寸。不需要开窗,
//! 也不需要 GPU——这是「数值断言,不是看图」的落点。
//!
//! 节点树(`iced_core/src/layout.rs:201`:`padded` 恒为「自己 + 一个子节点」):
//!
//! ```text
//! scrollable → [槽容器(Fill,右内边距 = 槽宽) → [内容]]
//! ```
//!
//! ## 抓什么 / 抓不住什么
//!
//! 抓得住:内容右侧真的留出了「轨宽 + 两侧 margin」的槽;长/短页面(出/不出滑块)内容宽度与左
//! 边界**逐项相同**;把 `app::components::scroll` 的 padding 改回 0,本文件立刻红。
//! 抓不住:滑块画在槽里的**像素**位置(那由 `pages/settings` 的出图与 iced 自己的 `Rail::new`
//! 公式担保,见 `src/components/scroll.rs` 文档里的源码行号)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, space};
use iced::{Element, Length, Rectangle, Size, Theme};
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use super::super::frame;
use super::super::frame::{SCROLLBAR_MARGIN, SCROLLBAR_WIDTH};
use crate::pages::Message;
use crate::theme::{Families, Ui};
/// 设置页表单列宽:`frame_metrics` 的 `column`(= `measure_cap_px()`,1180 与 880 窗口下都是它)。
const COLUMN: f32 = 424.0;
/// 视口高(1180×760 减去标题栏 / 页头 / 上下页边距之后的量级;只要求「长页 > 它」「短页 < 它」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(正文远超视口 ⇒ 一定出滑块)。
const LONG: f32 = 4000.0;
/// 短页面(正文装得下 ⇒ 一定不出滑块)。
const SHORT: f32 = 120.0;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 滚动条槽宽(dp):轨道 + 两侧留白,与 iced 的 `total_scrollbar_width` 同口径。
fn slot() -> f32 {
SCROLLBAR_WIDTH + 2.0 * SCROLLBAR_MARGIN
}
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let ui = ui();
let filler: Element<'_, Message, Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
// 走的是**产品那条** `frame::scroll`(泛型只为换成 `()` 渲染器,几何一字不改)。
let mut element = frame::scroll::<()>(&ui, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(COLUMN, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本条 mission 的核心数值断言**:内容可用宽度 = 容器宽 − 滚动条占位。
#[test]
fn the_form_column_reserves_a_rail_slot_beside_the_content() {
let (gutter, content) = laid_out(LONG);
assert_eq!(slot(), 10.0, "6 轨宽 + 2×2 内缩 = 10dp 的槽");
assert_eq!(gutter.width, COLUMN, "槽容器自身仍是满宽");
assert_eq!(
content.width,
COLUMN - slot(),
"内容可用宽度必须是「容器宽 − 滚动条槽」,否则轨道就压在内容右缘上"
);
assert_eq!(content.width, 414.0, "424 − 10 = 414");
}
/// 长页面(出滑块)与短页面(不出滑块)——内容宽度与左边界必须**逐项相同**。
///
/// 这是 M173 要求的「两张对照图 + 数值」里的数值那一半:只给一张出滑块的图说明不了「滑块出现时
/// 内容会不会跳」,必须两态同框比。
#[test]
fn the_content_does_not_jump_between_a_long_and_a_short_page() {
let (long_gutter, long) = laid_out(LONG);
let (short_gutter, short) = laid_out(SHORT);
assert_eq!(long_gutter.x, short_gutter.x, "槽容器的左边界不许变");
assert_eq!(long_gutter.width, short_gutter.width, "槽容器的宽度不许变");
assert_eq!(long.x, short.x, "左边界不许横跳");
assert_eq!(long.width, short.width, "宽度不许横跳");
assert_eq!(short.x, 0.0, "槽只占右侧:左侧内边距为 0");
assert_eq!(
short.x + short.width,
COLUMN - slot(),
"内容右缘 = 轨道的左缘(再往右 10dp 才是轨道自己)"
);
}
/// 反证:`content_width` 这条纯量与实际布局量出来的是同一个数(两处不许各算一套)。
#[test]
fn the_layout_agrees_with_the_pure_quantity() {
let (_, content) = laid_out(LONG);
assert_eq!(
content.width,
crate::app::components::scroll::content_width(COLUMN, slot())
);
}
@@ -11,11 +11,20 @@ use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus; use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update}; use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` /// 更新组**下标固定为 4**:加组只追加不重排,插到它前面会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。 /// 两个下标一起失效,也会把更新组自己的 `UPDATE_GROUP` 指到别人身上。
///
/// M174 之后它**不再是最后一组**(助手组追加在它后面):这条断言因此从「len == 下标 + 1」
/// 改成「下标 4 上就是更新组,且它是**倒数第二**组」——追加语义仍然成立,而「更新组被挤走」
/// 照样会红。
#[test] #[test]
fn the_update_group_sits_at_the_end_of_the_registry() { fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组"); assert_eq!(UPDATE_GROUP, 4, "更新组的下标不许变(加组只追加)");
assert_eq!(
GROUPS.len(),
UPDATE_GROUP + 2,
"更新组之后只该有 M174 的助手组"
);
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name); assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates"); assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len()); assert!(SELECTED < GROUPS.len());
+4 -4
View File
@@ -2,10 +2,10 @@
//! //!
//! ## 为什么这一组不走 `Group.body` //! ## 为什么这一组不走 `Group.body`
//! //!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组 //! `Group.body` 的槽位拿的是 [`States`](super::States) 与**生效语言** `Lang`(M179 起),而更新状态
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种 //! 是 **presenter 快照**(`pages::Update`,不是某一组的字段、也不在 `States` 里),槽位装不下它。
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给 //! 与 M106 的语言行同一种情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的
//! [`body`],`GROUP.body` 仍是 `None`。 //! `update` 形参交给 [`body`],`GROUP.body` 仍是 `None`。
//! //!
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 / //! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。 //! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
+111
View File
@@ -0,0 +1,111 @@
//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。
//!
//! ## 为什么需要它
//!
//! 字体下载要把 17 MiB 落在数据根里(`<launcher_home>/cache/fonts/`),而「数据根在哪」这件事
//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 +
//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析,
//! 只做两件它没暴露的事:
//!
//! 1. 由配置文件路径 `<home>/config/launcher.json` 反推 `<home>`,再用 [`LauncherLayout::cache`]
//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串);
//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写,
//! 而 `ui.font_cdn` 是 M168 新增的键。
//!
//! ## 诚实边界
//!
//! - **抓得住**:路径推导的形状(`<home>/config/x` → `<home>/cache`)与「配置读不到时返回 `None`
//! 而不是猜一个目录」;
//! - **抓不住**:真机上 `SettingsBridge::open()` 解析出哪个根(那是 presenter + core 的用例),
//! 以及 macOS `.app` 只读这类平台差异(没有真机)。
//!
//! ## 后续收口(已登记 finding)
//!
//! 更干净的做法是让 presenter 暴露一个通用的 `SettingsBridge::load_text(key)`,本模块就只剩
//! 「布局 + 一条键」。presenter 不在 M168 的 scope 里,所以这里先按上面的方式落地。
use std::path::PathBuf;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_presenter::settings_bridge::SettingsBridge;
/// 中文字体下载地址的覆盖键(M168;空 = 用内置镜像)。
pub const FONT_CDN_KEY: &str = "ui.font_cdn";
/// 本进程的数据根布局(`None` = 数据根解析不出来,例如 exe 目录都拿不到)。
///
/// 解析只发生一次:配方来自 `SettingsBridge::open()`(它内部走 `DataDirRequest` + `Resolver`)。
pub fn layout() -> Option<LauncherLayout> {
static LAYOUT: std::sync::OnceLock<Option<LauncherLayout>> = std::sync::OnceLock::new();
LAYOUT
.get_or_init(|| {
let config = SettingsBridge::open().path()?.to_path_buf();
// `<home>/config/launcher.json` → `<home>`
let home = config.parent()?.parent()?.to_path_buf();
Some(LauncherLayout::new(home))
})
.clone()
}
/// 字体缓存的根(`<cache>/fonts/` 的父目录;**不**等于字体目录本身)。
///
/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里,
/// 这就是「可重建」的那一类)。
pub fn cache_root() -> Option<PathBuf> {
layout().map(|layout| layout.cache())
}
/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。
///
/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。
pub fn setting_text(key: &str) -> Option<String> {
let path = SettingsBridge::open().path()?.to_path_buf();
let schema = launcher_schema();
let store = SettingStore::load(&path, schema);
let document: &SettingDocument = store.doc();
document.effective_str(key).map(str::to_owned)
}
/// `ui.font_cdn` 的覆盖值(`None` = 没配 / 配了空串 ⇒ 用内置镜像)。
pub fn font_cdn_override() -> Option<String> {
let raw = setting_text(FONT_CDN_KEY)?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
/// 键名与 core 的 schema 一致:写错一个字符,用户配了也不生效(而 UI 不会报错)。
///
/// 抓得住:本模块的常量与 `core::setting::keys` 里登记的键名对不上。
/// 抓不住:真机上读到的值(那要数据根与配置文件)。
#[test]
fn the_cdn_key_is_registered_in_the_launcher_schema() {
let schema = launcher_schema();
assert!(
schema.spec(FONT_CDN_KEY).is_some(),
"{FONT_CDN_KEY} 没在 core 的 schema 里登记"
);
}
/// 布局推导的形状:`<home>/config/launcher.json` → `<home>/cache`。
///
/// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。
/// 抓不住:真机上 `<home>` 解析成什么(presenter 的 `resolve_config_path` 管那一层)。
#[test]
fn the_cache_partition_is_the_sibling_of_config() {
let config = PathBuf::from("/tmp/ull-home/config/launcher.json");
let home = config.parent().unwrap().parent().unwrap().to_path_buf();
let layout = LauncherLayout::new(home);
assert_eq!(layout.cache(), PathBuf::from("/tmp/ull-home/cache"));
assert_eq!(
layout.cache().join("fonts"),
PathBuf::from("/tmp/ull-home/cache/fonts")
);
}
}
+4 -2
View File
@@ -1,5 +1,5 @@
//! 平台相关的窗口能力(P4 / D36 / M116 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点, //! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe`
//! 以及系统语言真值源,四件事分居四处。 //! 落点、系统语言真值源、以及数据根派生,五件事分居五处。
//! //!
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
@@ -18,6 +18,7 @@
//! //!
//! | 模块 | 里面有什么 | 有没有 FFI | //! | 模块 | 里面有什么 | 有没有 FFI |
//! |---|---|---| //! |---|---|---|
//! | [`data`] | 数据根派生(字体缓存分区 + 一条设置键的生效值,M168) | 没有 |
//! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 | //! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 |
//! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) | //! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) |
//! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) | //! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) |
@@ -31,6 +32,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend; use crate::Backend;
pub mod data;
pub mod host; pub mod host;
pub mod linux; pub mod linux;
pub mod locale; pub mod locale;
+5 -1
View File
@@ -139,8 +139,12 @@ impl LangState {
report.strategy, report.strategy,
unlucky_i18n::font::Strategy::NeedsDownload(_) unlucky_i18n::font::Strategy::NeedsDownload(_)
) { ) {
// M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给
// `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里,
// 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」,
// 等于当面否定刚做的事(复审 r1 P2-3)。
eprintln!( eprintln!(
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)" "[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体"
); );
} }
} }
+1
View File
@@ -9,6 +9,7 @@
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。 //! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
pub mod ports; pub mod ports;
pub mod probe;
pub mod serve; pub mod serve;
use serde_json::{json, Value}; use serde_json::{json, Value};
+361
View File
@@ -0,0 +1,361 @@
//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现**
//! ([`RigProvider`])发一次最小请求,并把失败分成四类。
//!
//! ## 分类从哪来(r1 复审 P2-5 之后)
//!
//! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化
//! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成
//! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射:
//!
//! | [`ProviderFailure`] | 本模块产出 |
//! |---|---|
//! | `Auth`(401/403) | [`ProbeOutcome::Auth`] |
//! | `ModelNotFound`(404 / `model_not_found`) | [`ProbeOutcome::ModelNotFound`] |
//! | `Unreachable`(传输层:连接被拒 / DNS / TLS) | [`ProbeOutcome::Unreachable`] |
//! | `Timeout`(8 秒时限 / 传输层超时) | [`ProbeOutcome::Timeout`] |
//! | `NotConfigured`(没发请求) | [`ProbeOutcome::NotConfigured`] |
//! | `Other`(解析 / 5xx / 未知) | [`ProbeOutcome::Failed`] |
//!
//! ## 三条硬纪律
//!
//! 1. **配置不完整时一个请求都不发**:[`ProbeOutcome::NotConfigured`] 在造 provider **之前**返回,
//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。
//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把
//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),[`ProbeOutcome`] 里没有密钥字段。
//! 3. **每次现读文件**:[`probe_file`] 读的是「现在这一份」,所以设置页保存完再测,测的就是刚
//! 存下的 `ai.base_url`(不是启动时缓存的旧值)。
//!
//! 失败结论**一律带上被请求的 target**(r1 复审 P2-1):只说「鉴权失败」而不说「对哪个端点、
//! 哪个模型」,用户改完 key 也没法确认自己改的是不是那一份配置。
use std::path::Path;
use std::time::Duration;
use unlucky_agent::brain::provider::{
ProviderConfig, ProviderError, ProviderFailure, ProviderKind,
};
use unlucky_agent::brain::rig::RigProvider;
use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory};
use unlucky_agent::redact::{redact_text, MASK};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 一次「测试连接」的默认时限。
///
/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应。
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
/// 探测请求只发这一句:够触发一次真实补全,又不烧 token。
const PING: &str = "ping";
/// 从设置文件读出来的 AI 四项(快照)。
///
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。
#[derive(Clone, PartialEq, Eq)]
pub struct AiSettings {
/// `ai.enabled`
pub enabled: bool,
/// `ai.provider`(设置词表:`openai_compatible` / `anthropic` / `ollama` / `disabled`)
pub provider: String,
/// `ai.base_url`(空 = 用该形态的默认端点)
pub base_url: String,
/// `ai.model`
pub model: String,
/// `ai.api_key`(空/缺 = 没配置)
pub api_key: Option<String>,
}
impl std::fmt::Debug for AiSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiSettings")
.field("enabled", &self.enabled)
.field("provider", &self.provider)
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("api_key", &self.api_key.as_deref().map(|_| MASK))
.finish()
}
}
/// 缺哪一项配置(`NotConfigured` 的载荷:界面上要说清楚「差什么」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Missing {
/// `ai.enabled=false` 或 `ai.provider=disabled`
Disabled,
/// `ai.provider` 是空/未知值
Provider,
/// 该形态需要端点,但 `ai.base_url` 是空的
BaseUrl,
/// `ai.model` 是空的
Model,
/// 该形态(Anthropic)必须有密钥,但 `ai.api_key` 是空的
ApiKey,
}
impl Missing {
/// 稳定名(CLI 输出 / 日志 / 测试断言用;界面走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Provider => "provider",
Self::BaseUrl => "base_url",
Self::Model => "model",
Self::ApiKey => "api_key",
}
}
}
/// 被请求的那一份配置(端点 + 模型):失败结论也要带它,用户才知道「改的是不是这一份」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// 实际请求的端点(`resolved_base_url`)。
pub base_url: String,
/// 实际请求的模型名。
pub model: String,
}
/// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
/// 配置不完整:**没有发出任何请求**(因此也没有 target)。
NotConfigured {
/// 缺的是哪一项。
missing: Missing,
},
/// 通了(拿到一次真实补全的回复)。
Passed {
/// 实际请求的目标。
target: Target,
},
/// 鉴权失败(401 / 403)。
Auth {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 端点不可达(连接被拒 / DNS / TLS)。
Unreachable {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 超时。
Timeout {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因(含时限)。
detail: String,
},
/// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。
Failed {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
}
impl ProbeOutcome {
/// 是否「通」。
#[must_use]
pub fn is_pass(&self) -> bool {
matches!(self, Self::Passed { .. })
}
/// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` /
/// `not_configured` / `failed`)。
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::NotConfigured { .. } => "not_configured",
Self::Passed { .. } => "passed",
Self::Auth { .. } => "auth",
Self::ModelNotFound { .. } => "model_not_found",
Self::Unreachable { .. } => "unreachable",
Self::Timeout { .. } => "timeout",
Self::Failed { .. } => "failed",
}
}
/// 这条结论是关于哪个端点/模型的(`NotConfigured` 没有)。
#[must_use]
pub fn target(&self) -> Option<&Target> {
match self {
Self::NotConfigured { .. } => None,
Self::Passed { target }
| Self::Auth { target, .. }
| Self::ModelNotFound { target, .. }
| Self::Unreachable { target, .. }
| Self::Timeout { target, .. }
| Self::Failed { target, .. } => Some(target),
}
}
}
impl AiSettings {
/// 从设置文件读四项(文件缺失/坏掉时给「全默认」= 未配置,不报错)。
#[must_use]
pub fn load(path: &Path) -> Self {
let store = SettingStore::load(path, launcher_schema());
let doc = store.doc();
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
let api_key = doc
.effective_str("ai.api_key")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Self {
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
provider: text("ai.provider"),
base_url: text("ai.base_url"),
model: text("ai.model"),
api_key,
}
}
/// 四项 → [`ProviderConfig`];缺项时给出**具体缺哪一项**(不发请求)。
///
/// # Errors
/// 禁用、端点/模型/密钥缺失或 `ai.provider` 不是已知取值时返回 [`Missing`]。
pub fn to_config(&self) -> Result<ProviderConfig, Missing> {
if !self.enabled {
return Err(Missing::Disabled);
}
let Some(kind) = ProviderKind::from_setting(&self.provider) else {
return Err(Missing::Provider);
};
if kind == ProviderKind::Disabled {
return Err(Missing::Disabled);
}
let base_url = self.base_url.trim();
if base_url.is_empty() && kind.default_base_url().is_empty() {
return Err(Missing::BaseUrl);
}
let model = self.model.trim();
if model.is_empty() {
return Err(Missing::Model);
}
if kind.requires_api_key() && self.api_key.is_none() {
return Err(Missing::ApiKey);
}
let mut config = match kind {
ProviderKind::OpenAiCompatible => ProviderConfig::openai_compatible(base_url, model),
ProviderKind::Anthropic => ProviderConfig::anthropic(model),
ProviderKind::Ollama => ProviderConfig::ollama(model),
// 上面已经拦掉;留着是因为穷尽匹配比 `_ =>` 值钱(加形态时编译器会提醒)。
ProviderKind::Disabled => return Err(Missing::Disabled),
};
if !base_url.is_empty() {
config.base_url = base_url.to_string();
}
if let Some(key) = self.api_key.as_deref() {
config = config.with_api_key(key);
}
Ok(config)
}
/// 本次的密钥(脱敏用;空值给 `None`)。
fn secret(&self) -> Option<&str> {
self.api_key.as_deref().filter(|key| !key.trim().is_empty())
}
}
/// 读设置文件并测一次连接(真 provider、真网络、带时限)。
#[must_use]
pub fn probe_file(path: &Path, timeout: Duration) -> ProbeOutcome {
probe_with(&RigProvider::factory(), &AiSettings::load(path), timeout)
}
/// 测一次连接(工厂可注入:单测用计数假件证明「未配置时一次都没发」)。
///
/// # Panics
///
/// 在已有 tokio 运行时上下文里调用会 panic(`core::download::blocking::block_on` 的既有约定);
/// 本函数的两个调用方(GUI 的 thread-pool 任务、CLI)都不在运行时里。
#[must_use]
pub fn probe_with(
factory: &ProviderFactory,
settings: &AiSettings,
timeout: Duration,
) -> ProbeOutcome {
let config = match settings.to_config() {
Ok(config) => config,
Err(missing) => return ProbeOutcome::NotConfigured { missing },
};
let target = Target {
base_url: config.resolved_base_url().to_string(),
model: config.model.clone(),
};
let provider = match factory(&config) {
Ok(provider) => provider,
Err(error) => return outcome_from_error(&error, settings, target),
};
let request = ChatRequest {
model: target.model.clone(),
messages: vec![ChatMessage::user(PING)],
tools: Vec::new(),
temperature: Some(0.0),
max_tokens: Some(16),
};
unlucky_core::download::blocking::block_on(async move {
match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await {
Ok(Ok(_reply)) => ProbeOutcome::Passed { target },
Ok(Err(error)) => outcome_from_error(&error, settings, target),
Err(_) => ProbeOutcome::Timeout {
target,
detail: format!("{} s", timeout.as_secs_f32()),
},
}
})
}
/// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。
fn outcome_from_error(
error: &ProviderError,
settings: &AiSettings,
target: Target,
) -> ProbeOutcome {
let detail = scrub(&error.to_string(), settings.secret());
match error.kind() {
ProviderFailure::Auth => ProbeOutcome::Auth { target, detail },
ProviderFailure::ModelNotFound => ProbeOutcome::ModelNotFound { target, detail },
ProviderFailure::Unreachable => ProbeOutcome::Unreachable { target, detail },
ProviderFailure::Timeout => ProbeOutcome::Timeout { target, detail },
// 工厂阶段就报「配置不完整」(`validate()` 拒绝):仍然没发请求,但缺项已经在上游
// `to_config()` 拦过,这里退到 `Provider` 这个具名缺项。
ProviderFailure::NotConfigured => ProbeOutcome::NotConfigured {
missing: Missing::Provider,
},
ProviderFailure::Other => ProbeOutcome::Failed { target, detail },
}
}
/// 文本脱敏:先走 agent 的通用脱敏(主目录 / Bearer),再把本次密钥原文换成 [`MASK`]。
///
/// 第二道是必须的:`redact_text` 认不出「某个服务端回显的自定义密钥」,只有本模块知道本次的
/// 密钥原文。长度上限 400:错误体可能是整页 HTML,界面上放不下,日志里也没必要。
pub fn scrub(text: &str, secret: Option<&str>) -> String {
let mut out = redact_text(text);
if let Some(secret) = secret.filter(|value| !value.trim().is_empty()) {
out = out.replace(secret, MASK);
}
out.chars().take(400).collect()
}
#[cfg(test)]
#[path = "probe/tests.rs"]
mod tests;
@@ -0,0 +1,287 @@
//! 「测试连接」的守卫(M174)。四类失败**各有一条真 TCP 假服务器**用例——不是拿字符串假装的。
//!
//! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---|
//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` |
//! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth |
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` |
//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` |
//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 |
//! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 |
//! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) |
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_agent::brain::rig::RigProvider;
use super::{probe_with, AiSettings, ProbeOutcome};
/// 假服务器的行为。
enum Behaviour {
/// 立刻回这一份(状态行 + Content-Type + body)。
Reply(&'static str, &'static str, &'static str),
/// 收下请求但**永远不回**(超时用例)。
Silent,
}
/// 一条收到的请求:头 + 正文(P2-3:正文里才有 `model`)。
#[derive(Clone)]
struct Hit {
head: String,
body: String,
}
/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求,按 `behaviour` 回。
struct Server {
port: u16,
hits: Arc<Mutex<Vec<Hit>>>,
}
impl Server {
fn start(behaviour: Behaviour) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑本地端口");
let port = listener.local_addr().expect("本地地址").port();
let hits = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
handle(stream, &behaviour, &sink);
}
});
Self { port, hits }
}
fn base_url(&self) -> String {
format!("http://127.0.0.1:{}/v1", self.port)
}
fn hits(&self) -> Vec<Hit> {
self.hits.lock().expect("锁").clone()
}
}
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<Hit>>>) {
let mut buffer = [0_u8; 8192];
let mut raw = Vec::new();
// 先读头,再按 `Content-Length` 把正文读全(P2-3:只读头就断言不到 `model`)。
let head_end = loop {
if let Some(at) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
break at + 4;
}
match stream.read(&mut buffer) {
Ok(0) => break raw.len(),
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => return,
}
};
let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
let length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while raw.len() < head_end + length {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => break,
}
}
let body =
String::from_utf8_lossy(&raw[head_end..raw.len().min(head_end + length)]).to_string();
sink.lock().expect("锁").push(Hit { head, body });
match behaviour {
Behaviour::Silent => {
// 拖住这条连接(客户端会先超时):比客户端时限长得多。
std::thread::sleep(Duration::from_secs(5));
}
Behaviour::Reply(status, content_type, body) => {
let response = format!(
"{status}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}",
len = body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
}
}
fn settings(base_url: &str, model: &str) -> AiSettings {
AiSettings {
enabled: true,
provider: "openai_compatible".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
api_key: Some("sk-test-not-a-real-key".to_string()),
}
}
const AUTH_BODY: &str = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#;
const MISSING_MODEL_BODY: &str =
r#"{"error":{"message":"model_not_found: no such model gpt-nope","code":"model_not_found"}}"#;
/// 一份最小但合法的 OpenAI 流式回复(rig 走 `model.stream(..)`,要 SSE 不是整包 JSON)。
const SSE_BODY: &str = concat!(
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\n",
"data: [DONE]\n\n"
);
#[test]
fn auth_failure_is_classified_as_auth() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
let ProbeOutcome::Auth { target, detail } = &outcome else {
panic!("401 必须归到鉴权失败,实际 {outcome:?}");
};
assert!(!detail.is_empty());
// P2-1:失败结论必须说清「对哪个端点 / 哪个模型」。
assert_eq!(target.base_url, server.base_url());
assert_eq!(target.model, "gpt-test");
// 请求确实发出去了(否则「鉴权失败」是凭空说的)。
assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过");
}
#[test]
fn missing_model_is_model_not_found() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 404 Not Found",
"application/json",
MISSING_MODEL_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-nope"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"model_not_found",
"404 必须归到模型不存在,实际 {outcome:?}"
);
assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope"));
}
#[test]
fn closed_port_is_unreachable() {
// 绑一个端口再放掉:这个端口上没人监听 = 连接被拒。
let port = {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口");
listener.local_addr().expect("地址").port()
};
let base_url = format!("http://127.0.0.1:{port}/v1");
let outcome = probe_with(
&RigProvider::factory(),
&settings(&base_url, "gpt-test"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"unreachable",
"没人监听的端口必须归到端点不可达,实际 {outcome:?}"
);
assert_eq!(
outcome.target().map(|t| t.base_url.as_str()),
Some(base_url.as_str())
);
}
#[test]
fn silent_server_times_out() {
let server = Server::start(Behaviour::Silent);
let started = std::time::Instant::now();
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_millis(400),
);
assert_eq!(
outcome.kind(),
"timeout",
"不回应的端点必须归到超时,实际 {outcome:?}"
);
assert!(
started.elapsed() < Duration::from_secs(3),
"超时必须在时限附近收手,实际 {:?}",
started.elapsed()
);
}
#[test]
fn success_is_one_real_completion() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 200 OK",
"text/event-stream",
SSE_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
match &outcome {
ProbeOutcome::Passed { target } => {
assert_eq!(target.base_url, server.base_url());
assert_eq!(target.model, "gpt-test");
}
other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"),
}
}
/// **P2-3**:请求体里的 `model` 必须是用户配置的那个(不是写死的)。
///
/// 变异:把 `probe_with` 的 `model: target.model.clone()` 改成 `"gpt-4o".to_string()` ⇒ 本用例
/// 断言 `\"model\":\"gpt-test\"` 失败。这是「配置里的模型真的上了线」唯一可信的证据:只看
/// 状态码通过并不能证明服务端收到的是哪个模型。
#[test]
fn the_request_carries_the_configured_model() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 200 OK",
"text/event-stream",
SSE_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
assert!(outcome.is_pass(), "前提:这一轮应当通过,实际 {outcome:?}");
let hits = server.hits();
assert_eq!(hits.len(), 1, "应当恰好一次请求");
let hit = &hits[0];
assert!(
hit.head.starts_with("POST /v1/chat/completions"),
"请求行不对:{}",
hit.head.lines().next().unwrap_or("")
);
assert!(
!hit.body.is_empty(),
"没有读到请求正文(夹具没按 Content-Length 读 body?)"
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
}
#[path = "tests_guards.rs"]
mod guards;
@@ -0,0 +1,290 @@
//! 「测试连接」的**不发请求**那一半守卫(M174):未配置不建 provider / 改完设置打新地址 /
//! 密钥不外流 / 文件读取。与 `probe/tests.rs` 的分工:那边起真 TCP 假服务器验四类失败,
//! 这边只验「什么时候不发」「发到哪」「发出去的东西里有什么」。
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use unlucky_agent::brain::provider::ProviderConfig;
use unlucky_agent::brain::{
ChatProvider, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
};
use unlucky_agent::redact::MASK;
use super::{settings, Behaviour, Server, AUTH_BODY};
use crate::commands::agent::probe::{probe_file, probe_with, AiSettings, Missing, ProbeOutcome};
#[test]
fn incomplete_config_never_builds_a_provider() {
struct Counter;
impl ChatProvider for Counter {
fn config(&self) -> &ProviderConfig {
unreachable!("不该被造出来")
}
fn complete<'a>(
&'a self,
_request: &'a ChatRequest,
_observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
unreachable!("不该被造出来")
}
}
let builds = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&builds);
let factory: unlucky_agent::brain::ProviderFactory =
Arc::new(move |_config: &ProviderConfig| {
counter.fetch_add(1, Ordering::SeqCst);
Ok(Arc::new(Counter) as Arc<dyn ChatProvider>)
});
// 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(Anthropic)
let mut cases = vec![
(
AiSettings {
enabled: false,
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Disabled,
),
(
AiSettings {
base_url: String::new(),
..settings("", "m")
},
Missing::BaseUrl,
),
(
AiSettings {
model: String::new(),
..settings("http://127.0.0.1:1/v1", "")
},
Missing::Model,
),
(
AiSettings {
provider: "anthropic".to_string(),
base_url: String::new(),
api_key: None,
..settings("", "claude-sonnet-4")
},
Missing::ApiKey,
),
(
AiSettings {
provider: "openai-compatible".to_string(),
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Provider,
),
];
// `..::settings(..)` 里那些字段会被逐个覆盖,这里只要求「不全的配置都能被拦下」。
for (case, expected) in cases.drain(..) {
let outcome = probe_with(&factory, &case, Duration::from_millis(50));
assert_eq!(
outcome,
ProbeOutcome::NotConfigured { missing: expected },
"配置不全时必须返回 NotConfigured({expected:?}),实际 {outcome:?}"
);
}
assert_eq!(
builds.load(Ordering::SeqCst),
0,
"未配置时一个 provider 都不许造(更不许发请求)"
);
}
/// 守卫:设置页保存完再测,请求打到的是**文件里现在那个** `ai.base_url`。
#[test]
fn saved_base_url_is_the_address_actually_dialed() {
let old = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let new = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let path = temp_config("base-url");
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
old.base_url()
),
);
let first = probe_file(&path, Duration::from_secs(5));
assert!(!first.is_pass(), "假服务器回 401,不该算通过");
assert_eq!(old.hits().len(), 1, "第一次必须打到旧地址");
// 用户在设置页改了 base_url(同一份 schema、同一个文件)之后再测一次。
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
new.base_url()
),
);
let second = probe_file(&path, Duration::from_secs(5));
assert!(!second.is_pass());
assert_eq!(
new.hits().len(),
1,
"改完 base_url 之后请求必须打到新地址(读的是文件里现在那一份)"
);
assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次");
let hit = &new.hits()[0];
assert!(
hit.head.starts_with("POST /v1/chat/completions"),
"OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}",
hit.head.lines().next().unwrap_or("")
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
// P2-1:改完 base_url 之后,失败结论里说的也必须是**新**地址。
assert_eq!(
second.target().map(|target| target.base_url.as_str()),
Some(new.base_url().as_str())
);
}
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
#[test]
fn key_never_leaks_into_outcome_or_debug() {
const SECRET: &str = "sk-live-LEAK-MARKER-9f3a";
// 服务端把 key 原样回显(有些网关会这么干)。
let body = r#"{"error":{"message":"Incorrect API key provided: sk-live-LEAK-MARKER-9f3a","code":"invalid_api_key"}}"#;
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
body,
));
let settings = AiSettings {
api_key: Some(SECRET.to_string()),
..settings(&server.base_url(), "gpt-test")
};
let outcome = probe_with(
&unlucky_agent::brain::rig::RigProvider::factory(),
&settings,
Duration::from_secs(5),
);
let printed = format!("{outcome:?}");
assert!(
!printed.contains(SECRET),
"ProbeOutcome 里出现了密钥明文:{printed}"
);
assert!(
printed.contains(MASK),
"被掩码后应当看得见掩码标记:{printed}"
);
let debug = format!("{settings:?}");
assert!(
!debug.contains(SECRET),
"AiSettings 的 Debug 漏了密钥:{debug}"
);
assert!(
debug.contains(MASK),
"AiSettings 的 Debug 应当显示掩码:{debug}"
);
}
#[test]
fn settings_load_reads_the_five_keys() {
let path = temp_config("load");
write_config(
&path,
r#"{"ai":{"enabled":false,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3","api_key":"sk-x"}}"#,
);
let settings = AiSettings::load(&path);
assert!(!settings.enabled);
assert_eq!(settings.provider, "ollama");
assert_eq!(settings.base_url, "http://127.0.0.1:11434");
assert_eq!(settings.model, "llama3");
assert_eq!(settings.api_key.as_deref(), Some("sk-x"));
// 空的 api_key(null / 空串)读成 `None`,不是 `Some("")`。
let empty = temp_config("empty-key");
write_config(
&empty,
r#"{"ai":{"provider":"ollama","model":"llama3","api_key":""}}"#,
);
assert_eq!(AiSettings::load(&empty).api_key, None);
// 文件不存在也不 panic(`effective_*` 给 schema 默认值 = 未配置,不是坏值)。
let missing = temp_config("missing");
let settings = AiSettings::load(&missing);
assert_eq!(
settings.provider, "openai_compatible",
"缺文件时是 schema 默认值"
);
assert!(settings.model.is_empty());
assert_eq!(settings.api_key, None);
assert_eq!(settings.to_config(), Err(Missing::BaseUrl));
}
// ------------------------------------------------------------------ 夹具
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-probe-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
dir.join("config/launcher.json")
}
fn write_config(path: &PathBuf, body: &str) {
std::fs::write(path, body).expect("写配置文件");
}
/// 工厂阶段的错误也按**结构化分类**走(r1 复审 P2-5 之后:这里不再看文本)。
///
/// 两条一起钉住:
/// 1. 工厂给的是 `classified(Auth, …)` ⇒ 结论就是 `auth`(分类信息不丢);
/// 2. 工厂给的是 `ProviderError::new("401 bad key")`(**纯文本、没有分类**)⇒ 结论是 `failed`,
/// **不是** `auth`。第二条是 P2-5 的绊线:谁把「按 `"401"` 子串猜类别」加回来,这条立刻红。
#[test]
fn factory_error_carries_the_structured_classification() {
let classified: unlucky_agent::brain::ProviderFactory = Arc::new(|_config: &ProviderConfig| {
Err(ProviderError::classified(
unlucky_agent::brain::ProviderFailure::Auth,
"401 Unauthorized",
))
});
let outcome = probe_with(
&classified,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"auth",
"工厂阶段的结构化分类不许丢:{outcome:?}"
);
// 纯文本错误(无分类)**不许**被猜成鉴权失败。
let text_only: unlucky_agent::brain::ProviderFactory =
Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key")));
let outcome = probe_with(
&text_only,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"failed",
"没有结构化分类的错误必须落在「其它」,不许按文本猜成 auth(P2-5):{outcome:?}"
);
}
+330
View File
@@ -0,0 +1,330 @@
//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。
//!
//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事
//! 不必读算法也能核对。
//!
//! # 三份名单,一份都不许丢
//!
//! | 名单 | 含义 | 上层该做什么 |
//! |------|------|--------------|
//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 |
//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 |
//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 |
//!
//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是
//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。
//!
//! # 两档为什么不能合成一句
//!
//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同:
//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜,
//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx)
//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。
use super::model::{VersionFile, VersionQuery};
/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……),
/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。
pub const DEFAULT_MAX_DEPTH: u8 = 6;
/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PresentFile {
/// 文件名(可以带路径,比对时会砍掉)
pub name: String,
}
impl PresentFile {
/// 记一个已存在的文件名。
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
/// 归一化后的比对键(见 [`present_key`])。
#[must_use]
pub fn key(&self) -> String {
present_key(&self.name)
}
}
/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。
///
/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见
/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。
#[must_use]
pub fn present_key(name: &str) -> String {
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim()
.to_ascii_lowercase();
base.strip_suffix(".disabled").unwrap_or(&base).to_string()
}
/// 一次解析请求。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveRequest {
/// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关
pub roots: Vec<String>,
/// 目标游戏版本(选版本用;`None` = 不限)
pub game_version: Option<String>,
/// 目标加载器(选版本用;`None` = 不限)
pub loader: Option<String>,
/// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`])
pub max_depth: u8,
/// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的)
pub present: Vec<PresentFile>,
}
impl Default for ResolveRequest {
fn default() -> Self {
Self {
roots: Vec::new(),
game_version: None,
loader: None,
max_depth: DEFAULT_MAX_DEPTH,
present: Vec::new(),
}
}
}
impl ResolveRequest {
/// 单根请求(内容库「装进实例」的入口形状)。
#[must_use]
pub fn single(project: impl Into<String>) -> Self {
Self {
roots: vec![project.into()],
..Self::default()
}
}
/// 这个项目的版本请求(过滤条件相同,只换项目)。
#[must_use]
pub fn version_query(&self, project: &str) -> VersionQuery {
VersionQuery {
project: project.to_string(),
game_version: self.game_version.clone(),
loader: self.loader.clone(),
}
}
}
/// 计划里的一条「要装什么」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedNode {
/// 规范项目 id(站点回的 `project_id`;空则用请求时的键)
pub project: String,
/// 当初请求它用的键(slug 或 id)——重试要用同一个
pub requested_as: String,
/// 版本 id
pub version_id: String,
/// 版本名
pub version_name: String,
/// 版本号
pub version_number: String,
/// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`)
pub file: VersionFile,
/// 距根的层数(根 = 0)
pub depth: u8,
/// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的)
pub required_by: Vec<String>,
}
impl PlannedNode {
/// 是用户点的那个项目吗(根 = 层数 0)。
///
/// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」
/// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。
#[must_use]
pub fn is_root(&self) -> bool {
self.depth == 0
}
}
/// 为什么跳过。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 目标目录里已经有同名文件(含 `.disabled`)
AlreadyPresent,
}
impl SkipReason {
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AlreadyPresent => "already_present",
}
}
}
/// 一条被跳过的(本来要下载的)项目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedNode {
/// 规范项目 id
pub project: String,
/// 请求它用的键
pub requested_as: String,
/// 计划里的文件名(在目录里找到了同名的那个)
pub file_name: String,
/// 距根的层数
pub depth: u8,
/// 谁要求装它
pub required_by: Vec<String>,
/// 跳过原因
pub reason: SkipReason,
}
/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
/// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目
/// (版本列表为空,或选中的版本没有可下载的主文件)。
///
/// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。
Incompatible,
/// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。
///
/// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在
/// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。
Unreachable,
}
impl FailureKind {
/// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Incompatible => "incompatible",
Self::Unreachable => "unreachable",
}
}
}
/// 一条解析失败(进「可重试名单」的就是它们)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyFailure {
/// 请求它用的键(重试也用它)
pub requested_as: String,
/// 当初因为谁而被要求(规范项目 id;根为空)
pub required_by: Vec<String>,
/// 距根的层数
pub depth: u8,
/// 哪一档
pub kind: FailureKind,
/// 叶子成因(只有 [`FailureKind::Unreachable`] 有)
pub cause: Option<super::transport::OnlineErrorKind>,
/// 人读说明(站点/引擎原话 + 上下文,不是界面文案)
pub message: String,
}
/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。
///
/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被
/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedRequired {
/// 声明它的项目(规范 id)
pub parent: String,
/// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名)
pub version_id: Option<String>,
/// 站点给的文件名
pub file_name: Option<String>,
}
/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyPlan {
/// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`)
pub request: ResolveRequest,
/// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序)
pub nodes: Vec<PlannedNode>,
/// 目录里已经有了、不用再下的
pub skipped: Vec<SkippedNode>,
/// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`])
pub failures: Vec<DependencyFailure>,
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
pub truncated: Vec<String>,
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
pub unresolved_required: Vec<UnresolvedRequired>,
}
impl DependencyPlan {
/// 空计划(`resolve` 从这里开始填)。
#[must_use]
pub fn new(request: &ResolveRequest) -> Self {
Self {
request: request.clone(),
nodes: Vec::new(),
skipped: Vec::new(),
failures: Vec::new(),
truncated: Vec::new(),
unresolved_required: Vec::new(),
}
}
/// 一条失败都没有吗(空循环也算干净)。
#[must_use]
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
/// 某一档的失败(两档分开看时用)。
pub fn failures_of(&self, kind: FailureKind) -> impl Iterator<Item = &DependencyFailure> {
self.failures.iter().filter(move |item| item.kind == kind)
}
/// 记一条失败——**按 `requested_as` 去重**,父边合并。
///
/// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重,
/// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。
/// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。
pub fn record_failure(&mut self, failure: DependencyFailure) {
if let Some(existing) = self
.failures
.iter_mut()
.find(|item| item.requested_as == failure.requested_as)
{
existing.required_by.extend(failure.required_by);
existing.required_by.sort();
existing.required_by.dedup();
// 深度取更浅的那条:越靠近根越该先修
existing.depth = existing.depth.min(failure.depth);
return;
}
self.failures.push(failure);
}
/// 「可重试名单」:失败项原样重启一次解析的请求。
///
/// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。
/// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器,
/// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。
///
/// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的
/// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。
#[must_use]
pub fn retry_request(&self) -> ResolveRequest {
let mut roots: Vec<String> = Vec::new();
for failure in &self.failures {
if !roots.contains(&failure.requested_as) {
roots.push(failure.requested_as.clone());
}
}
ResolveRequest {
roots,
..self.request.clone()
}
}
/// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。
#[must_use]
pub fn required_by(&self, project: &str) -> Option<&[String]> {
self.nodes
.iter()
.find(|node| node.project == project)
.map(|node| node.required_by.as_slice())
}
}
+22 -2
View File
@@ -46,23 +46,43 @@
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | //! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | //! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 | //! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 |
//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) |
//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 |
//!
//! # 一次「装一个模组」到底发几个请求
//!
//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次
//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**,
//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」
//! 这条路只能靠用户自己发现缺前置。
//!
//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本
//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」,
//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。
pub mod complete; pub mod complete;
pub mod dependency;
pub mod download; pub mod download;
pub mod model; pub mod model;
pub mod modrinth; pub mod modrinth;
pub mod resolve;
pub mod transport; pub mod transport;
pub use complete::identity_of; pub use complete::identity_of;
pub use dependency::{
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, DEFAULT_MAX_DEPTH,
};
pub use download::{ pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS, FetchArgs, RETRY_ATTEMPTS,
}; };
pub use model::{ pub use model::{
ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex, ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage,
Version, VersionFile, VersionQuery, VersionType, SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType,
}; };
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API}; pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
pub use resolve::resolve;
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport}; pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
#[cfg(test)] #[cfg(test)]
+2 -2
View File
@@ -32,6 +32,6 @@ mod types;
pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions}; pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions};
pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS}; pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS};
pub use types::{ pub use types::{
FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version,
VersionType, VersionFile, VersionQuery, VersionType,
}; };
+33 -1
View File
@@ -8,7 +8,10 @@ use std::collections::BTreeMap;
use serde::Deserialize; use serde::Deserialize;
use super::query::KNOWN_LOADERS; use super::query::KNOWN_LOADERS;
use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType}; use super::types::{
Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile,
VersionType,
};
use crate::addon::online::transport::{OnlineError, OnlineErrorKind}; use crate::addon::online::transport::{OnlineError, OnlineErrorKind};
// --------------------------------------------------------------- 解析 // --------------------------------------------------------------- 解析
@@ -112,6 +115,23 @@ struct RawVersion {
downloads: u64, downloads: u64,
#[serde(default)] #[serde(default)]
files: Vec<RawFile>, files: Vec<RawFile>,
#[serde(default)]
dependencies: Vec<RawDependency>,
}
#[derive(Debug, Default, Deserialize)]
struct RawDependency {
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
version_id: Option<String>,
#[serde(default)]
file_name: Option<String>,
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`。
// 必须是 `Option<String>`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null`
// (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。
#[serde(default)]
dependency_type: Option<String>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -238,6 +258,18 @@ fn version_of(raw: RawVersion) -> Version {
primary: file.primary, primary: file.primary,
}) })
.collect(), .collect(),
dependencies: raw
.dependencies
.into_iter()
.map(|dependency| Dependency {
project_id: non_empty(dependency.project_id),
version_id: non_empty(dependency.version_id),
file_name: non_empty(dependency.file_name),
dependency_type: DependencyType::parse(
dependency.dependency_type.as_deref().unwrap_or_default(),
),
})
.collect(),
} }
} }
+14 -1
View File
@@ -83,7 +83,20 @@ impl SortIndex {
/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**, /// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**,
/// 直接整份当加载器会画出假加载器)。 /// 直接整份当加载器会画出假加载器)。
pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"]; ///
/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories`
/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」,
/// 也用不上「只看 Iris」这种判断。
pub const KNOWN_LOADERS: [&str; 8] = [
"fabric",
"forge",
"neoforge",
"quilt",
"liteloader",
"rift",
"iris",
"optifine",
];
/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。 /// 一次检索请求(分页游标也在里面:换页就是换一份 query)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+102
View File
@@ -140,6 +140,67 @@ impl VersionType {
} }
} }
/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。
///
/// # 为什么要留 `Unknown` 而不是「认不出就当可选」
///
/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`,
/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项
/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum DependencyType {
/// 必须装(BFS **只跟这一档**)
Required,
/// 可选(装不装都能跑)
#[default]
Optional,
/// 明确不兼容(**不要**和它一起装)
Incompatible,
/// 已内嵌在被依赖的文件里(不用另外装)
Embedded,
/// 站点给了个我们不认识的取值
Unknown,
}
impl DependencyType {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Required => "required",
Self::Optional => "optional",
Self::Incompatible => "incompatible",
Self::Embedded => "embedded",
Self::Unknown => "unknown",
}
}
/// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw.trim() {
"required" => Self::Required,
"optional" => Self::Optional,
"incompatible" => Self::Incompatible,
"embedded" => Self::Embedded,
_ => Self::Unknown,
}
}
}
/// 版本声明的一条前置(`dependencies[]`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Dependency {
/// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug)
pub project_id: Option<String>,
/// 依赖的版本 id(站点基本只对 `embedded` 给)
pub version_id: Option<String>,
/// 站点给的文件名(只对 `embedded` / `incompatible` 有意义)
pub file_name: Option<String>,
/// 依赖类型
pub dependency_type: DependencyType,
}
/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。 /// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Version { pub struct Version {
@@ -163,6 +224,8 @@ pub struct Version {
pub downloads: u64, pub downloads: u64,
/// 文件列表 /// 文件列表
pub files: Vec<VersionFile>, pub files: Vec<VersionFile>,
/// 前置声明(站点原文顺序;空 = 站点没说 / 没有)
pub dependencies: Vec<Dependency>,
} }
impl Version { impl Version {
@@ -176,6 +239,45 @@ impl Version {
.find(|file| file.primary) .find(|file| file.primary)
.or_else(|| self.files.first()) .or_else(|| self.files.first())
} }
/// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。
///
/// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的,
/// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。
#[must_use]
pub fn required_project_ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self
.dependencies
.iter()
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
.filter_map(|dependency| dependency.project_id.as_deref())
.map(str::trim)
.filter(|id| !id.is_empty())
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// 声明了 `required`、却**没有项目 id** 的前置(站点只给了版本 id / 文件名)。
///
/// 这些条目 BFS 跟不下去(取版本要项目 id),但也不能当成「没有这条依赖」——
/// 上层据此说「有 N 条前置没法解析」,见 `DependencyPlan::unresolved_required`。
#[must_use]
pub fn unresolved_required(&self) -> Vec<&Dependency> {
self.dependencies
.iter()
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
.filter(|dependency| {
dependency
.project_id
.as_deref()
.map(str::trim)
.map(str::is_empty)
.unwrap_or(true)
})
.collect()
}
} }
/// 版本里的一个文件。 /// 版本里的一个文件。
+303
View File
@@ -0,0 +1,303 @@
//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。
//!
//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别
//!
//! | 参考实现 | 这里 | 为什么 |
//! |---|---|---|
//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 |
//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` |
//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 |
//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 |
//!
//! # 失败是**数据**,不是 `Err`
//!
//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`,
//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的
//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。
//!
//! # 两张去重网
//!
//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进
//! [`PlannedNode::required_by`];
//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。
//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本**
//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。
//!
//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份
//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态
//! (我们落盘用站点给的文件名,不重命名)。
use std::collections::{BTreeMap, BTreeSet};
use super::dependency::{
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired,
};
use super::model::Version;
use super::modrinth::Modrinth;
use super::transport::{OnlineErrorKind, Transport};
/// 一层里还没处理的一项。
struct Wave {
key: String,
depth: u8,
parents: Vec<String>,
}
/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。
pub async fn resolve<T: Transport + ?Sized>(
client: &Modrinth<'_, T>,
request: &ResolveRequest,
) -> DependencyPlan {
let mut plan = DependencyPlan::new(request);
let present: BTreeSet<String> = request.present.iter().map(PresentFile::key).collect();
// 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id)
let mut alias: BTreeMap<String, String> = BTreeMap::new();
let mut resolved: BTreeSet<String> = BTreeSet::new();
let mut layer: Vec<Wave> = Vec::new();
for root in &request.roots {
let key = root.trim();
if !key.is_empty() {
wave_in(&mut layer, key, 0, None);
}
}
while !layer.is_empty() {
// 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。
layer.sort_by(|left, right| left.key.cmp(&right.key));
let mut next: Vec<Wave> = Vec::new();
for mut item in layer.drain(..) {
normalize(&mut item.parents);
// ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点
if let Some(canonical) = alias.get(&item.key).cloned() {
fold_parents(&mut plan, &canonical, &item.parents);
continue;
}
let version = match client.versions(&request.version_query(&item.key)).await {
Ok(versions) => match versions.into_iter().next() {
Some(version) => version,
None => {
let message = filter_message(request);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Incompatible,
None,
message,
);
continue;
}
},
Err(error) => {
let message = format!(
"取版本失败({}):{}",
error.kind().as_str(),
error.message()
);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Unreachable,
Some(error.kind()),
message,
);
continue;
}
};
let Some(file) = version
.primary_file()
.filter(|file| !file.url.trim().is_empty())
.cloned()
else {
let message = format!(
"{} 的版本 {} 里没有可下载的主文件",
item.key,
version_label(&version)
);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Incompatible,
None,
message,
);
continue;
};
let canonical = canonical_of(&item.key, &version);
alias.insert(item.key.clone(), canonical.clone());
alias
.entry(canonical.clone())
.or_insert_with(|| canonical.clone());
// ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装
if !resolved.insert(canonical.clone()) {
fold_parents(&mut plan, &canonical, &item.parents);
continue;
}
let file_name = file.filename.clone();
if present.contains(&present_key(&file_name)) {
plan.skipped.push(SkippedNode {
project: canonical.clone(),
requested_as: item.key.clone(),
file_name,
depth: item.depth,
required_by: item.parents.clone(),
reason: SkipReason::AlreadyPresent,
});
} else {
plan.nodes.push(PlannedNode {
project: canonical.clone(),
requested_as: item.key.clone(),
version_id: version.id.clone(),
version_name: version.name.clone(),
version_number: version.version_number.clone(),
file,
depth: item.depth,
required_by: item.parents.clone(),
});
}
// 子项:只跟 required(排序去重由 `required_project_ids` 保证)
for dependency in version.required_project_ids() {
if item.depth >= request.max_depth {
plan.truncated.push(dependency.to_string());
} else {
wave_in(&mut next, dependency, item.depth + 1, Some(&canonical));
}
}
// 只有版本 id、没有项目 id 的 required:没有地址可取,记下来不假装没看见
for dependency in version.unresolved_required() {
plan.unresolved_required.push(UnresolvedRequired {
parent: canonical.clone(),
version_id: dependency.version_id.clone(),
file_name: dependency.file_name.clone(),
});
}
}
plan.truncated.sort();
plan.truncated.dedup();
layer = next;
}
plan.unresolved_required.sort_by(|left, right| {
(&left.parent, &left.version_id).cmp(&(&right.parent, &right.version_id))
});
plan.unresolved_required.dedup();
plan
}
/// 记一条失败,并把这个请求键标成「已处理」。
///
/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`,
/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。
/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。
///
/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖)
///
/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个
/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里
/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据,
/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉,
/// 只把边界写在这里(复审 r2 nit③)。
fn fail(
plan: &mut DependencyPlan,
alias: &mut BTreeMap<String, String>,
item: Wave,
kind: FailureKind,
cause: Option<OnlineErrorKind>,
message: String,
) {
plan.record_failure(DependencyFailure {
requested_as: item.key.clone(),
required_by: item.parents,
depth: item.depth,
kind,
cause,
message,
});
alias.insert(item.key.clone(), item.key);
}
/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。
fn wave_in(layer: &mut Vec<Wave>, key: &str, depth: u8, parent: Option<&str>) {
if let Some(existing) = layer.iter_mut().find(|item| item.key == key) {
if let Some(parent) = parent {
existing.parents.push(parent.to_string());
}
return;
}
layer.push(Wave {
key: key.to_string(),
depth,
parents: parent
.map(|parent| vec![parent.to_string()])
.unwrap_or_default(),
});
}
/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。
fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) {
if parents.is_empty() {
return;
}
let node = plan
.nodes
.iter_mut()
.find(|node| node.project == project)
.map(|node| &mut node.required_by);
let node = node.or_else(|| {
plan.skipped
.iter_mut()
.find(|node| node.project == project)
.map(|node| &mut node.required_by)
});
if let Some(required_by) = node {
required_by.extend(parents.iter().cloned());
normalize(required_by);
return;
}
if let Some(failure) = plan
.failures
.iter_mut()
.find(|failure| failure.requested_as == project)
{
failure.required_by.extend(parents.iter().cloned());
normalize(&mut failure.required_by);
}
}
fn normalize(list: &mut Vec<String>) {
list.sort();
list.dedup();
}
/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。
fn canonical_of(key: &str, version: &Version) -> String {
let project_id = version.project_id.trim();
if project_id.is_empty() {
key.to_string()
} else {
project_id.to_string()
}
}
/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。
fn filter_message(request: &ResolveRequest) -> String {
let game_version = request.game_version.as_deref().unwrap_or("不限");
let loader = request.loader.as_deref().unwrap_or("不限");
format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本")
}
fn version_label(version: &Version) -> String {
let number = version.version_number.trim();
if number.is_empty() {
version.id.clone()
} else {
number.to_string()
}
}
@@ -0,0 +1,306 @@
//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。
//!
//! 三条守门对应任务书的三条:
//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定;
//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归;
//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。
//!
//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。
use crate::addon::online::dependency::{
present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason,
};
use crate::addon::online::model::parse_versions;
use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API};
use crate::download::blocking::block_on;
use super::fixtures_deps as deps;
use super::support::FixtureTransport;
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
fn request(root: &str) -> ResolveRequest {
ResolveRequest {
roots: vec![root.to_string()],
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
/// 三层链的传输层(sodium → fabric-api → yacl)。
fn chain_transport() -> FixtureTransport {
FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
.with(YACL_VERSIONS, deps::YACL)
}
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, request))
}
// ---------------------------------------------------------------- ① 嵌套两层
#[test]
fn two_nested_levels_are_pulled_in_a_predictable_order() {
let transport = chain_transport();
let plan = resolve_with(&transport, &request("sodium"));
assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures);
// 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl
let order: Vec<(&str, u8)> = plan
.nodes
.iter()
.map(|node| (node.project.as_str(), node.depth))
.collect();
assert_eq!(
order,
vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)]
);
// 「为什么装」:父子关系逐条对得上
assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty());
assert_eq!(
plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"),
["AANobbMI".to_string()].as_slice()
);
assert_eq!(
plan.required_by("1eAoo2KR").expect("yacl 在计划里"),
["P7dR8mSH".to_string()].as_slice()
);
// 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发
let seen = transport.seen();
assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}");
for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] {
assert!(
!seen.iter().any(|url| url.contains(quiet)),
"{quiet} 不该被跟:{seen:?}"
);
}
// 主文件取 primary(第一个是 sources.jar)
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar");
}
#[test]
fn only_required_dependencies_are_followed() {
// 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。
let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功");
let version = &versions[0];
assert_eq!(version.dependencies.len(), 4);
let kinds: Vec<&str> = version
.dependencies
.iter()
.map(|dependency| dependency.dependency_type.as_str())
.collect();
assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]);
assert_eq!(version.required_project_ids(), ["P7dR8mSH"]);
// embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id
assert!(version.dependencies[3].project_id.is_none());
}
#[test]
fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() {
let transport = chain_transport();
let mut request = request("sodium");
request.max_depth = 1;
let plan = resolve_with(&transport, &request);
assert_eq!(plan.nodes.len(), 2, "根 + 第一层");
assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里");
}
// ---------------------------------------------------------------- ② 环
#[test]
fn a_dependency_cycle_terminates_and_each_project_appears_once() {
let transport = FixtureTransport::new()
.with(
"https://api.modrinth.com/v2/project/cycle-a/version",
deps::CYCLE_A,
)
.with(
"https://api.modrinth.com/v2/project/cycBid01/version",
deps::CYCLE_B,
);
let plan = resolve_with(&transport, &request("cycle-a"));
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次");
assert_eq!(transport.seen().len(), 2, "环不许把请求数放大");
// B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点
assert_eq!(
plan.required_by("cycBid01").expect("B 在计划里"),
["cycAid01".to_string()].as_slice()
);
assert_eq!(
plan.required_by("cycAid01").expect("A 在计划里"),
["cycBid01".to_string()].as_slice()
);
assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by");
assert!(!plan.nodes[1].is_root());
}
// ---------------------------------------------------------------- ③ 两档失败
#[test]
fn an_incompatible_dependency_lands_in_the_retry_list() {
// fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组)
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, "[]");
let plan = resolve_with(&transport, &request("sodium"));
// 根还在计划里(失败不连坐),缺的那条单独立案
assert_eq!(plan.nodes.len(), 1);
assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures);
let failure = &plan.failures[0];
assert_eq!(failure.kind, FailureKind::Incompatible);
assert_eq!(failure.requested_as, "P7dR8mSH");
assert_eq!(failure.depth, 1);
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
assert!(failure.cause.is_none(), "这一档不是网络成因");
assert!(failure.message.contains("1.20.1"), "文案要带过滤条件");
assert!(plan.failures_of(FailureKind::Unreachable).next().is_none());
// 可重试名单只含失败项,过滤条件沿用
let retry = plan.retry_request();
assert_eq!(retry.roots, ["P7dR8mSH"]);
assert_eq!(retry.game_version.as_deref(), Some("1.20.1"));
assert_eq!(retry.loader.as_deref(), Some("fabric"));
}
#[test]
fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() {
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.missing(FABRIC_API_VERSIONS);
let plan = resolve_with(&transport, &request("sodium"));
let failure = &plan.failures[0];
assert_eq!(failure.kind, FailureKind::Unreachable);
assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound));
// 两档从类型上就分得开:同一个计划里两种成因不会挤进一条
assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0);
}
#[test]
fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() {
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(
FABRIC_API_VERSIONS,
r#"[{"id": "ver-empty", "project_id": "P7dR8mSH",
"version_number": "0.0", "files": []}]"#,
);
let plan = resolve_with(&transport, &request("sodium"));
assert_eq!(plan.failures[0].kind, FailureKind::Incompatible);
assert!(plan.failures[0].message.contains("没有可下载的主文件"));
}
// ---------------------------------------------------------------- 两道去重
#[test]
fn one_project_required_by_two_parents_is_planned_once_with_both_parents() {
let transport = FixtureTransport::new()
.with(
"https://api.modrinth.com/v2/project/fanin-root/version",
deps::FANIN_ROOT,
)
.with(
"https://api.modrinth.com/v2/project/BRANCHAA/version",
deps::FANIN_A,
)
.with(
"https://api.modrinth.com/v2/project/BRANCHBB/version",
deps::FANIN_B,
)
.with(
"https://api.modrinth.com/v2/project/SHARED01/version",
deps::SHARED_LIB,
);
let plan = resolve_with(&transport, &request("fanin-root"));
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(
projects,
["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"],
"同层按项目键字典序,公共前置只出现一次"
);
assert_eq!(
plan.required_by("SHARED01").expect("公共前置在计划里"),
["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(),
"两条依赖路径都要留下"
);
let shared_requests = transport
.seen()
.iter()
.filter(|url| url.contains("SHARED01"))
.count();
assert_eq!(shared_requests, 1, "一个项目只解一次版本");
}
#[test]
fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() {
let mut request = request("sodium");
// 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用)
request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")];
let transport = chain_transport();
let plan = resolve_with(&transport, &request);
assert_eq!(plan.skipped.len(), 1);
let skipped = &plan.skipped[0];
assert_eq!(skipped.project, "P7dR8mSH");
assert_eq!(skipped.reason, SkipReason::AlreadyPresent);
assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar");
// 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]);
}
#[test]
fn present_keys_normalize_paths_case_and_the_disabled_suffix() {
assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar");
assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar");
assert_eq!(present_key(" y.jar "), "y.jar");
assert_eq!(present_key(""), "");
}
// ---------------------------------------------------------------- 光影过滤器
#[test]
fn shader_loaders_are_recognised_in_search_hits() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&crate::addon::online::SearchQuery {
text: String::new(),
project_type: Some(crate::addon::online::ContentType::Shader),
limit: 1,
..crate::addon::online::SearchQuery::default()
}))
.expect("解析应当成功");
assert_eq!(
page.hits[0].loaders,
vec!["iris".to_string(), "optifine".to_string()],
"光影的加载器不该被当成分类标签丢掉"
);
}
@@ -0,0 +1,277 @@
//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。
//!
//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节,
//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。
//!
//! 三条刻意的布置:
//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟;
//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错;
//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。
/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。
pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[
{
"id": "ver-sodium",
"project_id": "AANobbMI",
"name": "Sodium 0.6.0 for 1.20.1",
"version_number": "mc1.20.1-0.6.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2026-02-01T12:00:00Z",
"downloads": 4321,
"files": [
{
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 1000,
"primary": false
},
{
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar",
"filename": "sodium-fabric-0.6.0.jar",
"size": 2048,
"primary": true
}
],
"dependencies": [
{"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"},
{"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"},
{"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"},
{"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"}
]
}
]"#;
/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。
pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[
{
"id": "ver-fabric-api",
"project_id": "P7dR8mSH",
"name": "Fabric API 0.92.2 for 1.20.1",
"version_number": "0.92.2+1.20.1",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-12-01T00:00:00Z",
"downloads": 9999,
"files": [
{
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"},
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar",
"filename": "fabric-api-0.92.2-sources.jar",
"size": 512,
"primary": false
},
{
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"},
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar",
"filename": "fabric-api-0.92.2+1.20.1.jar",
"size": 4096,
"primary": true
}
],
"dependencies": [
{"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"}
]
}
]"#;
/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。
pub(super) const YACL: &str = r#"[
{
"id": "ver-yacl",
"project_id": "1eAoo2KR",
"name": "YetAnotherConfigLib 3.3.2",
"version_number": "3.3.2+1.20.1",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-11-01T00:00:00Z",
"downloads": 1234,
"files": [
{
"hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"},
"url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar",
"filename": "yacl-3.3.2+1.20.1.jar",
"size": 8192,
"primary": true
}
],
"dependencies": []
}
]"#;
/// 扇入的根:两个 required 分支。
pub(super) const FANIN_ROOT: &str = r#"[
{
"id": "ver-root",
"project_id": "ROOT0001",
"name": "Fan-in Root 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"},
"url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar",
"filename": "fanin-root-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "BRANCHAA", "dependency_type": "required"},
{"project_id": "BRANCHBB", "dependency_type": "required"}
]
}
]"#;
/// 分支 A:也要 `shared-lib`。
pub(super) const FANIN_A: &str = r#"[
{
"id": "ver-branch-a",
"project_id": "BRANCHAA",
"name": "Branch A 2.0",
"version_number": "2.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"},
"url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar",
"filename": "branch-a-2.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "SHARED01", "dependency_type": "required"}
]
}
]"#;
/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。
pub(super) const FANIN_B: &str = r#"[
{
"id": "ver-branch-b",
"project_id": "BRANCHBB",
"name": "Branch B 3.0",
"version_number": "3.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"},
"url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar",
"filename": "branch-b-3.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "SHARED01", "dependency_type": "required"}
]
}
]"#;
/// 扇入的公共前置。
pub(super) const SHARED_LIB: &str = r#"[
{
"id": "ver-shared",
"project_id": "SHARED01",
"name": "Shared Lib 1.5",
"version_number": "1.5",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"},
"url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar",
"filename": "shared-lib-1.5.jar",
"size": 2048,
"primary": true
}
],
"dependencies": []
}
]"#;
/// 环的一端:`cycle-a` 要求 `cycle-b`。
pub(super) const CYCLE_A: &str = r#"[
{
"id": "ver-cycle-a",
"project_id": "cycAid01",
"name": "Cycle A 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"},
"url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar",
"filename": "cycle-a-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "cycBid01", "dependency_type": "required"}
]
}
]"#;
/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。
pub(super) const CYCLE_B: &str = r#"[
{
"id": "ver-cycle-b",
"project_id": "cycBid01",
"name": "Cycle B 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"},
"url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar",
"filename": "cycle-b-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "cycAid01", "dependency_type": "required"}
]
}
]"#;
/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。
pub(super) const SEARCH_SHADER: &str = r#"{
"hits": [
{
"project_id": "shader01",
"project_type": "shader",
"slug": "complementary-reimagined",
"author": "EminGT",
"title": "Complementary Reimagined",
"description": "A shader pack",
"categories": ["iris", "optifine", "shaders"],
"versions": ["1.20.1"],
"downloads": 1234567,
"follows": 4242,
"icon_url": "",
"date_modified": "2026-01-02T03:04:05Z"
}
],
"offset": 0,
"limit": 1,
"total_hits": 1
}"#;
@@ -8,6 +8,10 @@
mod api; mod api;
mod classify; mod classify;
mod dependency;
mod download; mod download;
mod fixtures; mod fixtures;
mod fixtures_deps;
mod order;
mod regressions;
mod support; mod support;
+137
View File
@@ -0,0 +1,137 @@
//! 顺序确定性(r1 P2-4):同层 ≥2 个前置、夹具数组故意逆序、多根乱序,结果必须一样。
//!
//! # 这条用例守的是什么
//!
//! `resolve` 在每层处理前把队列按**项目键字典序**排一遍。把那一行删掉,原来那 11 条用例
//! (以及 core 的全部用例)**仍然全绿**——因为三跳链每层只有一个前置、扇入用例里两个同层
//! 节点的先后恰好等于夹具数组顺序。也就是说「顺序由数据决定」这条性质当时没有守门。
//!
//! # 归因(实测过,别写成「都靠这两条守门」)
//!
//! 删掉 `resolve` 里那行 `layer.sort_by` ⇒
//! [`prerequisites_from_two_roots_are_ordered_by_project_key`] 与
//! [`multiple_roots_are_processed_in_key_order_whatever_the_argument_order`] 红,
//! [`same_root_prerequisites_follow_key_order_not_payload_order`] 仍绿——因为那两个前置
//! 是**同一个根**入队的,顺序已经被 `Version::required_project_ids()` 内部的排序排过了。
//! 反过来,只删 `required_project_ids()` 的排序时三条都绿:`layer.sort_by` 会把结果重排一遍。
//!
//! 也就是说:跨层顺序由 `layer.sort_by` 兜底(这两条用例钉它),同一个根内的顺序**两层保护**
//! 冗余存在;`required_project_ids()` 自己的「排序去重」契约由 `tests/regressions.rs` 的
//! `required_project_ids_are_sorted_and_deduplicated_on_their_own` 单独钉(那条只删排序就会红)。
use crate::addon::online::dependency::{DependencyPlan, ResolveRequest};
use crate::addon::online::{resolve, Modrinth};
use crate::download::blocking::block_on;
use super::support::{version_json, FixtureTransport};
const API: &str = "https://api.modrinth.com/v2/project";
fn request(roots: &[&str]) -> ResolveRequest {
ResolveRequest {
roots: roots.iter().map(|root| (*root).to_string()).collect(),
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
fn plan_of(transport: &FixtureTransport, roots: &[&str]) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, &request(roots)))
}
fn projects(plan: &DependencyPlan) -> Vec<&str> {
plan.nodes
.iter()
.map(|node| node.project.as_str())
.collect()
}
#[test]
fn same_root_prerequisites_follow_key_order_not_payload_order() {
// 夹具里 ZEBRA01 在前、ALPHA01 在后(站点原文顺序反过来是常态)。
// 这一条守的是 `Version::required_project_ids()` 里的排序:靠「入队顺序」展开的话,
// 站点换个数组顺序,安装顺序就跟着变。
let transport = FixtureTransport::new()
.with(
&format!("{API}/order-root/version"),
&version_json(
"order-root",
"order-root.jar",
&[("ZEBRA01", "required"), ("ALPHA01", "required")],
),
)
.with(
&format!("{API}/ALPHA01/version"),
&version_json("ALPHA01", "alpha.jar", &[]),
)
.with(
&format!("{API}/ZEBRA01/version"),
&version_json("ZEBRA01", "zebra.jar", &[]),
);
let plan = plan_of(&transport, &["order-root"]);
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(
projects(&plan),
["order-root", "ALPHA01", "ZEBRA01"],
"同层按项目键字典序,与 `dependencies` 数组顺序无关"
);
}
/// 两个根各带一个前置 ⇒ 它们在**同一层**相遇,且项目键的顺序与根的先后相反。
///
/// 这一条守的是 `resolve` 里每层的 `sort_by`:不排的话,层内顺序就是「谁先被入队」——
/// 也就是参数顺序与站点响应顺序的混合,删掉那行 `sort_by` 这条会红
/// (实测:`left: ["zebra-branch","alpha-branch"]` 之类的参数序)。
#[test]
fn prerequisites_from_two_roots_are_ordered_by_project_key() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/branch-a/version"),
&version_json("branch-a", "branch-a.jar", &[("ZEBRA01", "required")]),
)
.with(
&format!("{API}/branch-b/version"),
&version_json("branch-b", "branch-b.jar", &[("ALPHA01", "required")]),
)
.with(
&format!("{API}/ALPHA01/version"),
&version_json("ALPHA01", "alpha.jar", &[]),
)
.with(
&format!("{API}/ZEBRA01/version"),
&version_json("ZEBRA01", "zebra.jar", &[]),
);
let plan = plan_of(&transport, &["branch-a", "branch-b"]);
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(
projects(&plan),
["branch-a", "branch-b", "ALPHA01", "ZEBRA01"],
"第二层两个前置按项目键排,不按「谁先入队」"
);
}
#[test]
fn multiple_roots_are_processed_in_key_order_whatever_the_argument_order() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/zebra-root/version"),
&version_json("zebra-root", "zebra-root.jar", &[]),
)
.with(
&format!("{API}/alpha-root/version"),
&version_json("alpha-root", "alpha-root.jar", &[]),
);
let forward = plan_of(&transport, &["zebra-root", "alpha-root"]);
let backward = plan_of(&transport, &["alpha-root", "zebra-root"]);
assert_eq!(
projects(&forward),
["alpha-root", "zebra-root"],
"参数逆序也要按项目键处理"
);
assert_eq!(projects(&backward), projects(&forward), "两次结果必须一样");
}
@@ -0,0 +1,232 @@
//! M186 复审(r1)补的用例:失败去重、null 载荷、版本级去重。
//!
//! 三条都对应一轮真实缺陷或真实缺口,而不是「补覆盖率」:
//! 1. [`the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it`] — P2-1:
//! 失败分支以前直接 `continue`,同一项目被两条路径要求时取两次版本、记两条失败、
//! 重试名单里写两遍同一个名字;
//! 2. [`a_null_dependency_type_does_not_poison_the_whole_version_list`] — P2-2:
//! `"dependency_type": null` 曾让整份 `/version` 报 Malformed,于是整个项目都装不上;
//! 3. [`another_version_of_the_same_mod_in_the_directory_still_downloads`] — nit③:
//! 归一化一旦改松,同 mod 的另一个版本会被误判成「已装」而跳过。
use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest};
use crate::addon::online::model::parse_versions;
use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired};
use crate::download::blocking::block_on;
use super::fixtures_deps as deps;
use super::support::{version_json, FixtureTransport};
const API: &str = "https://api.modrinth.com/v2/project";
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
fn request(root: &str) -> ResolveRequest {
ResolveRequest {
roots: vec![root.to_string()],
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, request))
}
fn chain_transport() -> FixtureTransport {
FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
.with(YACL_VERSIONS, deps::YACL)
}
/// 同一个项目被两条路径要求、且它 404:**只请求一次、只记一条失败**,父边合起来。
///
/// r1 P2-1 的探针:`ROOT0001 → {BRANCH01, MIDDLE01}`、`BRANCH01 → GONE0001`(404)、
/// `MIDDLE01 → LATE0001 → GONE0001`。修之前 GONE0001 被请求 2 次、失败 2 条、
/// 重试名单是 `["GONE0001","GONE0001"]`(用户会看到「可只重试这 2 个:GONE0001、GONE0001」)。
#[test]
fn the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/root/version"),
&version_json(
"ROOT0001",
"root.jar",
&[("BRANCH01", "required"), ("MIDDLE01", "required")],
),
)
.with(
&format!("{API}/BRANCH01/version"),
&version_json("BRANCH01", "branch.jar", &[("GONE0001", "required")]),
)
.with(
&format!("{API}/MIDDLE01/version"),
&version_json("MIDDLE01", "middle.jar", &[("LATE0001", "required")]),
)
.with(
&format!("{API}/LATE0001/version"),
&version_json("LATE0001", "late.jar", &[("GONE0001", "required")]),
)
.missing(&format!("{API}/GONE0001/version"));
let plan = resolve_with(&transport, &request("root"));
let gone_requests = transport
.seen()
.iter()
.filter(|url| url.contains("GONE0001"))
.count();
assert_eq!(
gone_requests,
1,
"同一个失败项目不许取两次:{:?}",
transport.seen()
);
assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures);
assert_eq!(plan.failures[0].requested_as, "GONE0001");
assert_eq!(
plan.failures[0].required_by,
["BRANCH01".to_string(), "LATE0001".to_string()],
"两条路径的父边都要留下"
);
assert_eq!(plan.retry_request().roots, ["GONE0001"], "名单不许重复");
}
/// `"dependency_type": null` 不许把整份版本响应打成 Malformed(r1 P2-2 的回归)。
///
/// 以前 `RawDependency.dependency_type` 是 `String`:`#[serde(default)]` 只兜「字段缺失」,
/// 兜不住 null ⇒ 整份 `/version` 解析失败 ⇒ 根项目记 `Malformed`、`nodes` 为空 ⇒ 一个都装不上。
#[test]
fn a_null_dependency_type_does_not_poison_the_whole_version_list() {
let body = r#"[{"id": "ver-null", "project_id": "NULLTYPE", "version_number": "1.0",
"files": [{"url": "https://cdn.modrinth.com/data/NULLTYPE/1.0/null-type.jar",
"filename": "null-type.jar", "size": 16, "primary": true}],
"dependencies": [{"project_id": "X1", "dependency_type": null}]}]"#;
// 解析层:null 与缺失一样落 `Unknown`
let versions = parse_versions(body).expect("null 不该让整份响应失败");
assert_eq!(
versions[0].dependencies[0].dependency_type,
DependencyType::Unknown
);
assert_eq!(versions[0].required_project_ids(), Vec::<&str>::new());
// 解析流程:根照样装得上,X1 一次请求都不发
let transport = FixtureTransport::new().with(&format!("{API}/null-type/version"), body);
let plan = resolve_with(&transport, &request("null-type"));
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(plan.nodes.len(), 1, "根项目要还在计划里");
assert_eq!(transport.seen().len(), 1, "未知类型不许递归");
}
/// 目录里是**同 mod 的另一个版本** ⇒ 文件名不同,仍然要下载(r1 nit③ 的负向用例)。
///
/// 归一化如果改松(例如「砍到第一个 `-` 前 + 砍扩展名」),`sodium-fabric-0.5.11.jar` 与
/// `sodium-fabric-0.6.0.jar` 会被判成同一个文件,该装的被跳过。
#[test]
fn another_version_of_the_same_mod_in_the_directory_still_downloads() {
let mut request = request("sodium");
request.present = vec![PresentFile::new("sodium-fabric-0.5.11.jar")];
let transport = chain_transport();
let plan = resolve_with(&transport, &request);
assert!(plan.skipped.is_empty(), "{:?}", plan.skipped);
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
assert_ne!(
present_key("sodium-fabric-0.5.11.jar"),
present_key("sodium-fabric-0.6.0.jar"),
"不同版本的文件名归一化后不许相等"
);
}
/// 未知/大小写不同的取值一律 `Unknown`,**绝不**猜成 required(这条设计不许被 r1 的修法改坏)。
#[test]
fn unknown_dependency_types_never_trigger_recursion() {
for raw in [
"required_by_default",
"Required ",
"Recommended",
"",
"REQUIRED",
"req",
] {
assert_eq!(
DependencyType::parse(raw),
DependencyType::Unknown,
"{raw:?} 不该被当成 required"
);
}
assert_eq!(DependencyType::parse("required"), DependencyType::Required);
}
/// `required_project_ids()` 的「排序 + 去重」契约单独钉住。
///
/// 为什么要单独钉:resolver 里 `layer.sort_by` 会把它重排一遍、`wave_in` 也会按同键合并,
/// 于是**这条契约在端到端用例里被兜住了**(删掉这里的 `sort_unstable` 三条顺序用例仍绿)。
/// 但 `dedup()` 只去相邻重复,不排序就留下 `["ZEBRA01","ALPHA01","ZEBRA01"]`——
/// 公开 API 说好了「排序去重」,就得有自己的一条。
#[test]
fn required_project_ids_are_sorted_and_deduplicated_on_their_own() {
let body = version_json(
"dup-root",
"dup-root.jar",
&[
("ZEBRA01", "required"),
("ALPHA01", "required"),
("ZEBRA01", "required"),
],
);
let versions = parse_versions(&body).expect("夹具该解析成功");
assert_eq!(
versions[0].required_project_ids(),
["ALPHA01", "ZEBRA01"],
"既要去重(非相邻)也要排序"
);
}
/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。
///
/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析,
/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。
#[test]
fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() {
let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0",
"files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar",
"filename": "mixed.jar", "size": 16, "primary": true}],
"dependencies": [
{"version_id": "vid-only", "dependency_type": "required"},
{"project_id": "FOLLOW01", "dependency_type": "required"}
]}]"#;
let transport = FixtureTransport::new()
.with(&format!("{API}/mixed/version"), body)
.with(
&format!("{API}/FOLLOW01/version"),
&version_json("FOLLOW01", "follow.jar", &[]),
);
let plan = resolve_with(&transport, &request("mixed"));
assert_eq!(
plan.unresolved_required,
[UnresolvedRequired {
parent: "MIXED001".to_string(),
version_id: Some("vid-only".to_string()),
file_name: None,
}],
"只给版本 id 的 required 必须被记下来"
);
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(
projects,
["MIXED001", "FOLLOW01"],
"同一版本里带 project_id 的 required 仍要被跟"
);
}
@@ -167,3 +167,23 @@ pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) {
crate::addon::online::download::to_hex(&sha512), crate::addon::online::download::to_hex(&sha512),
) )
} }
/// 拼一份 `/v2/project/{id}/version` 响应:一个版本、一个主文件、按给定顺序声明依赖。
///
/// `deps` 的每一项是 `(project_id, dependency_type)`,**顺序照抄站点原文**——顺序无关性由
/// `resolve` 保证(`tests/order.rs` 用它做「夹具逆序也得到同一顺序」的守门)。
pub(super) fn version_json(project: &str, file: &str, deps: &[(&str, &str)]) -> String {
let dependencies: Vec<String> = deps
.iter()
.map(|(id, kind)| format!(r#"{{"project_id":"{id}","dependency_type":"{kind}"}}"#))
.collect();
format!(
r#"[{{"id":"ver-{project}","project_id":"{project}","name":"{project}",
"version_number":"1.0","version_type":"release","loaders":["fabric"],
"game_versions":["1.20.1"],
"files":[{{"hashes":{{}},"url":"https://cdn.modrinth.com/data/{project}/1.0/{file}",
"filename":"{file}","size":16,"primary":true}}],
"dependencies":[{}]}}]"#,
dependencies.join(",")
)
}
+65 -1
View File
@@ -29,7 +29,9 @@ use super::chain::complete_chain;
use super::endpoints::MicrosoftEndpoints; use super::endpoints::MicrosoftEndpoints;
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE}; use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
use super::now_ms; use super::now_ms;
use super::preflight::{self, RedirectRegistration};
use super::session::MicrosoftSession; use super::session::MicrosoftSession;
use super::{BrowserFallback, BrowserFallbackReason};
/// 授权码 grant 类型。 /// 授权码 grant 类型。
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
@@ -171,11 +173,28 @@ pub(crate) async fn login<F>(
policy: AuthCodePolicy, policy: AuthCodePolicy,
present: F, present: F,
) -> AuthResult<MicrosoftSession> ) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
login_counting(client, endpoints, client_id, policy, None, present).await
}
/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。
pub(crate) async fn login_counting<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
counter: Option<&std::sync::atomic::AtomicUsize>,
present: F,
) -> AuthResult<MicrosoftSession>
where where
F: FnOnce(&AuthCodePrompt), F: FnOnce(&AuthCodePrompt),
{ {
let state = policy.state.clone().unwrap_or_else(nonce); let state = policy.state.clone().unwrap_or_else(nonce);
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?; let server =
CallbackServer::bind_counting(policy.port, policy.bind_attempts, state.clone(), counter)
.await?;
let redirect_uri = RedirectUri::new(server.port()); let redirect_uri = RedirectUri::new(server.port());
present(&AuthCodePrompt { present(&AuthCodePrompt {
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state), authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
@@ -194,6 +213,51 @@ where
complete_chain(client, endpoints, tokens, now_ms()).await complete_chain(client, endpoints, tokens, now_ms()).await
} }
/// 一次「浏览器优先」尝试的结果(M178)。
pub(crate) enum AuthCodeAttempt {
/// 授权码流走通(凭据已换到,整链完成)。
LoggedIn(MicrosoftSession),
/// **没去试**:预检说这个回环 URI 没登记(或问不出来)。监听端口一次都没开。
Skipped(BrowserFallback),
}
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
///
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(`counter` 是绑定现场的
/// 真计数,见 [`CallbackServer::bind_counting`]),也就不会出现「浏览器里一张 AAD 错误页 +
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
pub(crate) async fn login_checked<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
counter: &std::sync::atomic::AtomicUsize,
present: F,
) -> AuthResult<AuthCodeAttempt>
where
F: FnOnce(&AuthCodePrompt),
{
let probe = RedirectUri::new(preflight::PROBE_PORT);
match preflight::check(client, endpoints, client_id, &probe).await {
RedirectRegistration::Registered => {}
RedirectRegistration::NotRegistered { detail } => {
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
BrowserFallbackReason::RedirectNotRegistered,
detail,
)))
}
RedirectRegistration::Unknown { detail } => {
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
BrowserFallbackReason::PreflightUnavailable,
detail,
)))
}
}
let session =
login_counting(client, endpoints, client_id, policy, Some(counter), present).await?;
Ok(AuthCodeAttempt::LoggedIn(session))
}
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。 /// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
/// ///
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 /// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
@@ -66,11 +66,29 @@ impl CallbackServer {
port: Option<u16>, port: Option<u16>,
attempts: u8, attempts: u8,
state: impl Into<String>, state: impl Into<String>,
) -> AuthResult<Self> {
Self::bind_counting(port, attempts, state, None).await
}
/// 同 [`Self::bind`],但把**每一次真正调用 `TcpListener::bind`** 记进 `counter`(M178)。
///
/// 为什么不把计数写在外层:`LoginOutcome::listeners_opened` 是「这次登录有没有偷开监听口」
/// 的唯一证据,它必须是**绑定现场**的数,不能是「进没进授权码分支」的代理指标
/// (复审 r1 P2-1 的变异 D 实测:代理指标对「真的多绑了一次」不敏感)。
/// 计数发生在 `TcpListener::bind` 调用之前:要数的是「去开了没」,不是「开成功了没」。
pub(crate) async fn bind_counting(
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
counter: Option<&std::sync::atomic::AtomicUsize>,
) -> AuthResult<Self> { ) -> AuthResult<Self> {
let wanted = port.unwrap_or(0); let wanted = port.unwrap_or(0);
let attempts = attempts.max(1); let attempts = attempts.max(1);
let mut last = String::new(); let mut last = String::new();
for attempt in 0..attempts { for attempt in 0..attempts {
if let Some(counter) = counter {
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
match TcpListener::bind(("127.0.0.1", wanted)).await { match TcpListener::bind(("127.0.0.1", wanted)).await {
Ok(listener) => { Ok(listener) => {
let port = listener let port = listener
+2 -137
View File
@@ -116,140 +116,5 @@ pub fn client_id_status() -> ClientIdStatus {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "client_id/tests.rs"]
use std::sync::Mutex; mod tests;
use super::{
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
MISSING_CLIENT_ID_MESSAGE,
};
use crate::auth::error::AuthError;
use crate::auth::microsoft::MicrosoftAuth;
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
struct EnvGuard {
saved: Option<String>,
}
impl EnvGuard {
fn clear() -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::remove_var(CLIENT_ID_ENV);
Self { saved }
}
fn set(value: &str) -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::set_var(CLIENT_ID_ENV, value);
Self { saved }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
None => std::env::remove_var(CLIENT_ID_ENV),
}
}
}
#[test]
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
// 抓不住:文案好不好读——那要靠 review。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
}
#[test]
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
let result = MicrosoftAuth::from_env();
match result {
Err(AuthError::Unsupported(message)) => {
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
// 但**人读文案**必须说「未配置」。
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持"));
}
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功"
),
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
}
}
#[test]
fn env_client_id_wins_over_build_and_the_log_says_so() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set("0000-test-env-id");
let (id, source) = resolve_client_id().expect("环境变量给了 id");
assert_eq!(id, "0000-test-env-id");
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
assert_eq!(source, ClientIdSource::Env);
let status = client_id_status();
assert!(status.is_configured());
assert_eq!(status.source(), Some(ClientIdSource::Env));
assert_eq!(
status.log_line(),
"[auth] msa client_id=configured source=env"
);
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
}
#[test]
fn missing_client_id_log_line_is_machine_readable() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
return;
}
let status = client_id_status();
assert!(!status.is_configured());
assert_eq!(status.source(), None);
assert_eq!(
status.log_line(),
"[auth] msa client_id=missing source=none"
);
}
#[test]
fn blank_env_value_counts_as_missing() {
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set(" ");
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
assert_eq!(
resolve_client_id().map(|(_, source)| source),
Some(ClientIdSource::Build),
"空白环境变量必须让位给构建期注入"
);
} else {
assert!(resolve_client_id().is_none());
}
}
}
@@ -0,0 +1,358 @@
//! `client_id` 的判据(从 `client_id.rs` 拆出来守 400 行硬线)。
//!
//! 两块:①「缺 id」文案与日志行的措辞纪律(M117);②**M178 守卫**——client id 只能构建期注入,
//! 微软轨源码里不许出现 UUID 形状的字面量(复审 r1 P2-3 加严:`concat!` / 跨行 / `format!`
//! 三种拼法都要抓得住,且不许误杀 `session.rs` 里那个 profile UUID)。
use std::sync::Mutex;
use super::{
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
MISSING_CLIENT_ID_MESSAGE,
};
use crate::auth::error::AuthError;
use crate::auth::microsoft::MicrosoftAuth;
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
struct EnvGuard {
saved: Option<String>,
}
impl EnvGuard {
fn clear() -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::remove_var(CLIENT_ID_ENV);
Self { saved }
}
fn set(value: &str) -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::set_var(CLIENT_ID_ENV, value);
Self { saved }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
None => std::env::remove_var(CLIENT_ID_ENV),
}
}
}
#[test]
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
// 抓不住:文案好不好读——那要靠 review。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
}
#[test]
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
let result = MicrosoftAuth::from_env();
match result {
Err(AuthError::Unsupported(message)) => {
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
// 但**人读文案**必须说「未配置」。
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持"));
}
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功"
),
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
}
}
#[test]
fn env_client_id_wins_over_build_and_the_log_says_so() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set("0000-test-env-id");
let (id, source) = resolve_client_id().expect("环境变量给了 id");
assert_eq!(id, "0000-test-env-id");
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
assert_eq!(source, ClientIdSource::Env);
let status = client_id_status();
assert!(status.is_configured());
assert_eq!(status.source(), Some(ClientIdSource::Env));
assert_eq!(
status.log_line(),
"[auth] msa client_id=configured source=env"
);
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
}
#[test]
fn missing_client_id_log_line_is_machine_readable() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
return;
}
let status = client_id_status();
assert!(!status.is_configured());
assert_eq!(status.source(), None);
assert_eq!(
status.log_line(),
"[auth] msa client_id=missing source=none"
);
}
#[test]
fn blank_env_value_counts_as_missing() {
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set(" ");
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
assert_eq!(
resolve_client_id().map(|(_, source)| source),
Some(ClientIdSource::Build),
"空白环境变量必须让位给构建期注入"
);
} else {
assert!(resolve_client_id().is_none());
}
}
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
///
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
#[test]
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
let manifest = env!("CARGO_MANIFEST_DIR");
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
.expect("读 client_id.rs");
assert!(
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
"client id 的构建期注入点不见了"
);
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
// 测试向量,与 client id 无关。
let mut offenders = Vec::new();
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path.display().to_string());
continue;
}
if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
if contains_uuid_literal(&text) {
offenders.push(path.display().to_string());
}
}
}
}
assert!(
offenders.is_empty(),
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
);
}
/// 源码里有没有「Azure 客户端 id 形状」的字面量(复审 r1 P2-3 加严)。
///
/// 两条判据,覆盖复审点名的三种拼法:
/// 1. **带连字符**(`8-4-4-4-12`):把全部字面量首尾相接后扫,于是
/// `concat!("83fd9b2f-", "da89-…")`、跨行相邻两段、单段完整 UUID 都能抓到;
/// 2. **无连字符的 32 位十六进制、且由 ≥2 个**连续**的「纯十六进制字面量**拼起来
/// (`format!("{}-{}-{}-{}", "83fd9b2f", "da89", …)` 这类逐段拼法)。
///
/// 为什么判据 2 要求 ≥2 段:`session.rs` 的测试数据里有 profile UUID
/// (`"0f8fad5bd9cb469fa16570867728950e"`)——单个字面量的 32 位 hex 是**档案 id**,
/// 不是 client id;只有「拼出来的」才可疑。也不能把字面量无分隔地全部相接去数 hex 段:
/// 那样下一个字面量的开头若恰好是 hex 字符(如 `"Bearer"` 的 `Bea`)会把上一段续长造成误杀。
fn contains_uuid_literal(text: &str) -> bool {
let literals = string_literals(text);
let joined: String = literals.iter().map(String::as_str).collect();
if has_dashed_uuid(&joined) {
return true;
}
// 判据 2:连续的「纯 hex 字面量」累计长度 ≥32 且至少两段。
let mut run_len = 0;
let mut run_parts = 0;
for literal in &literals {
let pure_hex = !literal.is_empty() && literal.bytes().all(|b| b.is_ascii_hexdigit());
if pure_hex {
run_len += literal.len();
run_parts += 1;
if run_len >= 32 && run_parts >= 2 {
return true;
}
} else {
run_len = 0;
run_parts = 0;
}
}
false
}
/// 收集源码里的字符串字面量内容(跳过注释;`\` 转义按「跳一个字符」处理)。
///
/// 为什么跳注释:本模块的文档里就写着 `concat!("83fd9b2f-", "da89-…")` 这类**说明性**例子,
/// 不跳的话自检样本会被自己的文档抓成 offender(实测踩过)。注释里出现 id 不构成「代码里
/// 硬编码 id」,判据只管可执行的字面量。
///
/// 近似处(够用即可):字符字面量 `'a'` 不当字符串;`r#"…"#` 按普通字面量近似(前缀 `r`
/// 不在引号内,不影响内容提取)。
fn string_literals(source: &str) -> Vec<String> {
let bytes = source.as_bytes();
let mut out = Vec::new();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
// 行注释:跳到行尾
b'/' if bytes.get(index + 1) == Some(&b'/') => {
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
}
// 块注释:跳到 `*/`
b'/' if bytes.get(index + 1) == Some(&b'*') => {
index += 2;
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
{
index += 1;
}
index = (index + 2).min(bytes.len());
}
b'"' => {
index += 1;
let start = index;
let mut escaped = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
index += 1;
continue;
}
match byte {
b'\\' => {
escaped = true;
index += 1;
}
b'"' => break,
_ => index += 1,
}
}
let end = index.min(bytes.len());
out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned());
index += 1;
}
_ => index += 1,
}
}
out
}
/// 拼接后的串里有没有 `8-4-4-4-12` 的十六进制段(间距 5-5-5-13)。
fn has_dashed_uuid(text: &str) -> bool {
fn hex(value: &[u8]) -> bool {
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
}
let bytes = text.as_bytes();
let dashes: Vec<usize> = bytes
.iter()
.enumerate()
.filter(|(_, byte)| **byte == b'-')
.map(|(index, _)| index)
.collect();
dashes.windows(4).any(|window| {
let (first, last) = (window[0], window[3]);
let gaps = [
window[1] - first,
window[2] - window[1],
window[3] - window[2],
];
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
return false;
}
hex(&bytes[first - 8..first])
&& hex(&bytes[first + 1..window[1]])
&& hex(&bytes[window[1] + 1..window[2]])
&& hex(&bytes[window[2] + 1..last])
&& hex(&bytes[last + 1..last + 13])
})
}
/// 判据自检:三种拼法都要抓得住,且不能把普通代码当 client id。
///
/// 没有这条,扫描可能因为判据本身坏掉而永远为真(假绿)。
/// 样本**运行时拼、且源码里不出现十六进制字面量**(用 `char` 数组拼):否则这条自检的
/// 样本本身会被扫描抓成 offender——自指陷阱(实测踩过一次)。
#[test]
fn the_uuid_literal_detector_bites_three_shapes() {
let part = |chars: &[char]| chars.iter().collect::<String>();
let head = part(&['8', '3', 'f', 'd', '9', 'b', '2', 'f']);
let p2 = part(&['d', 'a', '8', '9']);
let p3 = part(&['4', '1', 'f', '7']);
let p4 = part(&['b', '4', '1', 'b']);
let tail = part(&['1', '3', '7', '4', '2', 'a', '7', '3', '8', '4', '1', 'c']);
let uuid = format!("{head}-{p2}-{p3}-{p4}-{tail}");
// ① 单个完整字面量(基线)。
let plain = format!("let id = \"{uuid}\";");
assert!(contains_uuid_literal(&plain), "{plain}");
// ② `concat!` 拼出来(复审点名的形态之一)。
let concatenated = format!("const ID: &str = concat!(\"{head}-\", \"{p2}-{p3}-{p4}-{tail}\");");
assert!(contains_uuid_literal(&concatenated), "{concatenated}");
// ③ 跨行两段字面量。
let split_lines =
format!("let id = concat!(\n \"{head}-{p2}-\",\n \"{p3}-{p4}-{tail}\"\n);");
assert!(contains_uuid_literal(&split_lines), "{split_lines}");
// ④ `format!` 逐段拼(无连字符落点,靠「连续纯 hex 字面量累计 ≥32 且 ≥2 段」抓)。
let formatted = format!(
"let id = format!(\"{{}}-{{}}-{{}}-{{}}-{{}}\", \"{head}\", \"{p2}\", \"{p3}\", \"{p4}\", \"{tail}\");"
);
assert!(contains_uuid_literal(&formatted), "{formatted}");
// 反例:这些都不许误杀。
assert!(!contains_uuid_literal("// 0000-test-client"));
assert!(!contains_uuid_literal("a-b-c-d"));
assert!(!contains_uuid_literal(
"let uri = \"http://localhost/auth?client_id=not-a-uuid\";"
));
// 单个字面量的 32 位 hex = 既有测试数据(`session.rs` 的 profile UUID),不算 client id。
let profile = format!("let profile_id = \"{head}{p2}{p3}{p4}{tail}\";");
assert!(!contains_uuid_literal(&profile), "{profile}");
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
assert!(!contains_uuid_literal(
"let x = \"abcdefgh-1234-5678-9012-12345678901\";"
));
}
+195
View File
@@ -0,0 +1,195 @@
//! 「浏览器优先、失败自动回退设备码」(M178)——GUI 那条可选加速路的编排。
//!
//! ## 为什么这条流必须自己会退
//!
//! 授权码(浏览器)流把用户送回 `http://localhost:{port}/auth`。随机端口只有在「回环 URI
//! 的端口不参与匹配」成立时才可能被接受——微软官方文档
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
//! *Localhost exceptions*)原文:
//!
//! > "Due to ephemeral port ranges often required by native applications, the port component
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI."
//!
//! 也就是说**注册一次 `http://localhost/auth` 就能接住任意端口**;但本项目这个注册当初是为
//! device-code 建的,重定向 URI 一项都没登记(`DevDocs/ops/azure-msa-app.md`)。没登记时
//! 授权端点当场回 `invalid_request ... 'redirect_uri' is not valid`,**而且不会**再跳回本地
//! 回调——用户只看到一张 AAD 原始错误页,启动器干等到超时。这正是 2026-11-08 官方产物上
//! 「微软登录整体不可用」的根因。
//!
//! 于是这里的分工是:设备码当默认(不依赖任何注册项),浏览器流只在**用户显式要求**时试,
//! 而且试之前先预检([`super::preflight`]):没登记就一个监听口都不开。
//!
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::auth::error::{AuthError, AuthResult};
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
use super::chain;
use super::flow::{self, DeviceCodePrompt};
use super::now_ms;
use super::preflight;
use super::session::MicrosoftSession;
use super::MicrosoftAuth;
/// 「浏览器优先但没走成」的原因(**上屏只看码**,`detail` 只进日志)。
///
/// 三个码对应三种不同的下一步:
/// - [`BrowserFallbackReason::RedirectNotRegistered`]:应用注册里没登记回环重定向 URI。
/// 修法是**具体可执行的**(把 `http://localhost/auth` 加进「重定向 URI」,端口不参与匹配)。
/// - [`BrowserFallbackReason::PreflightUnavailable`]:没法确认(网络/超时),只能先用设备码。
/// - [`BrowserFallbackReason::AttemptFailed`]:试过了但失败(超时、换 token 被拒…)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserFallbackReason {
/// 授权端点当场拒绝了这个回环 `redirect_uri`(`invalid_request` / `AADSTS50011`)
RedirectNotRegistered,
/// 预检本身没问出结论(网络/超时/非预期应答)
PreflightUnavailable,
/// 预检通过、但授权码流仍然没走通
AttemptFailed,
}
impl BrowserFallbackReason {
/// 稳定的 ascii 码(日志 / 页面「Code: …」/ 测试断言都用它)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::RedirectNotRegistered => "redirect_not_registered",
Self::PreflightUnavailable => "preflight_unavailable",
Self::AttemptFailed => "attempt_failed",
}
}
}
/// 回退到设备码时的说明:原因码 + 原始诊断(诊断只进日志,不上屏)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowserFallback {
/// 原因码
pub reason: BrowserFallbackReason,
/// 原始诊断(**只进日志**;截断到 240 字符)
pub detail: String,
}
impl BrowserFallback {
/// 构造(诊断串截断:只进日志,别把整张错误页灌进去)。
#[must_use]
pub fn new(reason: BrowserFallbackReason, detail: impl Into<String>) -> Self {
let detail = detail.into();
let detail = match detail.char_indices().nth(240) {
Some((index, _)) => format!("{}…", &detail[..index]),
None => detail,
};
Self { reason, detail }
}
}
/// 一次「浏览器优先」登录的完整结果(M178)。
///
/// `listeners_opened` 是**这次登录**真的去开本地监听口的次数(0 或 1,`bind_counting` 在
/// `TcpListener::bind` 现场 +1)。它存在的唯一理由是守卫能直接数:「没登记回环」「走设备码」
/// 这两条路上必须是 0。用进程级全局计数器做不到这一点(并行跑的测试会互相搅动),所以计数
/// 是 per-login 的 `AtomicUsize`,但**增长点在绑定现场**,不在「进没进某个分支」。
#[derive(Debug, Clone)]
pub struct LoginOutcome {
/// 拿到的会话(两条流任意一条走通)
pub session: MicrosoftSession,
/// 回退说明(`None` = 没走浏览器 / 浏览器走通了)
pub browser: Option<BrowserFallback>,
/// 这次登录一共开了几个本地监听口(设备码路恒 0)
pub listeners_opened: usize,
}
impl LoginOutcome {
/// 浏览器(授权码)流是不是被跳过了。
#[must_use]
pub fn browser_was_skipped(&self) -> bool {
self.browser.is_some()
}
}
impl MicrosoftAuth {
/// 浏览器优先、失败自动回退设备码(M178:GUI 的「用浏览器登录」入口)。
///
/// 顺序与判据:
/// 1. 先预检回环注册([`authcode::login_checked`])。没登记 / 问不出来 → **不绑端口**、
/// 不弹浏览器,带一条结构化说明直接进设备码;
/// 2. 预检通过 → 真走授权码流(绑端口 + 弹浏览器);
/// 3. 授权码流失败(超时 / 换 token 被拒 / 端口绑不上)→ 同样回退设备码,
/// 说明码是 [`BrowserFallbackReason::AttemptFailed`](拒绝原因若属于「重定向 URI 没登记」
/// 则归一成 [`BrowserFallbackReason::RedirectNotRegistered`],好让用户看到怎么修);
/// 4. **用户自己取消不算失败**([`AuthError::Canceled`] 原样上抛):那是明确意图,
/// 不能偷偷换一条流继续问用户要码。
///
/// `present_device` 额外收到回退说明(`None` = 根本没走浏览器),宿主据此在设备码面板上
/// 画一句「为什么改用设备码 + 怎么修」。
///
/// # Errors
///
/// 用户取消、设备码流本身的失败(网络/超时/被拒/无拥有权/无档案),见 [`AuthError`]。
pub async fn browser_then_device_login<FB, FD>(
&self,
present_browser: FB,
mut present_device: FD,
) -> AuthResult<LoginOutcome>
where
FB: FnOnce(&AuthCodePrompt),
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
{
let mut fallback: Option<BrowserFallback> = None;
// 绑定现场的**真计数**(`CallbackServer::bind_counting` 每次调 `TcpListener::bind` 前 +1):
// 不是「进没进授权码分支」的代理指标——那正是复审 r1 P2-1 变异 D 打穿的地方。
let listeners = AtomicUsize::new(0);
if self.browser_first {
match authcode::login_checked(
&self.client,
&self.endpoints,
&self.client_id,
self.authcode.clone(),
&listeners,
present_browser,
)
.await
{
Ok(AuthCodeAttempt::LoggedIn(session)) => {
return Ok(LoginOutcome {
session,
browser: None,
listeners_opened: listeners.load(Ordering::Relaxed),
})
}
// 跳过:**没绑端口**(预检在绑定之前),计数仍是 0。
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
Err(error) => {
self.record_failure("authcode_login", &error);
let reason = if preflight::error_means_unregistered(&error) {
BrowserFallbackReason::RedirectNotRegistered
} else {
BrowserFallbackReason::AttemptFailed
};
fallback = Some(BrowserFallback::new(reason, error.to_string()));
}
}
}
let grant =
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
present_device(&grant.prompt, fallback.as_ref());
let tokens = flow::poll_device_token(
&self.client,
&self.endpoints,
&self.client_id,
&grant,
self.poll,
)
.await?;
let session =
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
Ok(LoginOutcome {
session,
browser: fallback,
listeners_opened: listeners.load(Ordering::Relaxed),
})
}
}
+46 -12
View File
@@ -1,29 +1,39 @@
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
//! //!
//! 分六块,各管一件事:
//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络); //! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络);
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); //! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); //! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 +
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token); //! 回环注册预检 + 回退设备码的编排);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//! //!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`], //! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。 //! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//! # 默认走哪条(M178)
//!
//! **设备码是默认**,授权码是可选加速路:授权码流要求应用注册里登记回环重定向 URI
//! (`http://localhost/auth`,端口由微软忽略),而本项目的注册当初只为 device-code 而建。
//! 没登记时授权端点当场拒掉且**不**把浏览器送回本地回调,用户只看到
//! `invalid_request ... 'redirect_uri' is not valid` 那张 AAD 原始错误页。所以:
//!
//! | 入口 | 走什么 |
//! |---|---|
//! | [`MicrosoftAuth::device_code_login`] | 设备码,**一个监听口都不开** |
//! | [`MicrosoftAuth::browser_then_device_login`] | 先预检回环注册:没登记就直接设备码;登记了走授权码,失败回退设备码 |
//! | [`MicrosoftAuth::authcode_login`] | 强制授权码(测试/排障用,不做预检) |
//! //!
//! # client id 从哪来 //! # client id 从哪来
//! //!
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、 //! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 //! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配;
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 //! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码,
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是 //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是
//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])—— //! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
//! //!
//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在 //! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。
//! [`client_id`],本模块只消费结论。
pub mod authcode; pub mod authcode;
pub mod callback; pub mod callback;
@@ -31,11 +41,15 @@ pub mod chain;
pub mod client_id; pub mod client_id;
pub mod endpoints; pub mod endpoints;
pub mod flow; pub mod flow;
pub mod login;
pub mod preflight;
pub mod session; pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer; pub use callback::CallbackServer;
// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。
pub use preflight::{RedirectRegistration, PROBE_PORT};
// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认 // client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认
// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。 // `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。
pub use client_id::{ pub use client_id::{
@@ -44,6 +58,7 @@ pub use client_id::{
}; };
pub use endpoints::MicrosoftEndpoints; pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy}; pub use flow::{DeviceCodePrompt, PollPolicy};
pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -63,6 +78,7 @@ pub struct MicrosoftAuth {
endpoints: MicrosoftEndpoints, endpoints: MicrosoftEndpoints,
poll: PollPolicy, poll: PollPolicy,
authcode: AuthCodePolicy, authcode: AuthCodePolicy,
browser_first: bool,
journal: Option<AuthJournal>, journal: Option<AuthJournal>,
} }
@@ -81,6 +97,7 @@ impl MicrosoftAuth {
endpoints: MicrosoftEndpoints::consumers(), endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(), poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(), authcode: AuthCodePolicy::default(),
browser_first: false,
journal: None, journal: None,
}) })
} }
@@ -155,6 +172,22 @@ impl MicrosoftAuth {
self self
} }
/// 「浏览器优先」(M178):授权码流只是**可选加速路**,默认不走。
///
/// 只有宿主确认用户显式要求时才置位(GUI 的「用浏览器登录」按钮)。置位后仍然先做
/// 回环注册预检([`preflight`]):没登记就**不绑端口、不弹浏览器**,直接设备码。
#[must_use]
pub fn with_browser_first(mut self, enabled: bool) -> Self {
self.browser_first = enabled;
self
}
/// 是否允许尝试浏览器(授权码)流。
#[must_use]
pub fn browser_first(&self) -> bool {
self.browser_first
}
/// 当前端点表。 /// 当前端点表。
#[must_use] #[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints { pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -354,13 +387,14 @@ mod tests {
.endpoints() .endpoints()
.profile .profile
.contains("api.minecraftservices.com")); .contains("api.minecraftservices.com"));
// 默认**不是**浏览器优先(M178:设备码才是默认路)。
assert!(!auth.browser_first());
} }
#[test] #[test]
fn now_ms_is_sane() { fn now_ms_is_sane() {
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
let now = now_ms(); let now = now_ms();
assert!(now > 1_577_836_800_000); assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000);
assert!(now < 4_102_444_800_000);
} }
} }
+340
View File
@@ -0,0 +1,340 @@
//! 授权码流的「回环重定向 URI 注册预检」(M178)。
//!
//! ## 为什么需要预检
//!
//! 授权码流把浏览器送回 `http://localhost:{port}/auth`。**随机端口**只有在「回环 URI 的
//! 端口不参与匹配」这件事成立时才可能被接受——微软官方文档写得很清楚
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
//! 小节 *Localhost exceptions*):
//!
//! > "Due to ephemeral port ranges often required by native applications, the port component
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI.
//! > As a result, all of these URIs are considered equivalent: `http://localhost/MyApp`,
//! > `http://localhost:1234/MyApp`, `http://localhost:5000/MyApp`, `http://localhost:8080/MyApp`"
//!
//! 换句话说:**注册一次 `http://localhost/auth` 就能接住任意端口**;但**一次都没注册**时,
//! 任何端口的回环 URI 都会被授权端点当场拒掉(`invalid_request: ... 'redirect_uri' is not valid`),
//! 而且**不会**再跳回本地回调——用户只在浏览器里看到一张 AAD 错误页,启动器只能干等超时。
//! 本项目的 Azure 应用注册当初是为 device-code 建的,重定向 URI 一项都没登记
//! (`DevDocs/ops/azure-msa-app.md`),所以这条流在官方产物上必然失败。
//!
//! ## 预检做什么、不做什么
//!
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
//! 监听口一次都不开(守卫断言 `LoginOutcome::listeners_opened == 0`,那个数是
//! [`super::callback::CallbackServer::bind_counting`] 在 `TcpListener::bind` 现场加的)。
//!
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
//! `Unknown`(网络问题/整体超时)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
//! 整次预检有 [`PREFLIGHT_TIMEOUT`] 的上限:服务器接了连接却不出响应时,不能让「浏览器登录」
//! 按钮把界面卡在 Requesting(取消按钮只收面板、停不掉已经跑起来的线程)。
use reqwest::Client;
use crate::auth::error::AuthError;
use super::authcode::{authorization_url, RedirectUri};
use super::endpoints::MicrosoftEndpoints;
/// 预检用的回环端口。
///
/// **不是要绑它**:端口对回环匹配不参与(见模块文档引的那段官方原文),所以随便一个
/// 「看起来像临时端口」的值就能代表真实绑定后的那个。固定值让日志与测试可复现。
pub const PROBE_PORT: u16 = 53_691;
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
const PROBE_STATE: &str = "ull-redirect-probe";
/// 整次预检的时间上限(复审 r1 P2-2)。
///
/// 为什么必须有:下载引擎那个 HTTP 客户端只设了 `connect_timeout`(10s),**没有**整体超时
/// ——`read_timeout` 从未应用到 reqwest 客户端。授权端点接了连接却不出响应(或 header 后停住)时,
/// 这一次「用浏览器登录」会把界面卡在 Requesting,而取消按钮只收面板、停不掉已经跑起来的线程。
/// 超时按 [`RedirectRegistration::Unknown`] 处理:回退设备码,原因码「没确认」。
pub const PREFLIGHT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// 授权端点对 `redirect_uri` 的态度。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RedirectRegistration {
/// 注册里有这个回环 URI(端口随意)→ 可以真的走授权码流。
Registered,
/// 明确没登记(`invalid_request` / `AADSTS50011`)→ 别试,直接设备码。
NotRegistered {
/// 诊断原文(只进日志)
detail: String,
},
/// 问不出来(网络/超时/非预期应答)→ 同样不试,但原因是「没确认」而不是「没配」。
Unknown {
/// 诊断原文(只进日志)
detail: String,
},
}
impl RedirectRegistration {
/// 是否值得继续走授权码流。
#[must_use]
pub fn is_registered(&self) -> bool {
matches!(self, Self::Registered)
}
}
/// 预检:注册里有没有这个回环重定向 URI。
///
/// 判据(顺序即优先级):
/// 1. 应答里出现 `invalid_request` / `AADSTS50011` / `redirect_uri is not valid` 一类字样 → 没登记;
/// 2. 非 2xx/3xx → 按「没登记」处理(AAD 对不合法的 redirect_uri 直接渲染错误页,状态码 400);
/// 3. 其余 2xx/3xx 且没有拒绝字样 → 认为已登记(那就是登录页本身);
/// 4. 连不上 → `Unknown`。
///
/// 阅读提示:这条判据**宁可错杀**——错过一次浏览器登录只是慢一步,误放行会让用户撞上
/// AAD 的原始错误页,而那正是本 mission 要消灭的现象。
pub(super) async fn check(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
) -> RedirectRegistration {
check_with_deadline(
client,
endpoints,
client_id,
redirect_uri,
PREFLIGHT_TIMEOUT,
)
.await
}
/// [`check`] 的可注入 deadline 版本(单测用短 deadline,不必等 10 秒)。
pub(super) async fn check_with_deadline(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
deadline: std::time::Duration,
) -> RedirectRegistration {
match tokio::time::timeout(deadline, probe(client, endpoints, client_id, redirect_uri)).await {
Ok(verdict) => verdict,
Err(_) => RedirectRegistration::Unknown {
detail: format!(
"预检 {} 毫秒内没有结论,按「没确认」处理",
deadline.as_millis()
),
},
}
}
/// 真正的预检请求(不含超时;由 [`check_with_deadline`] 套 deadline)。
async fn probe(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
) -> RedirectRegistration {
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
let response = client.get(&url).send().await;
let response = match response {
Ok(response) => response,
Err(error) => {
return RedirectRegistration::Unknown {
detail: format!("预检请求失败:{error}"),
}
}
};
let status = response.status().as_u16();
// 登录页可能带上几十上百 KB 的脚本;拒绝页只有一小段。读整页是为了抓正文里的错误码,
// reqwest 的 `text()` 会把正文一次读尽(授权页不含任何凭据,不需要脱敏)。
let body = response.text().await.unwrap_or_default();
if rejects_redirect_uri(&body) {
return RedirectRegistration::NotRegistered {
detail: format!("HTTP {status}:{}", snippet(&body)),
};
}
if !(200..400).contains(&status) {
return RedirectRegistration::NotRegistered {
detail: format!("HTTP {status}:{}", snippet(&body)),
};
}
RedirectRegistration::Registered
}
/// 这个错误是不是「重定向 URI 没登记」那一类(`AuthError` 侧重判,回调/换 token 用)。
///
/// 与 [`rejects_redirect_uri`] 同源:一个看页面正文,一个看结构化错误。两者都覆盖
/// `invalid_request` 与 `AADSTS50011`,因为同一件事在两条流上的形状不一样。
#[must_use]
pub(super) fn error_means_unregistered(error: &AuthError) -> bool {
match error {
AuthError::Denied { code, description } => {
is_redirect_code(code) || redirects_rejected(description)
}
AuthError::Response(detail) => {
redirects_rejected(detail.server_message.as_deref().unwrap_or_default())
|| redirects_rejected(detail.digest.as_str())
}
_ => false,
}
}
fn is_redirect_code(code: &str) -> bool {
matches!(
code,
"invalid_request"
| "invalid_redirect_uri"
| "redirect_uri_mismatch"
| "unauthorized_client"
)
}
fn redirects_rejected(text: &str) -> bool {
let lowered = text.to_lowercase();
lowered.contains("aadsts50011")
|| lowered.contains("invalid_request")
|| lowered.contains("redirect_uri is not valid")
|| lowered.contains("redirect_uri+is+not+valid")
|| lowered.contains("reply url specified in the request does not match")
|| (lowered.contains("redirect") && lowered.contains("not valid"))
}
/// 页面正文里有没有「重定向 URI 不合法」的拒绝标记。
fn rejects_redirect_uri(body: &str) -> bool {
let lowered = body.to_lowercase();
lowered.contains("aadsts50011")
|| lowered.contains("invalid_request")
|| lowered.contains("redirect_uri is not valid")
|| lowered.contains("redirect_uri+is+not+valid")
|| (lowered.contains("redirect") && lowered.contains("not valid"))
}
/// 正文摘要(单行、截断):只进日志,不上屏。
fn snippet(body: &str) -> String {
let one_line: String = body
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = one_line.split_whitespace().collect::<Vec<_>>().join(" ");
match collapsed.char_indices().nth(200) {
Some((index, _)) => format!("{}…", &collapsed[..index]),
None => collapsed,
}
}
#[cfg(test)]
mod tests {
use super::{
check_with_deadline, error_means_unregistered, rejects_redirect_uri, snippet,
RedirectRegistration,
};
use crate::auth::body::ResponseDetail;
use crate::auth::error::AuthError;
use crate::auth::microsoft::{MicrosoftEndpoints, RedirectUri};
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
#[test]
fn the_reported_aad_page_is_recognized_as_unregistered() {
let page = "error=invalid_request&error_description=The+provided+value+for+the+input+\
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
redirect+URI+registered+for+this+client+application.";
assert!(rejects_redirect_uri(page));
// 登录页本身(有脚本、有表单)不能被误判成拒绝。
assert!(!rejects_redirect_uri(
"<html><head><title>Sign in to your account</title></head><body>...</body></html>"
));
}
#[test]
fn aadsts50011_is_also_a_registration_problem() {
assert!(rejects_redirect_uri(
"AADSTS50011: The redirect URI 'http://localhost:61492/auth' specified in the request does not match"
));
}
/// 两条流上的同一件事:授权页拒绝(`Denied`)与 token 端点拒绝(`Response`)都要认。
#[test]
fn structured_errors_of_both_shapes_are_recognized() {
assert!(error_means_unregistered(&AuthError::Denied {
code: "invalid_request".to_string(),
description: "bad redirect".to_string(),
}));
assert!(error_means_unregistered(&AuthError::Response(Box::new(
ResponseDetail {
step: "token".to_string(),
what: "换 token".to_string(),
status: 400,
content_type: None,
kind: crate::auth::BodyKind::Json,
server_message: Some("AADSTS50011: reply url mismatch".to_string()),
digest: "digest".to_string(),
}
))));
// 不相干的失败不是注册问题(别把网络/白名单也归到这条)。
assert!(!error_means_unregistered(&AuthError::Canceled));
}
#[test]
fn registration_state_is_answered_by_one_predicate() {
assert!(RedirectRegistration::Registered.is_registered());
assert!(!RedirectRegistration::NotRegistered {
detail: String::new()
}
.is_registered());
assert!(!RedirectRegistration::Unknown {
detail: String::new()
}
.is_registered());
}
/// **复审 r1 P2-2**:服务器接了连接却永不出响应时,预检必须靠**整体 deadline** 收场
/// (不是 connect_timeout:连接早就建好了),结论是「没确认」⇒ 上层回退设备码。
///
/// 抓得住:把 `tokio::time::timeout` 去掉(那条路会一直挂着,本用例在 deadline 后
/// 断言超时不出来,必然失败)。
/// 抓不住:真机上网关的其它卡法(这里只有一个「不回字」的假服务器)。
#[tokio::test]
async fn a_silent_authorize_server_hits_the_overall_deadline() {
// 假授权端点:accept 之后一个字节都不写(connect 成功、读永远等不到)。
let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("假服务器起不来");
let port = silent.local_addr().expect("地址").port();
tokio::spawn(async move {
let mut held = Vec::new();
loop {
let Ok((stream, _)) = silent.accept().await else {
break;
};
held.push(stream);
}
});
let client =
crate::download::http::build_client(&crate::download::config::EngineConfig::default())
.expect("HTTP 客户端");
let mut endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1");
endpoints.authorize = format!("http://127.0.0.1:{port}/authorize");
let verdict = check_with_deadline(
&client,
&endpoints,
"test-client-id",
&RedirectUri::new(53_691),
std::time::Duration::from_millis(200),
)
.await;
match verdict {
RedirectRegistration::Unknown { detail } => {
assert!(detail.contains("200"), "文案要带 deadline:{detail}");
}
other => panic!("静默服务器必须走 Unknown,实际 {other:?}"),
}
}
#[test]
fn snippet_is_single_line_and_capped() {
let long = format!("a\nb\t{}", "x".repeat(400));
let text = snippet(&long);
assert!(!text.contains('\n'), "{text}");
assert!(text.ends_with('…'), "{text}");
assert_eq!(text.chars().count(), 201);
}
}
+352
View File
@@ -0,0 +1,352 @@
//! OFL 中文字体的取回(D28 后半):**缓存命中 → 共享引擎下载 → 复验 → 原子落盘**。
//!
//! ## 一段话
//!
//! `unlucky_i18n::font` 只做判据与「该下载什么」(URL 候选 + sha256 + 字节数),真正把字节弄到
//! 盘上的活在这里:
//!
//! ```text
//! <数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf
//! 已有且哈希 + 魔数都对 ⇒ 直接返回(零网络)
//! 否则 ⇒ 共享引擎的 download_bytes(进内存)
//! ⇒ 自己再算一遍 sha256、再看一眼是不是 sfnt、再看大小上限
//! ⇒ 全过才 write_atomic 落盘 ⇒ 返回字节
//! ```
//!
//! ## 为什么用「内存下载」而不是「边下边写文件」
//!
//! 一张 17 MiB 的字体本来就要整份进内存(注册进字体系统要 `Vec<u8>`),而**先复验再落盘**让
//! 「哈希不符」这条判据简单到不可能出错:盘上要么没有这个文件,要么它是校验过的。文件下载路径
//! (`DownloadEngine::download`)会先写 `<dest>.ullpart-*` 再换名,中间态与「拒绝落盘」混在一起,
//! 对这一份内容寻址的小资源没有收益。
//!
//! ## 三条判据(都有守卫,见 `tests.rs`)
//!
//! 1. **缓存命中不联网**:命中就 `from_cache`,一条 HTTP 都不发(同进程第二次、二次启动都一样);
//! 哈希不符的旧文件会被删掉重下(自愈),不冒充命中、也不永远挡路。
//! 2. **sha256 不符拒绝落盘**:复验在任何写盘动作**之前**;不符 ⇒ 正式路径上什么都没有。
//! 3. **大小上限**:[`MAX_FONT_BYTES`]。pin 声称超过上限的直接拒(不发请求);实得字节超上限的
//! 也拒。挡的是「CDN 被换成一个大文件」这类事故,不是省流量。
//!
//! ## 失败分类为什么是三类
//!
//! 上层(设置页那一行)只需决定「说什么话」:网络类可重试、校验类是内容不对、磁盘类得腾空间。
//! 再多分类对用户没有区别。
//!
//! ## 与 M165 共享引擎的关系
//!
//! **不另写 HTTP 通路**:请求一律走 [`super::shared::engine_for`] 那个进程级引擎(连接池 / 限流 /
//! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、
//! 复验、大小上限。
use std::path::Path;
use std::time::{Duration, Instant};
use super::atomic::write_atomic;
use super::blocking::block_on;
use super::error::DownloadErrorKind;
use super::shared;
use super::types::{DownloadRequest, HashAlgo, ResourceKind};
use super::verify;
/// 一套字体允许的最大字节数(32 MiB)。
///
/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)16.95 MiB;再大的「字体」基本
/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西
/// 写进数据根**。
pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
/// **非最后一个候选**的单次尝试时限(不是连接超时,也不是空闲超时)。
///
/// 为什么必须有:引擎的 `read_timeout` 是 30 s **空闲**超时,一条每秒只挤几 KB、但一直在动的
/// 连接永远不会被判失败。复审实测过这条路径(同一台机器、同一份文件):
///
/// | 路径 | 结果 |
/// |---|---|
/// | Fastly 的 IPv6 边缘 | 60 s 只到 4.0 MB(≈67 KB/s,17.7 MB 要 ≈4 分钟) |
/// | 同一域名的 IPv4 边缘 | 1.2 s 下完(≈15 MB/s) |
///
/// 60 s 是「明显不对劲就换源」的位置:健康线路(实测 1.1~16.6 s)根本用不到它。
///
/// ## 为什么**最后一个**候选不吃这个时限(复审 r2 P2-3 / r3 P2-3)
///
/// 每个候选都从 0 字节重下(不做续传),所以「单次上限」直接就是一条**吞吐门槛**:
/// `17 772 300 B / 预算`。历史两版都踩在这里:90 s ⇒ 门槛 197 KB/s;120 s + 总 360 s ⇒
/// 第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 **72.3 KiB/s**,而 r1 实测那条 IPv6 是
/// 67~77 KiB/s——**67 KiB/s 需要 259 s > 240 s,仍然必失败**(复审 r3 就是这么把上一版的
/// 「49 KB/s」拆穿的:那个数是「第一个候选瞬间失败」的情形,不是这条改动要救的场景)。
///
/// 现在的规则(见 [`fetch_blocking_with_limits`]):前 n-1 个候选各拿
/// `min(ATTEMPT_DEADLINE, 剩余)`,**最后一个候选拿整个剩余总预算**。
///
/// ## 三个数的完整算术(2 个候选的常见情形)
///
/// | 情形 | 最后一个候选拿到的预算 | 门槛 = 17 772 300 B / 预算 |
/// |---|---|---|
/// | 第一个候选瞬间失败 | 420 s | **42.3 KiB/s** |
/// | 第一个候选爬满 60 s 被切 | 360 s | **49.4 KiB/s** |
///
/// 两者都低于 r1 实测最慢的 67 KiB/s(需要 259 s),于是「慢而当当能下完」的线路仍有**一次**
/// 老老实实下完的机会。**超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。
pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(60);
/// 整条取回(所有候选加起来)的总时限,也是**最后一个候选**能拿到的预算上限。
///
/// 取值依据见 [`ATTEMPT_DEADLINE`] 的算术表:420 s 让「第一个候选爬满 60 s」这种最差情形
/// 仍给最后一个候选 360 s(49.4 KiB/s 门槛)。
///
/// 代价:最坏情形下用户要在设置页看着「正在获取中文字体…(16.9 MB)」等 7 分钟。这是刻意的
/// 取舍——**慢而最终成功**远好过**快而必然失败**(后者要用户自己再来一次,结果一样)。
pub const TOTAL_DEADLINE: Duration = Duration::from_secs(420);
/// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FontFetchError {
/// 网络层:连不上 / 超时 / 非 2xx / 所有候选源都失败。
Network(String),
/// 内容层:sha256 不符 / 不是字体文件 / 超出大小上限。
Integrity(String),
/// 本地磁盘层:建目录、写文件、换名失败。
Disk(String),
}
impl FontFetchError {
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词,**不要改名**)。
#[must_use]
pub const fn class(&self) -> &'static str {
match self {
Self::Network(_) => "network",
Self::Integrity(_) => "integrity",
Self::Disk(_) => "disk",
}
}
/// 人类可读描述(进日志的一句)。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
}
}
}
impl std::fmt::Display for FontFetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.class(), self.message())
}
}
impl std::error::Error for FontFetchError {}
impl From<super::error::DownloadError> for FontFetchError {
fn from(error: super::error::DownloadError) -> Self {
// 用叶子成因:`download_bytes` 的整体失败会带上它(全 404 → NotFound,出现网络错 → Network,
// 校验不符 → Integrity)。少了叶子这一层,哈希不符会被读成「没联网」。
let message = error.to_string();
match error.effective_kind() {
DownloadErrorKind::Io => Self::Disk(message),
DownloadErrorKind::Integrity | DownloadErrorKind::InvalidRequest => {
Self::Integrity(message)
}
_ => Self::Network(message),
}
}
}
/// 一次取回的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontFetch {
/// 字体字节(交给调用方注册进字体系统)。
pub bytes: Vec<u8>,
/// 是否零网络命中缓存。
pub from_cache: bool,
/// 成功的源标识(`cache` / 引擎给的候选源名)。
pub source: String,
/// 耗时。
pub elapsed: Duration,
}
/// 取一套字体(**阻塞**:内部走进程共享运行时)。
///
/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
///
/// # Errors
/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。
pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, FontFetchError> {
fetch_blocking_with_limits(cache_root, spec, ATTEMPT_DEADLINE, TOTAL_DEADLINE)
}
/// 同 [`fetch_blocking`],但两个时限由调用方给(单测用它把 60 s / 420 s 压成几百毫秒)。
///
/// 为什么把时限做成参数而不是读环境变量:这是**行为**判据,不是诊断开关——测试要能证明
/// 「一条只爬不死的连接会在时限处被打断并换源」,而 90 s 的用例没人会跑。
///
/// # Errors
/// 同 [`fetch_blocking`];超时归 [`FontFetchError::Network`](文案里带「超时」二字)。
pub fn fetch_blocking_with_limits(
cache_root: &Path,
spec: &FontSpec,
attempt_deadline: Duration,
total_deadline: Duration,
) -> Result<FontFetch, FontFetchError> {
let started = Instant::now();
spec.check()?;
let final_path = cache_path(cache_root, spec);
if let Some(bytes) = read_verified(&final_path, spec)? {
return Ok(FontFetch {
bytes,
from_cache: true,
source: "cache".to_owned(),
elapsed: started.elapsed(),
});
}
let engine = shared::engine_for(cache_root);
let mut network_error: Option<FontFetchError> = None;
let mut integrity_error: Option<FontFetchError> = None;
for (index, url) in spec.urls.iter().enumerate() {
// 总时限先看一眼:已经用完了就不必再开一条注定超时的连接。
let remaining = total_deadline.saturating_sub(started.elapsed());
if remaining.is_zero() {
network_error = Some(FontFetchError::Network(format!(
"取字体超过总时限 {} s(已试 {index} / {} 个候选;这是「太慢」,不是校验失败)",
total_deadline.as_secs(),
spec.urls.len()
)));
break;
}
// 前 n-1 个候选:`min(单次上限, 剩余)`;**最后一个候选**拿整个剩余总预算。
// 为什么对最后一个放宽:每个候选都从 0 字节重下,「单次上限」就是一条吞吐门槛,
// 卡死最后一个等于把「慢但能下完」判成永久失败(复审 r2 P2-3 的算术,见 ATTEMPT_DEADLINE)。
let is_last = index + 1 == spec.urls.len();
let budget = if is_last {
remaining
} else {
attempt_deadline.min(remaining)
};
let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path)
.with_sha256(&spec.sha256)
.with_size(spec.bytes);
let attempt = block_on(async {
// `tokio::time::timeout` 必须在**运行时上下文里**构造(它一创建就要注册计时器;
// 在外面构造会 panic「there is no reactor running」)。所以整段包进 async 块。
tokio::time::timeout(budget, engine.download_bytes(request)).await
});
let outcome = match attempt {
Ok(outcome) => outcome,
Err(_elapsed) => {
network_error = Some(FontFetchError::Network(format!(
"下载字体超时(单次上限 {} ms,总上限 {} ms):{url}",
budget.as_millis(),
total_deadline.as_millis()
)));
continue;
}
};
match outcome {
Ok(bytes) => {
if bytes.len() as u64 > MAX_FONT_BYTES {
integrity_error = Some(FontFetchError::Integrity(format!(
"字体超过大小上限:{} > {MAX_FONT_BYTES}",
bytes.len()
)));
continue;
}
// 引擎的流式校验与这一道是**两处独立**的判据:这里同时看哈希与魔数,且都在写盘之前。
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
if !verify::matches(&spec.sha256, &actual) {
integrity_error = Some(FontFetchError::Integrity(format!(
"字体 sha256 不符:期望 {} 实得 {actual}({url})",
spec.sha256
)));
continue;
}
if !looks_like_sfnt(&bytes) {
integrity_error = Some(FontFetchError::Integrity(format!(
"取回的不是字体文件(sfnt 魔数不符):{url}"
)));
continue;
}
write_atomic(&final_path, &bytes).map_err(|error| {
FontFetchError::Disk(format!(
"字体落盘失败({}):{error}",
final_path.display()
))
})?;
return Ok(FontFetch {
bytes,
from_cache: false,
source: url.clone(),
elapsed: started.elapsed(),
});
}
Err(error) => {
let mapped = FontFetchError::from(error);
match mapped {
FontFetchError::Integrity(_) => integrity_error = Some(mapped),
_ => network_error = Some(mapped),
}
}
}
}
// 报错时优先报「内容不对」:那说明源给了不该给的东西,比「连不上」更需要人看一眼。
Err(integrity_error
.or(network_error)
.unwrap_or_else(|| FontFetchError::Network("字体 pin 没有可用 URL".to_owned())))
}
/// 读缓存并复验(`Ok(None)` = 不存在或校验不过,**且坏文件已被删掉**)。
fn read_verified(path: &Path, spec: &FontSpec) -> Result<Option<Vec<u8>>, FontFetchError> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(FontFetchError::Disk(format!(
"读字体缓存失败({}):{error}",
path.display()
)))
}
};
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
if !verify::matches(&spec.sha256, &actual) || !looks_like_sfnt(&bytes) {
// 坏缓存自愈:删掉,交给下载路径重来(不把坏文件当命中,也不让它一直挡路)。
let _ = std::fs::remove_file(path);
return Ok(None);
}
Ok(Some(bytes))
}
/// 是不是一个 sfnt 字体文件(TTF / OTF / TTC)。
///
/// 只认魔数 + 表目录读得出来:够挡住「CDN 回了一个 HTML 错误页」这类事故,**不**做字形覆盖校验
/// (那要 `ttf-parser`,core 的依赖纪律不允许为一个判据引它)。
#[must_use]
pub fn looks_like_sfnt(bytes: &[u8]) -> bool {
if bytes.len() < 12 {
return false;
}
let tag = &bytes[..4];
if tag == b"ttcf" {
// 字体集合:只要长度说得过去就收(成员表的细节不值得在这里解析)。
return bytes.len() >= 16;
}
if !matches!(tag, b"\x00\x01\x00\x00" | b"OTTO" | b"true") {
return false;
}
let tables = u16::from_be_bytes([bytes[4], bytes[5]]);
tables > 0 && bytes.len() >= 12 + usize::from(tables) * 16
}
mod spec;
pub use spec::{cache_path, FontSpec};
#[cfg(test)]
#[path = "font/tests.rs"]
mod tests;
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more