diff --git a/crates/agent/src/brain/mod.rs b/crates/agent/src/brain/mod.rs index 152fb18..047a7f2 100644 --- a/crates/agent/src/brain/mod.rs +++ b/crates/agent/src/brain/mod.rs @@ -45,7 +45,7 @@ pub use chat::{ NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, }; 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; diff --git a/crates/agent/src/brain/provider.rs b/crates/agent/src/brain/provider.rs index 804fa3d..62525d6 100644 --- a/crates/agent/src/brain/provider.rs +++ b/crates/agent/src/brain/provider.rs @@ -14,6 +14,11 @@ use std::fmt; use serde::{Deserialize, Serialize}; +#[path = "provider/failure.rs"] +mod failure; + +pub use failure::ProviderFailure; + /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; /// 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 { + 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] pub fn display_name(self) -> &'static str { @@ -174,18 +208,22 @@ impl ProviderConfig { /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 pub fn validate(&self) -> Result<(), ProviderError> { if !self.is_enabled() { - return Err(ProviderError::new( + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", )); } 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() { - return Err(ProviderError::new(format!( - "{} 需要填写端点(base_url)", - self.kind.display_name() - ))); + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, + format!("{} 需要填写端点(base_url)", self.kind.display_name()), + )); } if self.kind.requires_api_key() && self @@ -193,10 +231,10 @@ impl ProviderConfig { .as_deref() .is_none_or(|key| key.trim().is_empty()) { - return Err(ProviderError::new(format!( - "{} 需要 API key", - self.kind.display_name() - ))); + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, + format!("{} 需要 API key", self.kind.display_name()), + )); } Ok(()) } @@ -239,20 +277,38 @@ impl fmt::Debug for ProviderConfig { } } -/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 +/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的 +/// `ErrorReport` 翻译,见 [`failure`])。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderError { + kind: ProviderFailure, message: String, } impl ProviderError { - /// 从可读文本构造。 + /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。 #[must_use] pub fn new(message: impl Into) -> Self { Self { + kind: ProviderFailure::Other, message: message.into(), } } + + /// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。 + #[must_use] + pub fn classified(kind: ProviderFailure, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + /// 失败分类(「测试连接」按它分档)。 + #[must_use] + pub fn kind(&self) -> ProviderFailure { + self.kind + } } impl fmt::Display for ProviderError { @@ -264,77 +320,5 @@ impl fmt::Display for ProviderError { impl std::error::Error for ProviderError {} #[cfg(test)] -mod tests { - 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 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}" - ); - } -} +#[path = "provider/tests.rs"] +mod tests; diff --git a/crates/agent/src/brain/provider/failure.rs b/crates/agent/src/brain/provider/failure.rs new file mode 100644 index 0000000..7b78a37 --- /dev/null +++ b/crates/agent/src/brain/provider/failure.rs @@ -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) + } +} diff --git a/crates/agent/src/brain/provider/tests.rs b/crates/agent/src/brain/provider/tests.rs new file mode 100644 index 0000000..d47d6f8 --- /dev/null +++ b/crates/agent/src/brain/provider/tests.rs @@ -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}" + ); +} diff --git a/crates/agent/src/brain/rig.rs b/crates/agent/src/brain/rig.rs index 3043564..27ce6d0 100644 --- a/crates/agent/src/brain/rig.rs +++ b/crates/agent/src/brain/rig.rs @@ -47,7 +47,7 @@ use std::sync::Arc; use futures_util::StreamExt; 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::{ AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, @@ -64,7 +64,7 @@ use super::chat::{ ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ProviderFuture, }; -use super::provider::{ProviderConfig, ProviderError, ProviderKind}; +use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind}; /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 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 { - 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`)。 +/// +/// 结构化判据(类型 + `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::() { + return io.kind() == std::io::ErrorKind::TimedOut; + } + source = cause.source(); + } + false } /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 @@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result { } #[cfg(test)] -mod tests { - 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("对话历史为空")); - } -} +#[path = "rig/tests.rs"] +mod tests; diff --git a/crates/agent/src/brain/rig/tests.rs b/crates/agent/src/brain/rig/tests.rs new file mode 100644 index 0000000..5ffa21c --- /dev/null +++ b/crates/agent/src/brain/rig/tests.rs @@ -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("对话历史为空")); +} diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 807b59c..a835487 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -130,6 +130,12 @@ pub struct App { pub(super) refreshes: u64, /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 pub(super) auto_selected: bool, + /// 上一次看到的**中文字体下载状态**(M168)。 + /// + /// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了 + /// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」, + /// 而空闲态不会为了它每拍白重建一次。 + pub(super) font_seen: crate::fonts::install::CjkInstall, /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 pub(super) height: f32, /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 7c0073e..231d64b 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -86,6 +86,10 @@ impl App { // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 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( pages::Screen::Instances, width, @@ -114,6 +118,9 @@ impl App { frames: 0, refreshes: 0, auto_selected: false, + // M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()` + // 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。 + font_seen: crate::fonts::install::status(), height, appearance, appearance_prefs, @@ -142,6 +149,10 @@ impl App { }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 app.rebuild_ui(); + // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 + app.hydrate_ai_settings(); + // M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。 + app.hydrate_java_settings(); // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 app.lang.log(); diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 15393d6..1511ebb 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -87,6 +87,8 @@ impl App { self.has_screenshot() || !self.state.toasts.is_empty() || !self.state.running().is_empty() + // M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。 + || self.settings.java.scanning.is_some() || self .watch_until .is_some_and(|deadline| Instant::now() < deadline) diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index c2a8467..0851445 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -95,7 +95,7 @@ impl App { self.dispatch(AppCommand::AddAuthlibAccount(request)); } } - Panel::Microsoft => return self.on_account(Account::StartBrowser), + Panel::Microsoft => return self.on_account(Account::StartDevice), Panel::None => {} }, Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), @@ -136,7 +136,8 @@ impl App { match account.kind { AccountKind::Microsoft => { self.account_form.panel = Panel::Microsoft; - self.dispatch(AppCommand::StartBrowserLogin); + // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。 + self.dispatch(AppCommand::StartDeviceLogin); } AccountKind::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 = 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)` "authlib-failed" => { app.account_form.panel = crate::pages::account::Panel::Authlib; @@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) { "closed" => { 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" => { app.account_form.section_open = true; diff --git a/crates/app-iced/src/app/update/account/tests.rs b/crates/app-iced/src/app/update/account/tests.rs index d74492b..4d43d37 100644 --- a/crates/app-iced/src/app/update/account/tests.rs +++ b/crates/app-iced/src/app/update/account/tests.rs @@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() { let _ = app.on_account(Account::StartDevice); match &app.account.device { DevicePanel::Code { - code, remaining, .. + code, countdown, .. } => { assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); - assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); + assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)"); } other => panic!("进行中状态必须可见,实际 {other:?}"), } diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index da1c759..31f9aed 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -42,6 +42,16 @@ impl App { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { 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, // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); @@ -68,6 +78,10 @@ impl App { self.apply_config_theme(); // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 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 { diff --git a/crates/app-iced/src/app/update/settings/ai_about.rs b/crates/app-iced/src/app/update/settings/ai_about.rs index 4ac6bb3..314bd49 100644 --- a/crates/app-iced/src/app/update/settings/ai_about.rs +++ b/crates/app-iced/src/app/update/settings/ai_about.rs @@ -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 crate::pages::settings::ai::{Probe, ProbeResult}; + impl App { - /// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。 + /// 助手分组的一条消息。 pub(super) fn on_settings_ai_about( &mut self, message: pages::settings::ai_about::Message, ) -> Task { - 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 { + 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 { + 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)), + ), + )) + }, + ) } } diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index a8f3fe7..7747a6f 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -1,33 +1,291 @@ -//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 +//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。 //! -//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 -//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 +//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在 +//! `pages/settings/java/{state,store}.rs`。本文件只有四件事: //! -//! 消息进来的通道有两条,都不经过共享文件: -//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; -//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` -//! (`app::Message::Page` 是既有通道,不用加新变体)。 +//! | 消息 | 做什么 | +//! |---|---| +//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) | +//! | `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 crate::pages::settings::java::{Context, Layer, Notice, Requirement}; + impl App { /// Java 分组的一条消息。 pub(super) fn on_settings_java( &mut self, message: pages::settings::java::Message, ) -> Task { + use pages::settings::java::Message; + match message { - // M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 - // 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 - #[cfg(test)] - pages::settings::java::Message::Ping => { - self.view_dirty = true; - Task::none() + Message::Scan => return self.start_java_scan(), + Message::CancelScan => { + self.settings.java.scan.cancel(); + 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=` 用种子(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 { + 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::>(); + 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; + 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 } } } diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs index 150f2ba..8d42d94 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { let mut app = headless(); app.view_dirty = false; + // M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗 + // 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。 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!( @@ -265,3 +269,128 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { "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; diff --git a/crates/app-iced/src/app/update/settings/tests/java.rs b/crates/app-iced/src/app/update/settings/tests/java.rs new file mode 100644 index 0000000..9ff9e55 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/tests/java.rs @@ -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 { + 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(); +} diff --git a/crates/app-iced/src/components/mod.rs b/crates/app-iced/src/components/mod.rs index ac7d52c..b5289b6 100644 --- a/crates/app-iced/src/components/mod.rs +++ b/crates/app-iced/src/components/mod.rs @@ -10,6 +10,7 @@ //! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法 //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! divider.rs ← COMPBASE(15 §22) +//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! inputs/ ← 同上(§9/§20/§11/§8) //! feedback/ ← 同上(§12/§14/§7/§15) @@ -52,6 +53,7 @@ pub mod inputs; pub mod interaction; pub mod layout; pub mod navigation; +pub mod scroll; pub mod selection; #[cfg(test)] diff --git a/crates/app-iced/src/components/scroll.rs b/crates/app-iced/src/components/scroll.rs new file mode 100644 index 0000000..e67baa1 --- /dev/null +++ b/crates/app-iced/src/components/scroll.rs @@ -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> +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> +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); + } +} diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs index e84353d..fc149a1 100644 --- a/crates/app-iced/src/components/selection/chip.rs +++ b/crates/app-iced/src/components/selection/chip.rs @@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> { 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)。 /// /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index 8690f05..e1b123e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -18,18 +18,31 @@ //! ## 不做什么 //! -//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半, -//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。 +//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活, +//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。 //! -//! ## 中文那一半(M92 / D28 后半) +//! ## 中文那一半(M92 / D28 后半 / M168 实装下载) //! //! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), //! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), //! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、 //! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是 //! 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; +pub mod install; use cjk::CjkReport; use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch}; @@ -173,16 +186,30 @@ fn register_termux_fonts() { } /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 +/// +/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是 +/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入, +/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。 pub fn resolve_with(setting: Option<&str>) -> Resolved { 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 faces = system_faces(); let is_installed = |family: &str| -> bool { - faces - .as_ref() - .is_none_or(|faces: &Vec| faces.iter().any(|face| face.family == family)) + faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family)) }; let families = [ @@ -206,11 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved { // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 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` 在这里是「字体库读不出来」, // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 - let cjk_report = cjk::probe_with(setting, faces.as_deref()); + let cjk_report = cjk::probe_with(setting, faces); Resolved { platform, @@ -220,20 +247,34 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved { } } -/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值** -/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 +/// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里 +/// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 /// /// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 /// 手选「简体中文」时,若不复算,策略会停在 `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 { - 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)] -struct Face { +pub struct Face { family: String, weight: u16, } diff --git a/crates/app-iced/src/fonts/cdn.rs b/crates/app-iced/src/fonts/cdn.rs new file mode 100644 index 0000000..5d022e8 --- /dev/null +++ b/crates/app-iced/src/fonts/cdn.rs @@ -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(交给 install 注册进 iced 的字体系统) +//! ``` +//! +//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。 +//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的 +//! 单测)。 +//! +//! ## 「不联网」的三条路 +//! +//! | 情形 | 谁拦住 | +//! |---|---| +//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] | +//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) | +//! | 二次启动 | 同上:缓存文件按 `` 命名,命中即不联网 | +//! +//! ## 设置改写只换 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>, +} + +impl CdnFetcher { + /// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。 + /// + /// # Errors + /// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。 + pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result { + 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 { + 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, 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 { + let Source::Pinned { + urls, + sha256, + bytes, + } = font.source; + + let mut candidates: Vec = 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")); + } +} diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs index 346f6f4..9d5be2a 100644 --- a/crates/app-iced/src/fonts/cjk.rs +++ b/crates/app-iced/src/fonts/cjk.rs @@ -19,8 +19,9 @@ //! //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` -//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); -//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。 +//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过); +//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] / +//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。 //! //! ## `ui.language` 的接线(M106) //! diff --git a/crates/app-iced/src/fonts/install.rs b/crates/app-iced/src/fonts/install.rs new file mode 100644 index 0000000..afe9f87 --- /dev/null +++ b/crates/app-iced/src/fonts/install.rs @@ -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 { + static STATE: OnceLock> = 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 { + static FORCED: OnceLock> = 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 { + 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) -> Result, +) -> 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; diff --git a/crates/app-iced/src/fonts/install/tests.rs b/crates/app-iced/src/fonts/install/tests.rs new file mode 100644 index 0000000..8d1267f --- /dev/null +++ b/crates/app-iced/src/fonts/install/tests.rs @@ -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, + seen: RefCell>, + result: Result, FetchError>, +} + +impl FakeFetcher { + fn new(result: Result, 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, FetchError> { + *self.calls.borrow_mut() += 1; + *self.seen.borrow_mut() = Some(font.family); + self.result.clone() + } +} + +/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。 +type Registered = std::rc::Rc>>; + +/// 假注册器:记收到的字节数,返回一个族名。 +fn recorder() -> (Registered, impl Fn(Vec) -> Result) { + let seen = std::rc::Rc::new(RefCell::new(Vec::new())); + let recorder = std::rc::Rc::clone(&seen); + let register = move |bytes: Vec| { + 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, ®ister), + CjkInstall::NotNeeded + ); + assert_eq!( + ensure_for(Strategy::System { family: "SimSun" }, &idle, ®ister), + CjkInstall::System { + family: "SimSun".to_owned() + } + ); + assert_eq!( + ensure_for(Strategy::Unprobeable, &idle, ®ister), + 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, ®ister), + 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, ®ister); + 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, ®ister); + 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), ¤t, 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), ¤t, 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}"); + } +} diff --git a/crates/app-iced/src/fonts/install/worker.rs b/crates/app-iced/src/fonts/install/worker.rs new file mode 100644 index 0000000..299008f --- /dev/null +++ b/crates/app-iced/src/fonts/install/worker.rs @@ -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, + ®ister_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) -> Result { + 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 = 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)) +} diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index 5616c58..d516123 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_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 = ["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:?})" + ); +} diff --git a/crates/app-iced/src/pages/account.rs b/crates/app-iced/src/pages/account.rs index a7ef912..d016ea6 100644 --- a/crates/app-iced/src/pages/account.rs +++ b/crates/app-iced/src/pages/account.rs @@ -38,6 +38,9 @@ pub mod widgets; #[cfg(test)] mod tests; #[cfg(test)] +#[path = "account/tests_msa.rs"] +mod tests_msa; +#[cfg(test)] #[path = "account/tests_text.rs"] mod tests_text; diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index 6d6c718..a11eaad 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -2,9 +2,15 @@ //! //! ## 为什么不是「一个转圈」 //! -//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, -//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 -//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, +//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。 +//! +//! ## 文案一律**借用模型里的串**(M178) +//! +//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n), +//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`: +//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白), +//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。 use iced::widget::{column, row}; use iced::{Alignment, Element}; @@ -13,7 +19,7 @@ use unlucky_design::color::Role; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; 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::pages::frame; @@ -36,34 +42,40 @@ pub fn panel<'a>( DevicePanel::Code { code, link, - remaining, + countdown, expired, - } => device_code(ui, code, link, *remaining, *expired, catalog), + note, + } => device_code(ui, code, link, countdown, *expired, *note, catalog), DevicePanel::Browser { link, - remaining, + countdown, expired, - } => browser(ui, link, *remaining, *expired, catalog), - DevicePanel::Failed { failure } => failed(ui, *failure, catalog), + } => browser(ui, link, countdown, *expired, catalog), + DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, 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> { let body = column![row![ buttons::filled( ui, Button::new( - catalog.text(Key::AccountDeviceBrowser), - Account::StartBrowser.into_page(), + catalog.text(Key::AccountDeviceSignIn), + Account::StartDevice.into_page(), ) ), buttons::outlined( ui, Button::new( - catalog.text(Key::AccountDeviceUseCode), - Account::StartDevice.into_page(), + catalog.text(Key::AccountDeviceBrowser), + Account::StartBrowser.into_page(), ) ), buttons::text( @@ -76,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { ] .spacing(ui.space(Space::X2)) .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) } @@ -105,13 +124,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { frame::card(ui, body) } -/// 设备码等待态:**码 + 链接 + 倒计时**。 +/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。 fn device_code<'a>( ui: &'a Ui, code: &'a str, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, + note: Option, catalog: Catalog, ) -> Element<'a, PageMessage> { let mut actions = row![ @@ -150,9 +170,7 @@ fn device_code<'a>( ), )); - let countdown = countdown_line(ui, remaining, expired, catalog); - - let body = column![ + let mut body = column![ frame::text_role( ui, TypeRole::LabelMedium, @@ -174,7 +192,7 @@ fn device_code<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown, + countdown_line(ui, countdown, expired), frame::text_role( ui, TypeRole::BodySmall, @@ -182,17 +200,21 @@ fn device_code<'a>( catalog.text(Key::AccountDeviceHint), ui.color(Role::OnSurfaceVariant), ), - actions, ] .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>( ui: &'a Ui, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, catalog: Catalog, ) -> Element<'a, PageMessage> { @@ -237,37 +259,19 @@ fn browser<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown_line(ui, remaining, expired, catalog), + countdown_line(ui, countdown, expired), actions, ] .spacing(ui.space(Space::X3)); frame::card(ui, body) } -/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 -fn countdown_line<'a>( - ui: &'a Ui, - remaining: Option, - expired: bool, - catalog: Catalog, -) -> Element<'a, PageMessage> { +/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。 +fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> { if expired { - return frame::text_role( - ui, - TypeRole::LabelMedium, - true, - catalog.text(Key::AccountDeviceExpired), - ui.color(Role::Error), - ); + return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error)); } - let text = remaining.map_or_else(String::new, |secs| { - // 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( + frame::text_role( ui, TypeRole::LabelMedium, 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, + catalog: Catalog, +) -> Option> { + 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 + 重试/关闭)。 /// -/// 三点修订叠在这里: -/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; -/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, -/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); -/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 -/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 -fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { - let body = catalog.text(failure_body_key(failure)).to_string(); +/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; +/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调; +/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」; +/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 +fn failed<'a>( + ui: &'a Ui, + failure: LoginFailure, + note: Option, + 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 mut actions: Vec> = Vec::new(); if !matches!(failure, LoginFailure::MsaNotConfigured) { diff --git a/crates/app-iced/src/pages/account/model.rs b/crates/app-iced/src/pages/account/model.rs index 03d6eea..36d21eb 100644 --- a/crates/app-iced/src/pages/account/model.rs +++ b/crates/app-iced/src/pages/account/model.rs @@ -7,7 +7,9 @@ use iced::widget::image; 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 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)] pub struct Row { @@ -253,7 +267,8 @@ impl Page { .map(|account| row_of(account, avatars, lang)) .collect(); 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) { diff --git a/crates/app-iced/src/pages/account/panels.rs b/crates/app-iced/src/pages/account/panels.rs index 5feb6b9..b956329 100644 --- a/crates/app-iced/src/pages/account/panels.rs +++ b/crates/app-iced/src/pages/account/panels.rs @@ -10,7 +10,10 @@ //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 原文仍在 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)] @@ -25,17 +28,23 @@ pub enum DevicePanel { code: String, /// 验证链接 link: String, - /// 剩余秒数 - remaining: Option, - /// 是否已过期 + /// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」) + /// + /// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径 + /// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、 + /// 视图用 `&'a str` 借用,两边都不依赖那条路径。 + countdown: String, + /// 是否已过期(决定颜色与「重试」按钮) expired: bool, + /// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事) + note: Option, }, /// 授权码流:等浏览器回调 Browser { /// 授权链接(可复制) link: String, - /// 剩余秒数 - remaining: Option, + /// 倒计时那行已渲染好的文案(同上) + countdown: String, /// 是否已过期 expired: bool, }, @@ -43,6 +52,8 @@ pub enum DevicePanel { Failed { /// 原因码(页面据此选 i18n) failure: LoginFailure, + /// 回退说明(浏览器登录没走成之后设备码也失败了;M178) + note: Option, }, /// 成功 Succeeded { @@ -76,8 +87,13 @@ pub enum AuthlibPanel { } /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 -pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> DevicePanel { +pub(super) fn device_panel( + view: &DeviceLoginView, + remaining: Option, + catalog: Catalog, +) -> DevicePanel { let expired = remaining == Some(0); + let countdown = countdown_text(remaining, expired, catalog); match view.phase { DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Requesting => DevicePanel::Requesting, @@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De if view.user_code.trim().is_empty() { DevicePanel::Browser { link: view.verification_uri.clone(), - remaining, + countdown, expired, } } else { DevicePanel::Code { code: view.user_code.clone(), link: view.qr_link.clone(), - remaining, + countdown, expired, + note: view.browser_note, } } } DeviceLoginPhase::Failed => DevicePanel::Failed { failure: view.failure.unwrap_or(LoginFailure::Other), + note: view.browser_note, }, DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { name: view.account_name.clone(), @@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De } } +/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。 +/// +/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。 +fn countdown_text(remaining: Option, 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) { match view.phase { diff --git a/crates/app-iced/src/pages/account/tests.rs b/crates/app-iced/src/pages/account/tests.rs index 92b0651..70d6b91 100644 --- a/crates/app-iced/src/pages/account/tests.rs +++ b/crates/app-iced/src/pages/account/tests.rs @@ -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`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 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 { DevicePanel::Code { code, - remaining, + countdown, expired, .. } => { assert_eq!(code, "ABCD-EFGH"); - assert_eq!(*remaining, Some(900)); + assert!( + countdown.contains("15:00"), + "倒计时要画成 mm:ss:{countdown}" + ); assert!(!expired); } other => panic!("等待态应该是设备码面板,实际 {other:?}"), @@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() { let page = build(&state, &Form::new()); match &page.device { DevicePanel::Code { - remaining, expired, .. + countdown, expired, .. } => { - assert_eq!(*remaining, Some(0)); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); + assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行"); } other => panic!("过期态仍是设备码面板,实际 {other:?}"), } diff --git a/crates/app-iced/src/pages/account/tests_msa.rs b/crates/app-iced/src/pages/account/tests_msa.rs new file mode 100644 index 0000000..3439e19 --- /dev/null +++ b/crates/app-iced/src/pages/account/tests_msa.rs @@ -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}"); +} diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index d5fbd57..6afa153 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -16,7 +16,7 @@ use unlucky_i18n::{Key, Lang}; 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}; /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 @@ -60,23 +60,33 @@ pub(super) fn on_screen(page: &Page) -> String { DevicePanel::Code { code, link, - remaining, - expired, + countdown, + note, + .. } => { out.push_str(code); out.push_str(link); - if *expired { - out.push_str(catalog.text(Key::AccountDeviceExpired)); - } else if let Some(secs) = remaining { - let clock = format!("{:02}:{:02}", secs / 60, secs % 60); - out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])); + // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。 + out.push_str(countdown); + // M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`), + // 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。 + if let Some(note) = note { + out.push_str(browser_note_text(*note, catalog)); } } - DevicePanel::Browser { link, .. } => out.push_str(link), - DevicePanel::Failed { failure } => { + DevicePanel::Browser { + 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_body_key(*failure))); 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 } => { out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 1cbda1c..ccb2505 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -20,7 +20,7 @@ //! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | -//! | `section` | 设置页「外观」分组(真控件) | 无 | +//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 | //! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! //! ## 偏离登记(D30 要求逐条写清) @@ -65,10 +65,12 @@ pub mod background; mod palette; mod picture; +#[cfg(test)] +pub(crate) mod probe; mod section; mod state; -/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 +/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。 /// /// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 /// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 diff --git a/crates/app-iced/src/pages/appearance/probe.rs b/crates/app-iced/src/pages/appearance/probe.rs new file mode 100644 index 0000000..2c340c2 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/probe.rs @@ -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> = const { RefCell::new(Vec::new()) }; }` + // —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。 + #[allow(clippy::missing_const_for_thread_local)] + static TEXTS: RefCell> = 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())) +} diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs deleted file mode 100644 index 04e3ada..0000000 --- a/crates/app-iced/src/pages/appearance/section.rs +++ /dev/null @@ -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, ¬e, 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> { - 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> { - 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() -} diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs new file mode 100644 index 0000000..0c954e5 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -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>) { + // ④ 背景图开关 + 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, ¬e, 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))), + ), + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/colour_source.rs b/crates/app-iced/src/pages/appearance/section/colour_source.rs new file mode 100644 index 0000000..e79a2b9 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -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>) { + // ⑨ 配色来源 + 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), + ), + ], + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs new file mode 100644 index 0000000..e6aa711 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -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`); +//! 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> = 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> { + 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> { + 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() +} diff --git a/crates/app-iced/src/pages/appearance/section/panel.rs b/crates/app-iced/src/pages/appearance/section/panel.rs new file mode 100644 index 0000000..58661ba --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/panel.rs @@ -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>) { + // ③ 面板透明度(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, + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/window.rs b/crates/app-iced/src/pages/appearance/section/window.rs new file mode 100644 index 0000000..3cd9399 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/window.rs @@ -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>, +) { + // ① 整窗透明度 + 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, + )); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index f0cbd1d..1fb36c1 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -268,7 +268,7 @@ pub fn app<'a>( /// 同 [`app`],但可以带上**外观运行期状态**。 /// -/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`); +/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`); /// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支: /// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**—— /// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。 diff --git a/crates/app-iced/src/pages/settings/ai/mod.rs b/crates/app-iced/src/pages/settings/ai/mod.rs new file mode 100644 index 0000000..418b536 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/mod.rs @@ -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}; diff --git a/crates/app-iced/src/pages/settings/ai/outcome.rs b/crates/app-iced/src/pages/settings/ai/outcome.rs new file mode 100644 index 0000000..817c4a6 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/outcome.rs @@ -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, + /// 实际请求的端点(`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 + } +} diff --git a/crates/app-iced/src/pages/settings/ai/state.rs b/crates/app-iced/src/pages/settings/ai/state.rs new file mode 100644 index 0000000..8525143 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/state.rs @@ -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) -> 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, + /// 探测结论 + 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 { + 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 { + 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, + } + } +} diff --git a/crates/app-iced/src/pages/settings/ai/store.rs b/crates/app-iced/src/pages/settings/ai/store.rs new file mode 100644 index 0000000..40c0f23 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store.rs @@ -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 { + 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; diff --git a/crates/app-iced/src/pages/settings/ai/store_tests.rs b/crates/app-iced/src/pages/settings/ai/store_tests.rs new file mode 100644 index 0000000..293e016 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -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:?} 没有文案"); + } +} diff --git a/crates/app-iced/src/pages/settings/ai/tests.rs b/crates/app-iced/src/pages/settings/ai/tests.rs new file mode 100644 index 0000000..9bef712 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/tests.rs @@ -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 = 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:?} 下没有译文" + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/ai/view.rs b/crates/app-iced/src/pages/settings/ai/view.rs new file mode 100644 index 0000000..542ea6f --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/view.rs @@ -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 }), + ) +} diff --git a/crates/app-iced/src/pages/settings/ai_about.rs b/crates/app-iced/src/pages/settings/ai_about.rs index 6d5aa62..e855aff 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -1,39 +1,80 @@ -//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。 +//! 设置页「助手(AI)」分组(M174):左列第 6 组的**挂载点**与本线的消息口。 //! -//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance / -//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的 -//! 空模块(不产生任何代码,`-D warnings` 不会红)。 +//! 本文件只留三样东西,正文/状态/读写都在 `pages/settings/ai/` 里: //! -//! ## 接线步骤(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`; -//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`; -//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。 +//! ## 为什么 `body` 是 `None` 而不是 `Some(..)` //! -//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。 +//! `Group.body` 的槽位(M179 起)拿的是 [`States`](super::States) 与**生效语言** `Lang`——本组要的 +//! 两件它其实都装得下。本组仍走按下标分发,是因为**还没迁过去**:槽位要求返回 `Option`, +//! 而 `ai::view::body` 返回 `Element`,把本组(以及 Java 组)统一迁到槽位上是一次独立重构。 +//! M106 的语言行与 M159 的更新组当初也是同一个理由走上这条路,代价是 `settings/mod.rs` 多一行 `else if`。 //! -//! ## M101 起本文件还装本线的状态与消息口 +//! ## 密钥纪律 //! -//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件); -//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。 -//! -//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配): -//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1); -//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译), -//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。 -//! -//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。 +//! [`Message::EditKey`] 的载荷是用户刚敲的明文(输入框必须有它),它**只**活在这个进程里: +//! 落盘走 `ai::store::save`(不经 `AppCommand::SetSetting` 那条可序列化通道), +//! 上屏默认掩码(`State::reveal` 才明文),`Debug` 由 `ai::state::Secret` 掩码。 -/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。 -/// -/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。 -#[derive(Debug, Default)] -pub struct State {} +pub use super::ai::State; -/// AI / About 线的消息(**只归本线**)。 -/// -/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是 -/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报 -/// E0004,不会静默。 +use unlucky_design::icons::IconKind; + +use super::Group; + +/// 本组在 [`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)] -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), +} + +impl Message { + /// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。 + #[must_use] + pub fn into_group(self) -> super::GroupMessage { + super::GroupMessage::AiAbout(self) + } +} diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index 6d98023..ba2214c 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -5,20 +5,25 @@ //! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP` //! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。 //! -//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责 +//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责 //! 把它挂进注册表),所以改控件时不用碰设置页目录。 //! //! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」 //! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。 +//! +//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的 +//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`, +//! 因为渲染器拿不到「这一次生效的语言」。 use iced::Element; use unlucky_design::icons::IconKind; +use unlucky_i18n::Lang; use crate::theme::Ui; use super::{Group, Message, States}; -/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件); +/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件); /// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。 pub(super) const GROUP: Group = Group { name: "Appearance", @@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group { }; /// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。 -fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option> { +/// +/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。 +fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { states .appearance - .map(|state| crate::pages::appearance::section(ui, state)) + .map(|state| crate::pages::appearance::section(ui, state, lang)) } diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 438744d..beb8514 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -12,8 +12,8 @@ pub(super) use super::super::frame::{ text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, }; -use iced::widget::{column, container, mouse_area, row, scrollable}; -use iced::{Alignment, Background, Border, Element, Length, Padding}; +use iced::widget::{column, container, mouse_area, row}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Theme}; use unlucky_design::color::Role; use unlucky_design::component_map::Component; @@ -137,20 +137,39 @@ fn group_item<'a>( /// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。 /// /// 「外观」分组有 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, - content: impl Into>, -) -> Element<'a, Message> { - scrollable(content) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(SCROLLBAR_WIDTH) - .scroller_width(SCROLLBAR_WIDTH) - .margin(SCROLLBAR_MARGIN), - )) - .height(Length::Fill) - .style(scroll_style(ui)) - .into() + content: impl Into>, +) -> Element<'a, Message, Theme, Renderer> +where + Renderer: iced::advanced::text::Renderer + 'a, +{ + crate::app::components::scroll::vertical( + SCROLLBAR_WIDTH, + SCROLLBAR_MARGIN, + scroll_style(ui), + content, + ) } /// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。 diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 9ece9c3..e5fb202 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -4,10 +4,11 @@ //! //! ## 这一组为什么特殊 //! -//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option`,`States` 只有 -//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在 -//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标 -//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。 +//! 它**不走** `Group::body`:那个渲染器拿的是 [`States`](super::States) 与**生效语言** `Lang` +//! (M179 起;此前连 `Lang` 都没有),而语言行要的是「当前语言**档位** `LangChoice` + 生效语言」 +//! 这一对——档位不在 `States` 里。所以它由 `pages::settings::view` 的 `language` 形参一路递进来、 +//! `form` 按下标转交 [`body`];`GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」 +//! 照样成立。 //! //! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记) //! @@ -46,6 +47,7 @@ use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; use unlucky_i18n::LangChoice; +use crate::fonts::install::{CjkInstall, FetchClass}; use crate::pages::{frame, Language, Message}; 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 mut stacked = column![].spacing(0.0).width(Length::Fill); stacked = stacked.push(language_row(ui, language)); + // M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面, + // 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。 + stacked = stacked.push(divider(ui)); + stacked = stacked.push(font_row(ui, language)); // 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 for (label, value) in [ @@ -85,6 +91,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { 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) { + 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> { 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`)。 /// -/// 与 `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> { let height = ui.metrics().control_height; let content_color = if selected { diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index dac13c9..ff6341a 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -1,44 +1,84 @@ -//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。 +//! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。 //! -//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件), -//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。 +//! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里: //! -//! 图标说明(视觉复审 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`(App 层在生产路径上只用 `State`,不需要这个名字)。 +#[cfg(test)] +pub use row::RuntimeRow; use unlucky_design::icons::IconKind; use super::Group; +/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。 +/// +/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。 +pub(crate) const JAVA_GROUP: usize = 2; + /// Java 分组。 pub(super) const GROUP: Group = Group { name: "Java", icon: IconKind::Folder, - rows: &[ - ("Runtime", "Temurin 21.0.4"), - ("Default memory", "4 GiB"), - ("JVM arguments", "Automatic"), - ], + // 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。 + rows: &[], body: None, }; -/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。 -/// -/// 加字段只改本文件:`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))。 +/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。 #[derive(Debug, Clone)] pub enum Message { - /// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 → - /// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。 - /// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的 - /// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。 - #[cfg(test)] - Ping, + /// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来) + Scan, + /// 取消正在跑的扫描 + CancelScan, + /// 手动路径输入框 + 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) + } } diff --git a/crates/app-iced/src/pages/settings/java/fields.rs b/crates/app-iced/src/pages/settings/java/fields.rs new file mode 100644 index 0000000..66d9ad9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/fields.rs @@ -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() +} diff --git a/crates/app-iced/src/pages/settings/java/row.rs b/crates/app-iced/src/pages/settings/java/row.rs new file mode 100644 index 0000000..42f41eb --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/row.rs @@ -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, + /// 发行方(认不出为 `None`)。 + pub vendor: Option, + /// 来源 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::>() + .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 { + 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 { + 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) -> 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" + ); + } +} diff --git a/crates/app-iced/src/pages/settings/java/seed.rs b/crates/app-iced/src/pages/settings/java/seed.rs new file mode 100644 index 0000000..4f59203 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/seed.rs @@ -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 { + 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) +} diff --git a/crates/app-iced/src/pages/settings/java/state.rs b/crates/app-iced/src/pages/settings/java/state.rs new file mode 100644 index 0000000..62e1bb9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/state.rs @@ -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, +} + +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, + /// 当前生效内存来自哪一层 + pub layer: Option, + /// 当前生效内存(三级回落之后的数值) + pub effective_memory_mb: Option, +} + +/// 保存 / 操作的回执。 +#[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, + /// 至少完整扫过一次(区分「未检测到」与「还没扫」) + pub scanned: bool, + /// 扫描进度 + pub scanning: Option, + /// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫) + pub cancelled: bool, + /// 扫描状态槽(快照 + 取消位;线程与界面共享同一份) + pub scan: super::store::ScanSlot, + /// 用户手动加的路径(原样,持久化在 `manual_file`) + pub manual: Vec, + /// `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, + /// 当前实例的上下文(版本门槛 / 生效内存层级) + 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) -> Option { + 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 { + let text = self.memory.trim(); + if text.is_empty() { + return Err(Refusal::MemoryEmpty); + } + let Ok(mb) = text.parse::() 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 { + 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 +} diff --git a/crates/app-iced/src/pages/settings/java/store.rs b/crates/app-iced/src/pages/settings/java/store.rs new file mode 100644 index 0000000..b5a795d --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/store.rs @@ -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, +} + +/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。 +pub type Job = + fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec; + +/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。 +/// +/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处—— +/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例 +/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。 +/// +/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。 +#[derive(Debug, Clone, Default)] +pub struct ScanSlot { + inner: std::sync::Arc, +} + +#[derive(Debug, Default)] +struct ScanInner { + report: Mutex, + 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, manual: Vec, system: bool) -> bool { + self.start_with(roots, manual, system, run_staged) + } + + /// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。 + pub fn start_with( + &self, + roots: Vec, + manual: Vec, + 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 { + 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 { + 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 +} + +/// 托管安装根(`/data/java-managed` + `java.installRoot` + 标准落点)。 +/// +/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式 +/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。 +#[must_use] +pub fn managed_roots(config: &Path) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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, +) -> 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 { + bridge.path().map(Path::to_path_buf) +} diff --git a/crates/app-iced/src/pages/settings/java/tests.rs b/crates/app-iced/src/pages/settings/java/tests.rs new file mode 100644 index 0000000..5df6fe9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests.rs @@ -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; + +/// 每个用例一个临时数据根(`/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::` 换成 `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; diff --git a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs new file mode 100644 index 0000000..882bcc2 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs @@ -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 { + 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), + "全新进程(没扫过、没取消)仍然自动扫一次" + ); +} diff --git a/crates/app-iced/src/pages/settings/java/tests/runtime.rs b/crates/app-iced/src/pages/settings/java/tests/runtime.rs new file mode 100644 index 0000000..c74294f --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/runtime.rs @@ -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 { + 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"); + // `/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() + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/java/view.rs b/crates/app-iced/src/pages/settings/java/view.rs new file mode 100644 index 0000000..944c022 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/view.rs @@ -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() +} diff --git a/crates/app-iced/src/pages/settings/language_appearance.rs b/crates/app-iced/src/pages/settings/language_appearance.rs index f167c03..2af25b2 100644 --- a/crates/app-iced/src/pages/settings/language_appearance.rs +++ b/crates/app-iced/src/pages/settings/language_appearance.rs @@ -17,7 +17,7 @@ //! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key | //! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 | //! -//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。 +//! 真控件那半边在 `pages/appearance/section/`(`appearance.rs` 只负责把 `body` 挂上去)。 /// 语言 + 外观线的状态(**只归本线**)。 /// diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index f578ad6..92838db 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -25,10 +25,11 @@ //! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 | //! |---|---|---| //! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) | -//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) | +//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) | //! | Java | [`java`] | java 线 | //! | Downloads | [`download`] | download 线 | -//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 | +//! | Updates | [`update`] | update 线(M159,按下标分发) | +//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) | //! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) | //! //! ## 测试也按 section 分(M100) @@ -93,9 +94,11 @@ //! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key` //! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改 //! `catalog/settings/mod.rs`(五个域已全部接进装配链)。 -//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。 -//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;` -//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。 +//! 2. 本文件的 [`GROUPS`] —— 加一整组时加一行(M174 的助手组就是这么来的:`GROUPS` 与 +//! [`GROUP_LABELS`] 各加一行、长度 +1,`tests/mod.rs` 的「左列顺序」随之更新)。 +//! 3. `tests/mod.rs` —— 「左列顺序」那条字面量断言会连带红,需要人确认(M174 就是把它加了一个 +//! `"Assistant"`)。加分组自己的测试文件时还要在 `tests/mod.rs` 顶部挂一行 `mod <名>;`; +//! M174 的用例走的是组内路径(`pages/settings/ai/tests.rs`),所以没有新增挂载行。 //! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到 //! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置 //! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测): @@ -114,7 +117,7 @@ //! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, //! 不是永久性四路撞车。 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 -//! `fn(&Ui, States) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` +//! `fn(&Ui, States, Lang) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` //! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的 //! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派 @@ -132,6 +135,7 @@ // 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子 // (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。 // 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。 +pub(crate) mod ai; pub(crate) mod ai_about; mod appearance; pub(crate) mod download; @@ -189,13 +193,16 @@ impl Default for Language { } } -/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态), -/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态), -/// 由 [`form`] 退回演示行。 +/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态) +/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下 +/// 没有外观状态),由 [`form`] 退回演示行。 /// /// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, /// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 -type Body = for<'a> fn(&'a Ui, States<'a>) -> Option>; +/// +/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 +/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 +type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option>; /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// @@ -215,7 +222,7 @@ struct Group { /// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 /// /// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 -const GROUPS: [Group; 5] = [ +const GROUPS: [Group; 6] = [ general::GROUP, appearance::GROUP, java::GROUP, @@ -223,6 +230,8 @@ const GROUPS: [Group; 5] = [ // M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标 // 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。 update::GROUP, + // M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。 + ai_about::GROUP, ]; /// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 @@ -230,9 +239,9 @@ const SELECTED: usize = 0; /// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 /// -/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带 -/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal -/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。 +/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带 +/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下: +/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。 /// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` /// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 const GENERAL_GROUP: usize = 0; @@ -241,13 +250,15 @@ const GENERAL_GROUP: usize = 0; /// /// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— /// 与 [`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::SettingsGroupAppearance, unlucky_i18n::Key::SettingsGroupJava, unlucky_i18n::Key::SettingsGroupDownloads, // M159 unlucky_i18n::Key::SettingsGroupUpdate, + // M174 + unlucky_i18n::Key::SettingsGroupAssistant, ]; /// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。 @@ -256,6 +267,19 @@ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [ /// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。 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`,是一次独立重构——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 接进来的外观运行期状态 @@ -292,9 +316,15 @@ pub fn view<'a>( /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// -/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 -/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 -/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 +/// **分发点(两条路,M106 起就并存)**: +/// +/// 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>( ui: &'a Ui, only: Option, @@ -321,10 +351,16 @@ fn form<'a>( } else if index == UPDATE_GROUP { // M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。 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 { group .body - .and_then(|body| body(ui, states)) + .and_then(|body| body(ui, states, language.effective)) .unwrap_or_else(|| frame::demo_card(ui, group.rows)) }; diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index e318042..2fe858e 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -2,12 +2,25 @@ //! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! //! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 -//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 +//! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。 //! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除** //! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。 +//! +//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。 use super::super::appearance; +use super::super::{demo_groups, view, Language, States}; +use unlucky_design::color::SchemeFlavor; 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;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)" ); } + +// --------------------------------------------------------------- 语言(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) +} diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 8825de1..702a9e0 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -8,8 +8,8 @@ //! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。 //! //! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP` -//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option`, -//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的 +//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是 +//! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的 //! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。 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`)。分组图标是左列的语义标签,换掉就是换观感。 #[test] fn the_general_group_keeps_its_icon() { @@ -111,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() { /// **绊线**:这一组仍不走 `Group.body`。 /// /// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发), -/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。 -/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。 +/// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。 +/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。 #[test] fn the_general_group_does_not_use_the_appearance_body_slot() { assert!( diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index 0dbcef4..8e6d8c7 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -1,33 +1,30 @@ -//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有, -//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。 +//! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。 //! -//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线, -//! 见 `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()`)。 +//! M101 的三条里有两条在 M175 换掉了: //! -//! 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 unlucky_design::icons::IconKind; -/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 +/// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。 /// -/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); -/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。 -/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。 +/// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据); +/// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。 +/// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。 #[test] -fn the_java_group_shows_runtime_memory_and_jvm_arguments() { - let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec(); - assert_eq!( - rows, - vec![ - ("Runtime", "Temurin 21.0.4"), - ("Default memory", "4 GiB"), - ("JVM arguments", "Automatic"), - ] +fn the_java_group_has_no_demo_rows_left() { + assert!( + java::GROUP.rows.is_empty(), + "Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}", + java::GROUP.rows ); } @@ -41,17 +38,15 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() { assert_eq!(java::GROUP.icon, IconKind::Folder); } -/// **绊线**:这一组还没接真控件,画的是演示行。 +/// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。 /// -/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 -/// 抓不住:界面上真控件长什么样。 -/// -/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去 -/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。 +/// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」 +/// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。 +/// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。 #[test] -fn the_java_group_is_still_demo_rows() { +fn the_java_group_is_dispatched_by_index_not_by_body() { assert!( java::GROUP.body.is_none(), - "Java 还没接真控件:接了就改本断言" + "Java 组走按下标分发(要 Lang),body 必须保持 None" ); } diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 5546c52..f9a9260 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -40,6 +40,7 @@ mod appearance_section; mod download_section; mod general_section; mod java_section; +mod scroll_fit; mod update_section; 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`(四组拼出来的数组),单组文件拿不到其它三组, -/// 而「新增一组忘了填 rows」正是这条要抓的。 +/// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 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] fn every_group_has_a_name_icon_and_rows() { assert!(GROUPS.len() >= 3); assert!(SELECTED < GROUPS.len()); + // 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。 + const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"]; for group in &GROUPS { assert!(!group.name.is_empty()); 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 { 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(); assert_eq!( 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`**—— -/// 那个槽位的签名是 `fn(&Ui, States) -> Option`,`States` 只有 `appearance` + `groups` -/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 + -/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。 +/// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option`(`Lang` 是 M179 加的), +/// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 + +/// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发, +/// `body` 仍是 `None`,所以本断言不动。 /// 判据没变:**`body: Some(..)` 仍然只有外观组**。 #[test] 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() { // 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事—— // ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。 - assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组"); + assert_eq!( + GROUPS.len(), + 6, + "四条填页线 + M159 的更新组 + M174 的助手组" + ); assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内"); } diff --git a/crates/app-iced/src/pages/settings/tests/scroll_fit.rs b/crates/app-iced/src/pages/settings/tests/scroll_fit.rs new file mode 100644 index 0000000..2cf8ec9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/scroll_fit.rs @@ -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()) + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/update_section.rs b/crates/app-iced/src/pages/settings/tests/update_section.rs index 5cc288a..ae21df8 100644 --- a/crates/app-iced/src/pages/settings/tests/update_section.rs +++ b/crates/app-iced/src/pages/settings/tests/update_section.rs @@ -11,11 +11,20 @@ use super::super::{GROUPS, SELECTED}; use crate::pages::update::UpdateStatus; use crate::pages::{Screen, Update}; -/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` -/// 两个下标一起失效。 +/// 更新组**下标固定为 4**:加组只追加不重排,插到它前面会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` +/// 两个下标一起失效,也会把更新组自己的 `UPDATE_GROUP` 指到别人身上。 +/// +/// M174 之后它**不再是最后一组**(助手组追加在它后面):这条断言因此从「len == 下标 + 1」 +/// 改成「下标 4 上就是更新组,且它是**倒数第二**组」——追加语义仍然成立,而「更新组被挤走」 +/// 照样会红。 #[test] 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, "Updates"); assert!(SELECTED < GROUPS.len()); diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs index ae984c8..98bdf78 100644 --- a/crates/app-iced/src/pages/settings/update.rs +++ b/crates/app-iced/src/pages/settings/update.rs @@ -2,10 +2,10 @@ //! //! ## 为什么这一组不走 `Group.body` //! -//! `Group.body` 的签名是 `fn(&Ui, States) -> Option`,而 `States` 只有「外观状态 + 各组 -//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种 -//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给 -//! [`body`],`GROUP.body` 仍是 `None`。 +//! `Group.body` 的槽位拿的是 [`States`](super::States) 与**生效语言** `Lang`(M179 起),而更新状态 +//! 是 **presenter 快照**(`pages::Update`,不是某一组的字段、也不在 `States` 里),槽位装不下它。 +//! 与 M106 的语言行同一种情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 +//! `update` 形参交给 [`body`],`GROUP.body` 仍是 `None`。 //! //! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 / //! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。 diff --git a/crates/app-iced/src/platform/data.rs b/crates/app-iced/src/platform/data.rs new file mode 100644 index 0000000..695db41 --- /dev/null +++ b/crates/app-iced/src/platform/data.rs @@ -0,0 +1,111 @@ +//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。 +//! +//! ## 为什么需要它 +//! +//! 字体下载要把 17 MiB 落在数据根里(`/cache/fonts/`),而「数据根在哪」这件事 +//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 + +//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析, +//! 只做两件它没暴露的事: +//! +//! 1. 由配置文件路径 `/config/launcher.json` 反推 ``,再用 [`LauncherLayout::cache`] +//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串); +//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写, +//! 而 `ui.font_cdn` 是 M168 新增的键。 +//! +//! ## 诚实边界 +//! +//! - **抓得住**:路径推导的形状(`/config/x` → `/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 { + static LAYOUT: std::sync::OnceLock> = std::sync::OnceLock::new(); + LAYOUT + .get_or_init(|| { + let config = SettingsBridge::open().path()?.to_path_buf(); + // `/config/launcher.json` → `` + let home = config.parent()?.parent()?.to_path_buf(); + Some(LauncherLayout::new(home)) + }) + .clone() +} + +/// 字体缓存的根(`/fonts/` 的父目录;**不**等于字体目录本身)。 +/// +/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里, +/// 这就是「可重建」的那一类)。 +pub fn cache_root() -> Option { + layout().map(|layout| layout.cache()) +} + +/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。 +/// +/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。 +pub fn setting_text(key: &str) -> Option { + 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 { + 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 里登记" + ); + } + + /// 布局推导的形状:`/config/launcher.json` → `/cache`。 + /// + /// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。 + /// 抓不住:真机上 `` 解析成什么(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") + ); + } +} diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index a2e04a4..e40742d 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -1,5 +1,5 @@ -//! 平台相关的窗口能力(P4 / D36 / M116 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点, -//! 以及系统语言真值源,四件事分居四处。 +//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` +//! 落点、系统语言真值源、以及数据根派生,五件事分居五处。 //! //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; @@ -18,6 +18,7 @@ //! //! | 模块 | 里面有什么 | 有没有 FFI | //! |---|---|---| +//! | [`data`] | 数据根派生(字体缓存分区 + 一条设置键的生效值,M168) | 没有 | //! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 | //! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) | //! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) | @@ -31,6 +32,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; use crate::Backend; +pub mod data; pub mod host; pub mod linux; pub mod locale; diff --git a/crates/app-iced/src/ui/lang.rs b/crates/app-iced/src/ui/lang.rs index 1ef28d5..db693a1 100644 --- a/crates/app-iced/src/ui/lang.rs +++ b/crates/app-iced/src/ui/lang.rs @@ -139,8 +139,12 @@ impl LangState { report.strategy, unlucky_i18n::font::Strategy::NeedsDownload(_) ) { + // M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给 + // `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里, + // 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」, + // 等于当面否定刚做的事(复审 r1 P2-3)。 eprintln!( - "[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)" + "[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体" ); } } diff --git a/crates/cli/src/commands/agent/mod.rs b/crates/cli/src/commands/agent/mod.rs index 06088e7..325ed41 100644 --- a/crates/cli/src/commands/agent/mod.rs +++ b/crates/cli/src/commands/agent/mod.rs @@ -9,6 +9,7 @@ //! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。 pub mod ports; +pub mod probe; pub mod serve; use serde_json::{json, Value}; diff --git a/crates/cli/src/commands/agent/probe.rs b/crates/cli/src/commands/agent/probe.rs new file mode 100644 index 0000000..40c1bff --- /dev/null +++ b/crates/cli/src/commands/agent/probe.rs @@ -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, +} + +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 { + 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; diff --git a/crates/cli/src/commands/agent/probe/tests.rs b/crates/cli/src/commands/agent/probe/tests.rs new file mode 100644 index 0000000..0d56513 --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests.rs @@ -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>>, +} + +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 { + self.hits.lock().expect("锁").clone() + } +} + +fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc>>) { + 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::().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; diff --git a/crates/cli/src/commands/agent/probe/tests_guards.rs b/crates/cli/src/commands/agent/probe/tests_guards.rs new file mode 100644 index 0000000..5753c47 --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests_guards.rs @@ -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) + }); + + // 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(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:?}" + ); +} diff --git a/crates/core/src/addon/online/dependency.rs b/crates/core/src/addon/online/dependency.rs new file mode 100644 index 0000000..440742f --- /dev/null +++ b/crates/core/src/addon/online/dependency.rs @@ -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) -> 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, + /// 目标游戏版本(选版本用;`None` = 不限) + pub game_version: Option, + /// 目标加载器(选版本用;`None` = 不限) + pub loader: Option, + /// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`]) + pub max_depth: u8, + /// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的) + pub present: Vec, +} + +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) -> 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, +} + +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, + /// 跳过原因 + 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, + /// 距根的层数 + pub depth: u8, + /// 哪一档 + pub kind: FailureKind, + /// 叶子成因(只有 [`FailureKind::Unreachable`] 有) + pub cause: Option, + /// 人读说明(站点/引擎原话 + 上下文,不是界面文案) + 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, + /// 站点给的文件名 + pub file_name: Option, +} + +/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyPlan { + /// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`) + pub request: ResolveRequest, + /// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序) + pub nodes: Vec, + /// 目录里已经有了、不用再下的 + pub skipped: Vec, + /// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`]) + pub failures: Vec, + /// 撞到 `max_depth` 而没展开的项目键(排序去重) + pub truncated: Vec, + /// 有 `required` 但没有项目 id、解析不了的前置(排序去重) + pub unresolved_required: Vec, +} + +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 { + 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 = 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()) + } +} diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 7886548..2fa4203 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -46,23 +46,43 @@ //! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | //! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | //! | [`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 dependency; pub mod download; pub mod model; pub mod modrinth; +pub mod resolve; pub mod transport; 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::{ digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, FetchArgs, RETRY_ATTEMPTS, }; pub use model::{ - ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex, - Version, VersionFile, VersionQuery, VersionType, + ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, + SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType, }; pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API}; +pub use resolve::resolve; pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport}; #[cfg(test)] diff --git a/crates/core/src/addon/online/model/mod.rs b/crates/core/src/addon/online/model/mod.rs index 5faf941..67af82b 100644 --- a/crates/core/src/addon/online/model/mod.rs +++ b/crates/core/src/addon/online/model/mod.rs @@ -32,6 +32,6 @@ mod types; pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions}; pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS}; pub use types::{ - FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery, - VersionType, + Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, + VersionFile, VersionQuery, VersionType, }; diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs index e7d1ceb..0e4722e 100644 --- a/crates/core/src/addon/online/model/parse.rs +++ b/crates/core/src/addon/online/model/parse.rs @@ -8,7 +8,10 @@ use std::collections::BTreeMap; use serde::Deserialize; 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}; // --------------------------------------------------------------- 解析 @@ -112,6 +115,23 @@ struct RawVersion { downloads: u64, #[serde(default)] files: Vec, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct RawDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + file_name: Option, + // 站点给的是 `required` / `optional` / `incompatible` / `embedded`。 + // 必须是 `Option`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null` + // (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。 + #[serde(default)] + dependency_type: Option, } #[derive(Debug, Default, Deserialize)] @@ -238,6 +258,18 @@ fn version_of(raw: RawVersion) -> Version { primary: file.primary, }) .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(), } } diff --git a/crates/core/src/addon/online/model/query.rs b/crates/core/src/addon/online/model/query.rs index 6c660e5..1e2b57f 100644 --- a/crates/core/src/addon/online/model/query.rs +++ b/crates/core/src/addon/online/model/query.rs @@ -83,7 +83,20 @@ impl SortIndex { /// 站点认的加载器 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)。 #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs index 532fb64..3c2b57a 100644 --- a/crates/core/src/addon/online/model/types.rs +++ b/crates/core/src/addon/online/model/types.rs @@ -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, + /// 依赖的版本 id(站点基本只对 `embedded` 给) + pub version_id: Option, + /// 站点给的文件名(只对 `embedded` / `incompatible` 有意义) + pub file_name: Option, + /// 依赖类型 + pub dependency_type: DependencyType, +} + /// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。 #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Version { @@ -163,6 +224,8 @@ pub struct Version { pub downloads: u64, /// 文件列表 pub files: Vec, + /// 前置声明(站点原文顺序;空 = 站点没说 / 没有) + pub dependencies: Vec, } impl Version { @@ -176,6 +239,45 @@ impl Version { .find(|file| file.primary) .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() + } } /// 版本里的一个文件。 diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs new file mode 100644 index 0000000..16966c7 --- /dev/null +++ b/crates/core/src/addon/online/resolve.rs @@ -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, +} + +/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。 +pub async fn resolve( + client: &Modrinth<'_, T>, + request: &ResolveRequest, +) -> DependencyPlan { + let mut plan = DependencyPlan::new(request); + let present: BTreeSet = request.present.iter().map(PresentFile::key).collect(); + // 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id) + let mut alias: BTreeMap = BTreeMap::new(); + let mut resolved: BTreeSet = BTreeSet::new(); + + let mut layer: Vec = 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 = 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, + item: Wave, + kind: FailureKind, + cause: Option, + 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, 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) { + 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() + } +} diff --git a/crates/core/src/addon/online/tests/dependency.rs b/crates/core/src/addon/online/tests/dependency.rs new file mode 100644 index 0000000..1a75948 --- /dev/null +++ b/crates/core/src/addon/online/tests/dependency.rs @@ -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()], + "光影的加载器不该被当成分类标签丢掉" + ); +} diff --git a/crates/core/src/addon/online/tests/fixtures_deps.rs b/crates/core/src/addon/online/tests/fixtures_deps.rs new file mode 100644 index 0000000..4680eac --- /dev/null +++ b/crates/core/src/addon/online/tests/fixtures_deps.rs @@ -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 +}"#; diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index f2509a1..31f60da 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -8,6 +8,10 @@ mod api; mod classify; +mod dependency; mod download; mod fixtures; +mod fixtures_deps; +mod order; +mod regressions; mod support; diff --git a/crates/core/src/addon/online/tests/order.rs b/crates/core/src/addon/online/tests/order.rs new file mode 100644 index 0000000..35917a8 --- /dev/null +++ b/crates/core/src/addon/online/tests/order.rs @@ -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), "两次结果必须一样"); +} diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs new file mode 100644 index 0000000..605c2fc --- /dev/null +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -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 仍要被跟" + ); +} diff --git a/crates/core/src/addon/online/tests/support.rs b/crates/core/src/addon/online/tests/support.rs index 6b0a9dd..f3a70fc 100644 --- a/crates/core/src/addon/online/tests/support.rs +++ b/crates/core/src/addon/online/tests/support.rs @@ -167,3 +167,23 @@ pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) { 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 = 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(",") + ) +} diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index 6757829..51ed053 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -29,7 +29,9 @@ use super::chain::complete_chain; use super::endpoints::MicrosoftEndpoints; use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE}; use super::now_ms; +use super::preflight::{self, RedirectRegistration}; use super::session::MicrosoftSession; +use super::{BrowserFallback, BrowserFallbackReason}; /// 授权码 grant 类型。 pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; @@ -171,11 +173,28 @@ pub(crate) async fn login( policy: AuthCodePolicy, present: F, ) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + login_counting(client, endpoints, client_id, policy, None, present).await +} + +/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。 +pub(crate) async fn login_counting( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + counter: Option<&std::sync::atomic::AtomicUsize>, + present: F, +) -> AuthResult where F: FnOnce(&AuthCodePrompt), { 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()); present(&AuthCodePrompt { authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state), @@ -194,6 +213,51 @@ where 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( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + counter: &std::sync::atomic::AtomicUsize, + present: F, +) -> AuthResult +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`(不用于加密,只用来把迟到的旧回调挡掉)。 /// /// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 diff --git a/crates/core/src/auth/microsoft/callback.rs b/crates/core/src/auth/microsoft/callback.rs index 2e8a27c..400a6a3 100644 --- a/crates/core/src/auth/microsoft/callback.rs +++ b/crates/core/src/auth/microsoft/callback.rs @@ -66,11 +66,29 @@ impl CallbackServer { port: Option, attempts: u8, state: impl Into, + ) -> AuthResult { + 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, + attempts: u8, + state: impl Into, + counter: Option<&std::sync::atomic::AtomicUsize>, ) -> AuthResult { let wanted = port.unwrap_or(0); let attempts = attempts.max(1); let mut last = String::new(); 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 { Ok(listener) => { let port = listener diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs index 9a5a648..67a1d32 100644 --- a/crates/core/src/auth/microsoft/client_id.rs +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -116,140 +116,5 @@ pub fn client_id_status() -> ClientIdStatus { } #[cfg(test)] -mod tests { - 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, - } - - 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。 - assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); - } - - #[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()); - } - } -} +#[path = "client_id/tests.rs"] +mod tests; diff --git a/crates/core/src/auth/microsoft/client_id/tests.rs b/crates/core/src/auth/microsoft/client_id/tests.rs new file mode 100644 index 0000000..c9ccad6 --- /dev/null +++ b/crates/core/src/auth/microsoft/client_id/tests.rs @@ -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, +} + +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。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); +} + +#[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 { + 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 = 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::(); + 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\";" + )); +} diff --git a/crates/core/src/auth/microsoft/login.rs b/crates/core/src/auth/microsoft/login.rs new file mode 100644 index 0000000..ad50f47 --- /dev/null +++ b/crates/core/src/auth/microsoft/login.rs @@ -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) -> 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, + /// 这次登录一共开了几个本地监听口(设备码路恒 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( + &self, + present_browser: FB, + mut present_device: FD, + ) -> AuthResult + where + FB: FnOnce(&AuthCodePrompt), + FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>), + { + let mut fallback: Option = 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), + }) + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs index ec8c3af..a3e8753 100644 --- a/crates/core/src/auth/microsoft/mod.rs +++ b/crates/core/src/auth/microsoft/mod.rs @@ -1,29 +1,39 @@ //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 //! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! -//! 分六块,各管一件事: //! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); -//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); -//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); -//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token); +//! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 + +//! 回环注册预检 + 回退设备码的编排); //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! //! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`], //! 落盘/刷新也共用 [`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 从哪来 //! -//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、 -//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 -//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 -//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 +//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。 +//! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配; +//! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码, +//! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是 //! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])—— //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! -//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在 -//! [`client_id`],本模块只消费结论。 +//! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。 pub mod authcode; pub mod callback; @@ -31,11 +41,15 @@ pub mod chain; pub mod client_id; pub mod endpoints; pub mod flow; +pub mod login; +pub mod preflight; pub mod session; pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; pub use callback::CallbackServer; +// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。 +pub use preflight::{RedirectRegistration, PROBE_PORT}; // client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认 // `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。 pub use client_id::{ @@ -44,6 +58,7 @@ pub use client_id::{ }; pub use endpoints::MicrosoftEndpoints; pub use flow::{DeviceCodePrompt, PollPolicy}; +pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -63,6 +78,7 @@ pub struct MicrosoftAuth { endpoints: MicrosoftEndpoints, poll: PollPolicy, authcode: AuthCodePolicy, + browser_first: bool, journal: Option, } @@ -81,6 +97,7 @@ impl MicrosoftAuth { endpoints: MicrosoftEndpoints::consumers(), poll: PollPolicy::default(), authcode: AuthCodePolicy::default(), + browser_first: false, journal: None, }) } @@ -155,6 +172,22 @@ impl MicrosoftAuth { 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] pub fn endpoints(&self) -> &MicrosoftEndpoints { @@ -354,13 +387,14 @@ mod tests { .endpoints() .profile .contains("api.minecraftservices.com")); + // 默认**不是**浏览器优先(M178:设备码才是默认路)。 + assert!(!auth.browser_first()); } #[test] fn now_ms_is_sane() { // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 let now = now_ms(); - assert!(now > 1_577_836_800_000); - assert!(now < 4_102_444_800_000); + assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000); } } diff --git a/crates/core/src/auth/microsoft/preflight.rs b/crates/core/src/auth/microsoft/preflight.rs new file mode 100644 index 0000000..f2ef0c6 --- /dev/null +++ b/crates/core/src/auth/microsoft/preflight.rs @@ -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::>().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( + "Sign in to your account..." + )); + } + + #[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); + } +} diff --git a/crates/core/src/download/font.rs b/crates/core/src/download/font.rs new file mode 100644 index 0000000..1d3f731 --- /dev/null +++ b/crates/core/src/download/font.rs @@ -0,0 +1,352 @@ +//! OFL 中文字体的取回(D28 后半):**缓存命中 → 共享引擎下载 → 复验 → 原子落盘**。 +//! +//! ## 一段话 +//! +//! `unlucky_i18n::font` 只做判据与「该下载什么」(URL 候选 + sha256 + 字节数),真正把字节弄到 +//! 盘上的活在这里: +//! +//! ```text +//! <数据根>/fonts/cjk-noto-sans-sc-.ttf +//! 已有且哈希 + 魔数都对 ⇒ 直接返回(零网络) +//! 否则 ⇒ 共享引擎的 download_bytes(进内存) +//! ⇒ 自己再算一遍 sha256、再看一眼是不是 sfnt、再看大小上限 +//! ⇒ 全过才 write_atomic 落盘 ⇒ 返回字节 +//! ``` +//! +//! ## 为什么用「内存下载」而不是「边下边写文件」 +//! +//! 一张 17 MiB 的字体本来就要整份进内存(注册进字体系统要 `Vec`),而**先复验再落盘**让 +//! 「哈希不符」这条判据简单到不可能出错:盘上要么没有这个文件,要么它是校验过的。文件下载路径 +//! (`DownloadEngine::download`)会先写 `.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 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, + /// 是否零网络命中缓存。 + 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 { + 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 { + 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 = None; + let mut integrity_error: Option = 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>, 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; diff --git a/crates/core/src/download/font/spec.rs b/crates/core/src/download/font/spec.rs new file mode 100644 index 0000000..8c3cba1 --- /dev/null +++ b/crates/core/src/download/font/spec.rs @@ -0,0 +1,106 @@ +//! 「下什么、放哪」:字体 pin 的**值**表示([`FontSpec`])与缓存落点([`cache_path`])。 +//! +//! 从 `font.rs` 拆出只为守 400 行硬线(M168 复审 r1 加了整体时限与 URL 口径之后越线)。 +//! 判据(哪些 URL 可用)、复验、落盘编排都在 [`super`] 那边;这里只有数据结构与路径规则。 + +use std::path::{Path, PathBuf}; + +use super::{FontFetchError, MAX_FONT_BYTES}; + +/// 一套字体的「在哪拿、拿到什么」:URL 候选(首选 + 备用镜像)+ 期望 sha256 + 期望字节数。 +/// +/// 与 `unlucky_i18n::font::Source::Pinned` 是同一份事实的两种表示:i18n 那份是**常量**(零依赖 +/// crate 里写死),本结构是**值**(可被设置改写、可在测试里注入假 URL)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FontSpec { + /// URL 候选,按顺序尝试(首选 CDN 挂了才试备用镜像)。 + pub urls: Vec, + /// 期望 sha256(小写十六进制、64 位)。 + pub sha256: String, + /// 期望字节数(进度预算 + 大小上限的判据)。 + pub bytes: u64, +} + +impl FontSpec { + /// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。 + /// + /// # Errors + /// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。 + pub fn new( + urls: impl IntoIterator>, + sha256: impl Into, + bytes: u64, + ) -> Result { + let spec = Self { + urls: urls.into_iter().map(Into::into).collect(), + sha256: sha256.into().trim().to_ascii_lowercase(), + bytes, + }; + spec.check()?; + Ok(spec) + } + + /// pin 是否自洽(`super::fetch_blocking*` 与构造器都调它,故 `pub(super)`)。 + pub(super) fn check(&self) -> Result<(), FontFetchError> { + if self.urls.is_empty() { + return Err(FontFetchError::Integrity("字体 pin 没有 URL".to_owned())); + } + if let Some(bad) = self.urls.iter().find(|url| !is_acceptable_url(url)) { + return Err(FontFetchError::Integrity(format!( + "字体 pin 只接受 https(http 仅限回环地址,给单测用):{bad}" + ))); + } + if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(FontFetchError::Integrity(format!( + "字体 pin 的 sha256 不是 64 位十六进制:{}", + self.sha256 + ))); + } + if self.bytes == 0 || self.bytes > MAX_FONT_BYTES { + return Err(FontFetchError::Integrity(format!( + "字体 pin 的字节数 {} 不在 (0, {MAX_FONT_BYTES}] 内", + self.bytes + ))); + } + Ok(()) + } + + /// 缓存文件名的哈希段(前 16 位:够区分,又不至于让路径长得没法看)。 + #[must_use] + fn cache_key(&self) -> &str { + let end = self.sha256.len().min(16); + &self.sha256[..end] + } +} + +/// URL 是否可用:**https 一律可以;http 只给回环地址**(单测的回环 HTTP 服务要用)。 +/// +/// 为什么这条要写进校验而不是只写进注释:字体是从网络下来、直接注册进渲染器的东西。明文 http +/// 在 sha256 钉死的前提下不会导致「内容被换」,但会把「从哪拿」暴露给路上的任何人(可被替换成 +/// 任意大文件来耗流量/磁盘,虽然会被大小上限与哈希挡住)。契约写成「非回环必须 https」, +/// 注释与实现就是一个口径。 +fn is_acceptable_url(url: &str) -> bool { + if url.starts_with("https://") { + return true; + } + let Some(rest) = url.strip_prefix("http://") else { + return false; + }; + let host_port = rest.split('/').next().unwrap_or_default(); + let host = host_port + .rsplit_once(':') + .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())) + .map_or(host_port, |(host, _)| host); + let host = host.trim_start_matches('[').trim_end_matches(']'); + matches!(host, "127.0.0.1" | "localhost" | "::1") || host.starts_with("127.") +} + +/// 缓存文件落点:`<数据根>/fonts/cjk-noto-sans-sc-.ttf`。 +/// +/// 文件名带哈希段是刻意的:pin 一改(换字体 / 换子集),新文件自然换名,旧文件不会冒充命中。 +#[must_use] +pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf { + cache_root + .join("fonts") + .join(format!("cjk-noto-sans-sc-{}.ttf", spec.cache_key())) +} diff --git a/crates/core/src/download/font/tests.rs b/crates/core/src/download/font/tests.rs new file mode 100644 index 0000000..06d3123 --- /dev/null +++ b/crates/core/src/download/font/tests.rs @@ -0,0 +1,319 @@ +//! `download::font` 的守卫(三条都能变红,变异证明见 M168 报告)。 +//! +//! ## 离线纪律 +//! +//! 服务端是 `127.0.0.1` 上的回环监听器,把**内存里的假字体字节**当响应体发出去:不碰任何 +//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在** +//! 本文件的能力范围内(报告里写明)。 + +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use super::*; + +#[path = "tests/support.rs"] +mod support; + +use support::{dead_url, fake_font, root, serve, serve_trickle, sha256, spec_for}; + +/// 守卫 ③:**缓存命中不联网**。 +/// +/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉——URL 指向的死人端口会被真的去连,本用例红。 +/// 抓不住:真机上 17 MiB 文件读盘有多快。 +#[test] +fn cache_hit_never_touches_the_network() { + let dir = root("cache"); + let body = fake_font(b"cached payload"); + let spec = spec_for(vec![dead_url()], &body); + let path = cache_path(&dir, &spec); + std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录"); + std::fs::write(&path, &body).expect("预置缓存"); + + let fetched = fetch_blocking(&dir, &spec).expect("缓存命中应成功"); + assert!(fetched.from_cache, "必须报「来自缓存」"); + assert_eq!(fetched.bytes, body); + assert_eq!(fetched.source, "cache"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 守卫 ②:**sha256 不符时拒绝落盘**(正式路径上什么都不许留下)。 +/// +/// **两层校验是互备的**(复审 r3 顺手项①:上一版这里写「只拆一层也会红」是假的,复审变异实测 +/// 单独拆掉引擎的 `with_sha256` 或单独短路本模块复验,本用例都**仍然绿**——另一层会接住)。 +/// 本用例真正抓的是**两层同时被拆**:那时坏字节会被当成字体返回、`cache_path` 也会落盘,断言在 +/// `expect_err` 处红(r1 的变异证明就是这么打的)。 +/// +/// 抓不住:单层的实现细节(比如 `verify::matches` 的大小写处理)——那要各自的单元用例。 +#[test] +fn sha256_mismatch_is_refused_and_nothing_lands() { + let dir = root("mismatch"); + let served = fake_font(b"what the cdn claims"); + let expected = fake_font(b"what the pin expects"); + let (url, _served) = serve(served.clone(), 4); + let spec = FontSpec::new(vec![url], sha256(&expected), served.len() as u64).expect("pin"); + + let error = fetch_blocking(&dir, &spec).expect_err("哈希不符必须失败"); + assert_eq!(error.class(), "integrity", "{error}"); + let path = cache_path(&dir, &spec); + assert!( + !path.exists(), + "校验不过的字体不许出现在 {}", + path.display() + ); + // 字体目录里什么都不该留下:复验在写盘之前,所以连中间态都不存在。 + let leftovers = std::fs::read_dir(path.parent().unwrap()) + .map_or(0, |entries| entries.filter_map(Result::ok).count()); + assert_eq!(leftovers, 0, "校验不过时字体目录必须干净"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 守卫 ③ 的另一半:**下过一次就再也不下**(同进程第二次调用零请求)。 +/// +/// 抓得住:缓存写失败(换名没做)/ 缓存文件名不带哈希(pin 换了还命中旧文件)/ 每次调用都 +/// 重新下载——第一条与第三条都会让 `served == 2`,本用例红。 +#[test] +fn the_second_call_is_served_from_the_cache() { + let dir = root("second"); + let body = fake_font(b"payload of the second call"); + // 服务端最多伺候 4 条请求:若第二次调用又联网,计数会变成 2。 + let (url, served) = serve(body.clone(), 4); + let spec = spec_for(vec![url], &body); + + let first = fetch_blocking(&dir, &spec).expect("第一次应成功"); + assert!( + !first.from_cache, + "第一次必须是真下载(否则这条用例什么也没证明" + ); + assert_eq!(first.bytes, body); + + let second = fetch_blocking(&dir, &spec).expect("第二次应命中缓存"); + assert!(second.from_cache, "第二次不许再联网"); + assert_eq!( + served.load(Ordering::SeqCst), + 1, + "整个过程只该有一条 HTTP 请求" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 坏缓存**自愈**:哈希不符的旧文件要被删掉重下,而不是被当成命中,也不是永远挡路。 +#[test] +fn a_corrupt_cache_file_is_healed_by_a_fresh_download() { + let dir = root("heal"); + let body = fake_font(b"fresh bytes"); + let spec = spec_for(vec![dead_url()], &body); + let path = cache_path(&dir, &spec); + std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录"); + std::fs::write(&path, fake_font(b"rotten bytes")).expect("预置坏缓存"); + + // 坏缓存 + 死人 URL ⇒ 只能报网络错(若把坏文件当命中会返回 Ok,本用例红)。 + let error = fetch_blocking(&dir, &spec).expect_err("坏缓存不该被当成命中"); + assert_eq!(error.class(), "network", "{error}"); + assert!(!path.exists(), "坏缓存必须被删掉"); + + // 换成真服务端:同一次进程里重来一次就能落地(自愈)。 + let (url, _served) = serve(body.clone(), 4); + let spec = spec_for(vec![url], &body); + let fetched = fetch_blocking(&dir, &spec).expect("自愈后应成功"); + assert!(!fetched.from_cache); + assert!(path.exists()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// CDN 回了一个 HTML 错误页(哈希还对不上/对得上都一样):不许当字体落盘。 +/// +/// 抓得住:`looks_like_sfnt` 这道闸被删掉——哈希正确时它会让一堆 HTML 进字体系统。 +/// 抓不住:真字体里子集不全这类内容问题(那要 cmap 检查)。 +#[test] +fn an_html_error_page_is_not_a_font() { + let dir = root("html"); + let body = b"403 Forbidden".to_vec(); + let (url, _served) = serve(body.clone(), 4); + let spec = spec_for(vec![url], &body); + + let error = fetch_blocking(&dir, &spec).expect_err("HTML 不是字体"); + assert_eq!(error.class(), "integrity", "{error}"); + assert!(!cache_path(&dir, &spec).exists()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 守卫(M168 复审 r2 P2-1/P2-3):**单次尝试的时限会打断爬行连接并转下一个候选**。 +/// +/// 这个场景现在是真的:第一个候选是 `serve_trickle()`(发真实 CRLF 响应头,之后每 100 ms 一个 +/// 字节),第二个候选是正常服务端。 +/// +/// 抓得住:①`serve_trickle` 的响应头写错(上一版就是这个病:真实换行字节 + 4 字节窗口 ⇒ 恒 false +/// ⇒ 一个字节都不发)——那样第一个候选会在**读不到响应头**时按网络错失败,用例里 +/// `trickle_served` 的「服务端真的发过头」这条断言会红;②`budget` 不是 `min(单次, 剩余)` 而是 +/// 直接用总预算 ⇒ 本用例会等到总预算(5 s)才换源,`elapsed < 3 s` 红;③时限被归成校验失败。 +/// 抓不住:默认值(120 s / 360 s)在真弱网下够不够(那要真 CDN,见报告「未验」)。 +#[test] +fn a_trickling_candidate_is_cut_by_the_attempt_deadline() { + let dir = root("deadline-attempt"); + let good = fake_font(b"served by the second candidate"); + let (good_url, _served) = serve(good.clone(), 4); + let (trickle_url, proof) = serve_trickle(); + let spec = FontSpec::new( + vec![trickle_url, good_url], + sha256(&good), + good.len() as u64, + ) + .expect("pin"); + + let started = std::time::Instant::now(); + let fetched = fetch_blocking_with_limits( + &dir, + &spec, + Duration::from_millis(300), + Duration::from_secs(5), + ) + .expect("第二个候选应当成功"); + assert_eq!(fetched.bytes, good); + assert!( + started.elapsed() < Duration::from_secs(3), + "单次上限该在 300 ms 处切断第一个候选,实际 {:?}", + started.elapsed() + ); + assert!(cache_path(&dir, &spec).exists()); + // **这条是「夹具没坏」的判据**(复审 r2 P2-1):第一个候选必须真的发过响应头、真的挤过字节。 + // 少了这两条,一个「什么都不发的服务端」也能让本用例绿——那证明的是「连接挂着被掐断」, + // 而不是我们想证明的「活着但在爬」。 + assert!( + proof.header.load(Ordering::SeqCst), + "爬行服务端必须真的发出响应头" + ); + assert!( + proof.dripped.load(Ordering::SeqCst) >= 1, + "爬行服务端必须真的挤出过字节(否则引擎的空闲超时那条路径根本没被造出来)" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 守卫(M168 复审 r3 P2-3):**最后一个候选拿整个剩余预算**(`is_last` 那一支)。 +/// +/// 形状是刻意挑的:**单候选** + `attempt = 300 ms` / `total = 5 s`。 +/// * 有 `is_last`:最后一个(也是唯一一个)候选拿到剩下的 ~5 s ⇒ 只在**总时限**处被打断, +/// 耗时 ≈5 s; +/// * 没有 `is_last`(一律 `attempt.min(remaining)`):300 ms 就切,耗时 ≈0.3 s。 +/// +/// 两种实现的解不同,于是这条用例能分辨它们——上一版给的是 `attempt=30 s > total=400 ms`, +/// 两种实现同解,天生测不出差别(复审 r3 就是据此说 `is_last` 没人守)。 +/// +/// 抓得住:删掉 `is_last` 那一支(或把预算写成 `attempt_deadline.min(remaining)` 一视同仁)⇒ +/// `elapsed >= 3 s` 当场红;也抓得住服务端其实不发字节(`proof` 的两个断言)。 +/// 抓不住:真弱网下 60 s / 420 s 够不够(要真 CDN,见报告「未验」)。 +#[test] +fn the_last_candidate_gets_the_whole_remaining_budget() { + let dir = root("deadline-last"); + let body = fake_font(b"not what the server sends"); + let (trickle_url, proof) = serve_trickle(); + let spec = FontSpec::new(vec![trickle_url], sha256(&body), body.len() as u64).expect("pin"); + + let started = std::time::Instant::now(); + let error = fetch_blocking_with_limits( + &dir, + &spec, + Duration::from_millis(300), + Duration::from_secs(5), + ) + .expect_err("爬行连接最终必须被总时限打断"); + let elapsed = started.elapsed(); + + assert_eq!(error.class(), "network", "超时属于网络档:{error}"); + assert!( + error.message().contains("超时"), + "文案要说得清是超时:{error}" + ); + assert!( + elapsed >= Duration::from_secs(3), + "最后一个候选该拿整个剩余预算(≈5 s),而不是在 300 ms 处就被切:{elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(9), + "不该超过总时限太多:{elapsed:?}" + ); + assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘"); + assert!( + proof.dripped.load(Ordering::SeqCst) >= 1, + "这条用例必须掐在「有字节在动」的连接上,而不是一个不响的端口" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// pin 的 URL 口径:**https 一律可以,http 只给回环**(M168 复审次要项)。 +/// +/// 抓得住:把 `http://` 一律放行(注释说「必须 https」、实现说「随便」的老毛病), +/// 也抓得住收紧过头(连回环的单测服务都不让用 ⇒ 一批用例会红)。 +#[test] +fn the_pin_only_accepts_https_or_loopback_http() { + let hash = "a".repeat(64); + assert!(FontSpec::new(vec!["https://mirror.example/a.ttf"], &hash, 1).is_ok()); + assert!(FontSpec::new(vec!["http://127.0.0.1:8080/a.ttf"], &hash, 1).is_ok()); + assert!(FontSpec::new(vec!["http://localhost:9/a.ttf"], &hash, 1).is_ok()); + assert!( + FontSpec::new(vec!["http://127.0.0.2:9/a.ttf"], &hash, 1).is_ok(), + "127.0.0.0/8 整个都是回环" + ); + assert!( + FontSpec::new(vec!["http://example.com/a.ttf"], &hash, 1).is_err(), + "非回环的明文 http 不许过" + ); + assert!(FontSpec::new(vec!["ftp://example.com/a.ttf"], &hash, 1).is_err()); +} + +/// pin 自身不合法时**一个请求都不发**(免得为一个坏 pin 白跑一趟网络)。 +#[test] +fn an_invalid_pin_never_reaches_the_network() { + let dir = root("pin"); + let served = fake_font(b"x"); + let (url, counter) = serve(served.clone(), 4); + + // 哈希形态不对 + assert!(FontSpec::new(vec![url.clone()], "not-a-hash", 10).is_err()); + // 非 http(s) + assert!(FontSpec::new(vec!["file:///etc/passwd"], sha256(&served), 10).is_err()); + // 没有 URL + assert!(FontSpec::new(Vec::::new(), sha256(&served), 10).is_err()); + // 超过大小上限 + assert!(FontSpec::new(vec![url], sha256(&served), MAX_FONT_BYTES + 1).is_err()); + // 零字节 + assert!(FontSpec::new(vec!["https://example.invalid/f.ttf"], sha256(&served), 0).is_err()); + + assert_eq!(counter.load(Ordering::SeqCst), 0, "坏 pin 不许发请求"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// `looks_like_sfnt`:只认魔数 + 表目录,不猜内容。 +#[test] +fn sfnt_sniffing_accepts_fonts_and_rejects_junk() { + assert!(looks_like_sfnt(&fake_font(b"x"))); + // OTF(CFF 轮廓):魔数 OTTO + 一张表 + let mut otf = b"OTTO".to_vec(); + otf.extend_from_slice(&1u16.to_be_bytes()); + otf.extend_from_slice(&[0u8; 6]); + otf.extend_from_slice(&[0u8; 16]); + assert!(looks_like_sfnt(&otf)); + // TTC(字体集合):长度够就收 + assert!(looks_like_sfnt( + b"ttcf\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + )); + assert!(!looks_like_sfnt(b"")); + assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x00")); // 表数 0 + assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x02")); // 长度不够装两张表 + assert!(!looks_like_sfnt(&[])); +} + +/// 缓存文件名带 pin 的哈希段:换 pin(换字体/换子集)⇒ 换文件名 ⇒ 不会命中旧内容。 +#[test] +fn the_cache_name_changes_with_the_pin() { + let dir: &Path = std::path::Path::new("/tmp/ull-font-name"); + let a = FontSpec::new(vec!["https://x.invalid/a.ttf"], "a".repeat(64), 1).expect("pin a"); + let b = FontSpec::new(vec!["https://x.invalid/a.ttf"], "b".repeat(64), 1).expect("pin b"); + assert_ne!(cache_path(dir, &a), cache_path(dir, &b)); + assert_eq!( + cache_path(dir, &a), + dir.join("fonts") + .join(format!("cjk-noto-sans-sc-{}.ttf", "a".repeat(16))) + ); +} diff --git a/crates/core/src/download/font/tests/support.rs b/crates/core/src/download/font/tests/support.rs new file mode 100644 index 0000000..89f4eed --- /dev/null +++ b/crates/core/src/download/font/tests/support.rs @@ -0,0 +1,177 @@ +//! `download::font` 的测试夹具(回环 HTTP 服务端 / 假字体字节 / 临时数据根)。 +//! +//! 从 `tests.rs` 拆出只为守 400 行硬线(复审 r2 加了两条时限用例之后越线)。这里只有夹具, +//! 断言全在 `tests.rs`——两个回环服务端(`serve` 正常、`serve_trickle` 只爬)是「缓存/校验/时限」 +//! 三组守卫的地基,改坏它们等于把守卫变成空转(复审 r2 P2-1 就是这么发现上一版的 +//! CRLF 字面量写坏了的)。 + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use super::super::super::verify; +use super::super::{FontSpec, HashAlgo}; + +/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。 +pub(super) fn root(tag: &str) -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let seq = SEQ.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("ull-font-{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建数据根"); + dir +} + +/// 一段「像字体」的字节:sfnt 头 + 一张表记录 + 载荷。 +/// +/// 为什么造它而不是塞随机字节:`looks_like_sfnt` 是落盘前的一道闸,测试若拿 HTML 当正常内容, +/// 「下载成功」这条路径就永远走不到。 +pub(super) fn fake_font(payload: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"\x00\x01\x00\x00"); // sfnt 版本 + bytes.extend_from_slice(&1u16.to_be_bytes()); // numTables + bytes.extend_from_slice(&[0u8; 6]); // searchRange / entrySelector / rangeShift + bytes.extend_from_slice(b"TEST"); // 表名 + bytes.extend_from_slice(&0u32.to_be_bytes()); // checksum + bytes.extend_from_slice(&28u32.to_be_bytes()); // offset + bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + bytes.extend_from_slice(payload); + bytes +} + +pub(super) fn sha256(bytes: &[u8]) -> String { + verify::digest_bytes(HashAlgo::Sha256, bytes) +} + +/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。 +/// +/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。 +pub(super) fn serve(body: Vec, expected_requests: usize) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口"); + let addr = listener.local_addr().expect("取端口"); + let served = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&served); + + std::thread::spawn(move || { + let mut requests = 0usize; + while requests < expected_requests { + let Ok((mut socket, _)) = listener.accept() else { + break; + }; + let _ = socket.set_read_timeout(Some(Duration::from_secs(10))); + let mut seen = Vec::new(); + loop { + let mut chunk = [0u8; 1024]; + match socket.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(read) => seen.extend_from_slice(&chunk[..read]), + } + if !seen.windows(4).any(|window| window == b"\r\n\r\n") { + continue; + } + requests += 1; + counter.store(requests, Ordering::SeqCst); + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + if socket.write_all(head.as_bytes()).is_err() || socket.write_all(&body).is_err() { + break; + } + seen.clear(); + if requests >= expected_requests { + return; + } + } + } + }); + + (format!("http://{addr}/cjk.ttf"), served) +} + +/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。 +pub(super) fn dead_url() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("占一个端口"); + let addr = listener.local_addr().expect("取端口"); + drop(listener); + format!("http://{addr}/cjk.ttf") +} + +/// 爬行服务端的取证计数器:`header` = 真的发过响应头,`dripped` = 真的挤出过几个字节。 +/// +/// 为什么把这两个数字**带出去**:这类 `serve_*` 夹具坏掉时,用例会静默变成另一种场景 +/// (复审 r2 P2-1 就是:请求头永远读不完 ⇒ 一个字节不发 ⇒ 用例证明的是「连接挂着」,而不是 +/// 「活着但在爬」)。用例拿到这两个数就能断言「服务端确实在说话」。 +pub(super) struct TrickleProof { + /// 响应头是否真的写出去了。 + pub header: Arc, + /// 写出去了几个正文字节。 + pub dripped: Arc, +} + +/// 由 URL 候选 + 期望字节算 pin(哈希取这批字节的 sha256)。 +pub(super) fn spec_for(urls: Vec, bytes: &[u8]) -> FontSpec { + FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽") +} + +/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永远不结束**。 +/// +/// 这就是复审 r1 P2-2 实测到的那条路径(Fastly 的 IPv6 边缘 ≈67 KB/s):引擎的 `read_timeout` +/// 是 30 s **空闲**超时,而字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们这一层的整体时限 +/// 能打断它——这条服务端存在的唯一目的就是造出「活着但爬」的连接。 +/// +/// 注意这里的 `\r\n` 必须是**转义写法**:上一版这个函数里写成了真实换行字节(`b"\n\n"`,2 字节) +/// 却拿 4 字节窗口去比,`PartialEq` 先比长度 ⇒ 恒 false ⇒ 请求头永远读不完、一个字节都不发, +/// 用例证明的其实是另一件事(复审 r2 P2-1)。`serve()` 那边的写法是对的,别动它。 +pub(super) fn serve_trickle() -> (String, TrickleProof) { + let header_sent = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let dripped = Arc::new(AtomicUsize::new(0)); + let (header_flag, drip_flag) = (Arc::clone(&header_sent), Arc::clone(&dripped)); + let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口"); + let addr = listener.local_addr().expect("取端口"); + std::thread::spawn(move || { + let Ok((mut socket, _)) = listener.accept() else { + return; + }; + let _ = socket.set_read_timeout(Some(Duration::from_secs(5))); + // 先把请求头读干净(不读完就写响应,某些栈会 RST)。 + let mut seen = Vec::new(); + loop { + let mut chunk = [0u8; 1024]; + match socket.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(read) => seen.extend_from_slice(&chunk[..read]), + } + if seen.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + // 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。 + if socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: 1000000\r\n\r\n", + ) + .is_err() + { + return; + } + header_flag.store(true, Ordering::SeqCst); + for _ in 0..600 { + if socket.write_all(&[0u8]).is_err() { + return; + } + drip_flag.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(100)); + } + }); + ( + format!("http://{addr}/cjk.ttf"), + TrickleProof { + header: header_sent, + dripped, + }, + ) +} diff --git a/crates/core/src/download/mod.rs b/crates/core/src/download/mod.rs index 291bb46..fbccf5e 100644 --- a/crates/core/src/download/mod.rs +++ b/crates/core/src/download/mod.rs @@ -53,6 +53,7 @@ pub mod chunked; pub mod config; pub mod engine; pub mod error; +pub mod font; pub mod health; pub mod jdk_sources; pub mod mirrors; @@ -81,6 +82,9 @@ pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport}; pub use self::config::EngineConfig; pub use self::engine::DownloadEngine; pub use self::error::{DownloadError, DownloadErrorKind}; +pub use self::font::{ + cache_path, fetch_blocking, FontFetch, FontFetchError, FontSpec, MAX_FONT_BYTES, +}; pub use self::health::{HealthKey, HealthRegistry, SourceHealth}; pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable}; pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule}; diff --git a/crates/core/src/game/java/manual.rs b/crates/core/src/game/java/manual.rs new file mode 100644 index 0000000..7bb390e --- /dev/null +++ b/crates/core/src/game/java/manual.rs @@ -0,0 +1,324 @@ +//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。 +//! +//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK —— +//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。 +//! +//! # 三条口径 +//! +//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`; +//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数—— +//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩; +//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由 +//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。 +//! +//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验, +//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。 + +use std::path::{Path, PathBuf}; + +use super::extract::java_binary_name; +use super::probe::{self, JavaOrigin, JavaRuntime}; + +/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。 +pub const MAX_PATH_CHARS: usize = 512; + +/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManualFailure { + /// 空串 / 全空白 + Empty, + /// 超过 [`MAX_PATH_CHARS`] + TooLong, + /// 盘上没这个文件或目录 + Missing, + /// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件) + NotJava, + /// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号 + NoVersion, +} + +impl ManualFailure { + /// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::TooLong => "too-long", + Self::Missing => "missing", + Self::NotJava => "not-java", + Self::NoVersion => "no-version", + } + } +} + +/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。 +/// +/// # Errors +/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。 +pub fn resolve_binary(input: &str) -> Result { + let text = input.trim(); + if text.is_empty() { + return Err(ManualFailure::Empty); + } + if text.chars().count() > MAX_PATH_CHARS { + return Err(ManualFailure::TooLong); + } + let path = Path::new(text); + if path.is_file() { + return looks_like_java(path) + .then(|| path.to_path_buf()) + .ok_or(ManualFailure::NotJava); + } + if path.is_dir() { + let binary = path.join("bin").join(java_binary_name()); + return binary + .is_file() + .then_some(binary) + .ok_or(ManualFailure::NotJava); + } + Err(ManualFailure::Missing) +} + +/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。 +fn looks_like_java(path: &Path) -> bool { + path.file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw")) +} + +/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。 +/// +/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行** +/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商, +/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。 +#[must_use] +pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) { + probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false) +} + +/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。 +#[must_use] +pub fn probe_with_timeout( + binary: &Path, + origin: JavaOrigin, + timeout: std::time::Duration, + cancelled: &dyn Fn() -> bool, +) -> (JavaRuntime, String) { + let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); + let mut command = std::process::Command::new(binary); + command.arg("-version"); + let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else { + return (runtime, String::new()); + }; + let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if let Some((line, major)) = probe::parse_version_output(&text) { + runtime.major = Some(major); + runtime.version = Some(line); + runtime.bits = probe::bits_of(&text); + } + runtime.vendor = vendor_of(&text); + (runtime, text) +} + +/// 严格探测:必须真跑得起来 `-version`。 +/// +/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。 +/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。 +/// +/// # Errors +/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。 +pub fn probe_manual(input: &str) -> Result { + let binary = resolve_binary(input)?; + let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System); + if runtime.major.is_none() { + return Err(ManualFailure::NoVersion); + } + Ok(runtime) +} + +/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。 +/// +/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系, +/// 而 `java -version` 的首行/次行本来就把发行方印出来了 +/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。 +#[must_use] +pub fn vendor_of(text: &str) -> Option { + let lowered = text.to_ascii_lowercase(); + // 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。 + const MARKERS: &[(&str, &str)] = &[ + ("temurin", "Temurin"), + ("adoptium", "Temurin"), + ("microsoft", "Microsoft"), + ("zulu", "Zulu"), + ("azul", "Zulu"), + ("corretto", "Corretto"), + ("semeru", "Semeru"), + ("graalvm", "GraalVM"), + ("sapmachine", "SapMachine"), + ("oracle", "Oracle"), + // 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime + // Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。 + ("se runtime", "Oracle"), + ("hotspot", "Oracle"), + ("openjdk", "OpenJDK"), + ]; + MARKERS + .iter() + .find(|(needle, _)| lowered.contains(needle)) + .map(|(_, display)| (*display).to_string()) +} + +/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。 +/// +/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键), +/// 顺序保持文件里的顺序(用户添加的先后)。 +#[must_use] +pub fn load_paths(file: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(file) else { + return Vec::new(); + }; + let mut paths: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let path = PathBuf::from(line); + if !paths + .iter() + .any(|known| super::discover::path_key(known) == super::discover::path_key(&path)) + { + paths.push(path); + } + } + paths +} + +/// 写名单(一行一个,末尾换行;父目录不存在就建)。 +/// +/// # Errors +/// 目录建不出来或写盘失败时返回 IO 错误。 +pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let mut text = String::new(); + for path in paths { + text.push_str(&path.display().to_string()); + text.push('\n'); + } + std::fs::write(file, text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-manual-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("临时目录"); + dir + } + + #[test] + fn recognises_vendors_from_real_version_lines() { + assert_eq!( + vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"), + Some("Temurin".to_string()) + ); + assert_eq!( + vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"), + Some("Microsoft".to_string()) + ); + assert_eq!( + vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"), + Some("Zulu".to_string()) + ); + assert_eq!( + vendor_of( + "java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)" + ), + Some("Oracle".to_string()) + ); + assert_eq!(vendor_of("nothing useful here"), None); + } + + #[test] + fn accepts_a_directory_and_a_binary_but_rejects_anything_else() { + let dir = temp("resolve"); + let jdk = dir.join("jdk-21"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + let binary = jdk.join("bin").join(java_binary_name()); + std::fs::write(&binary, b"").expect("写 java"); + + assert_eq!( + resolve_binary(&jdk.display().to_string()), + Ok(binary.clone()) + ); + assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary)); + assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty)); + assert_eq!( + resolve_binary(&dir.join("nope").display().to_string()), + Err(ManualFailure::Missing) + ); + let empty = dir.join("empty"); + std::fs::create_dir_all(&empty).expect("empty"); + assert_eq!( + resolve_binary(&empty.display().to_string()), + Err(ManualFailure::NotJava) + ); + // 目录里有一个**不叫 java 的**可执行文件:也不算。 + let text = dir.join("notes.txt"); + std::fs::write(&text, b"hi").expect("写文本"); + assert_eq!( + resolve_binary(&text.display().to_string()), + Err(ManualFailure::NotJava) + ); + let long = "a".repeat(MAX_PATH_CHARS + 1); + assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_binary_that_cannot_answer_version_is_refused() { + let dir = temp("noversion"); + let jdk = dir.join("jdk"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + // 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。 + std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java"); + assert_eq!( + probe_manual(&jdk.display().to_string()).err(), + Some(ManualFailure::NoVersion) + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_list_round_trips_and_dedupes() { + let dir = temp("list"); + let file = dir.join("config").join("java-extra-paths.txt"); + let paths = vec![ + PathBuf::from("C:/jdk/17"), + PathBuf::from("C:/jdk/21"), + PathBuf::from("C:\\jdk\\17"), + ]; + save_paths(&file, &paths).expect("写名单"); + // 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。 + assert_eq!(load_paths(&file).len(), 2); + assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17")); + + let missing = dir.join("nope.txt"); + assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index dd2aad7..1b93f57 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -45,14 +45,17 @@ pub mod error; pub mod extract; pub mod install; pub mod location; +pub mod manual; pub mod mapping; pub mod microsoft; pub mod net; pub mod oracle; pub mod probe; +pub mod proc; pub mod release; pub mod select; pub mod source; +pub mod staged; pub mod target; pub mod termux; pub mod vendor; diff --git a/crates/core/src/game/java/proc.rs b/crates/core/src/game/java/proc.rs new file mode 100644 index 0000000..beeb35d --- /dev/null +++ b/crates/core/src/game/java/proc.rs @@ -0,0 +1,169 @@ +//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。 +//! +//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不 +//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。 + +use std::time::Duration; + +/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。 +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。 +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。 +/// +/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。 +/// +/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死, +/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。 +#[must_use] +pub fn run_with_timeout( + mut command: std::process::Command, + timeout: Duration, + cancelled: &dyn Fn() -> bool, +) -> Option { + use std::process::Stdio; + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().ok()?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if cancelled() || started.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + child.wait_with_output().ok() +} + +#[cfg(test)] +mod tests { + use super::run_with_timeout; + + #[test] + fn a_hung_probe_is_killed_after_the_timeout() { + // 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。 + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_millis(400), + &|| false, + ); + assert!(output.is_none(), "超时必须判成「没有结论」"); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(5), + "超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}" + ); + } + + #[test] + fn a_cancel_interrupts_the_probe_that_is_already_running() { + // 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。 + let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let tripper = flag.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + tripper.store(true, std::sync::atomic::Ordering::SeqCst); + }); + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_secs(30), + &|| flag.load(std::sync::atomic::Ordering::SeqCst), + ); + assert!(output.is_none(), "被取消的探测没有结论"); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "取消必须打断正在跑的那个探测,而不是等它自己结束" + ); + } + + /// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。 + /// + /// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()` + /// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。 + /// + /// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把 + /// 标记文件写出来 ⇒ 本条红。 + #[test] + fn a_timed_out_child_is_really_killed() { + let dir = std::env::temp_dir().join(format!( + "ull-proc-kill-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("临时目录"); + let marker = dir.join("survived.txt"); + let _ = std::fs::remove_file(&marker); + + let output = run_with_timeout( + marker_command(&marker, 4), + std::time::Duration::from_millis(300), + &|| false, + ); + assert!(output.is_none(), "超时没有结论"); + // 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。 + std::thread::sleep(std::time::Duration::from_secs(6)); + assert!( + !marker.exists(), + "子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。 + fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command { + let target = marker.display().to_string(); + if cfg!(windows) { + // PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的 + // 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用 + // PowerShell 的单引号,外层只剩 Rust 加的那一层。 + let mut command = std::process::Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + &format!( + "Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive" + ), + ]); + command + } else { + let mut command = std::process::Command::new("sh"); + command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]); + command + } + } + + /// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。 + fn sleeping_command(seconds: u32) -> std::process::Command { + if cfg!(windows) { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]); + command + } else { + let mut command = std::process::Command::new("sleep"); + command.arg(seconds.to_string()); + command + } + } +} diff --git a/crates/core/src/game/java/staged.rs b/crates/core/src/game/java/staged.rs new file mode 100644 index 0000000..c4e7f86 --- /dev/null +++ b/crates/core/src/game/java/staged.rs @@ -0,0 +1,347 @@ +//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。 +//! +//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起, +//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。 +//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开: +//! +//! ```text +//! candidates(roots, manual, system) 纯文件系统,快、无子进程 +//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停 +//! ``` +//! +//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层—— +//! 所以 `ull java list` 与设置页看到的是同一批 JDK。 + +use std::path::PathBuf; + +use super::discover::{self, path_key}; +use super::extract::{java_binary_name, read_stamp}; +use super::probe::{self, JavaOrigin, JavaRuntime}; +use super::vendor::JdkVendor; + +/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// 托管目录(`ull java install` 装的) + Managed, + /// `JAVA_HOME` + JavaHome, + /// `PATH` 上的 `java` + Path, + /// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`) + System, + /// 用户在设置页手动指的路径 + Manual, +} + +impl Source { + /// 稳定 id(DTO / UI 文案选档用)。 + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::JavaHome => "env", + Self::Path => "path", + Self::System => "system", + Self::Manual => "manual", + } + } + + /// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前—— + /// 用户亲手指的是明确的意图)。 + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Managed => 1, + Self::JavaHome => 2, + Self::Path => 3, + Self::Manual => 4, + Self::System => 5, + } + } + + /// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可: + /// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。 + #[must_use] + pub const fn origin(self) -> JavaOrigin { + match self { + Self::Managed => JavaOrigin::Managed, + Self::JavaHome => JavaOrigin::JavaHome, + Self::Path => JavaOrigin::Path, + Self::System | Self::Manual => JavaOrigin::System, + } + } +} + +/// 一个待探测的候选。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// `bin/java[.exe]` 的绝对路径 + pub binary: PathBuf, + /// 来源 + pub source: Source, +} + +/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。 +/// +/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置, +/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。 +#[must_use] +pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let mut out: Vec = Vec::new(); + for root in managed_roots { + out.extend(managed_in(root)); + } + // 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。 + for path in manual { + let binary = if path.is_dir() { + path.join("bin").join(java_binary_name()) + } else { + path.clone() + }; + if binary.is_file() { + out.push(Candidate { + binary, + source: Source::Manual, + }); + } + } + if system { + out.extend(env_candidates()); + out.extend( + discover::scan_java_binaries() + .into_iter() + .map(|binary| Candidate { + binary, + source: Source::System, + }), + ); + } + dedupe(out) +} + +/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。 +fn managed_in(root: &std::path::Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if !path.is_dir() || name.starts_with('.') { + continue; + } + if let Some(binary) = stamped_binary(&path) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + continue; + } + // 两层布局 `/`:厂商名取自父目录名。 + if JdkVendor::parse(&name).is_some() { + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + for child in children.flatten() { + if let Some(binary) = stamped_binary(&child.path()) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + } + } + } + } + found +} + +/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。 +fn stamped_binary(dir: &std::path::Path) -> Option { + if !dir.is_dir() || read_stamp(dir).is_none() { + return None; + } + let binary = dir.join("bin").join(java_binary_name()); + binary.is_file().then_some(binary) +} + +/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。 +fn env_candidates() -> Vec { + let mut found = Vec::new(); + if let Some(home) = std::env::var_os("JAVA_HOME") { + let binary = PathBuf::from(home).join("bin").join(java_binary_name()); + if binary.is_file() { + found.push(Candidate { + binary, + source: Source::JavaHome, + }); + } + } + if let Some(binary) = probe::resolve_on_path(java_binary_name()) { + found.push(Candidate { + binary, + source: Source::Path, + }); + } + found +} + +/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。 +fn dedupe(candidates: Vec) -> Vec { + let mut seen: Vec = Vec::with_capacity(candidates.len()); + let mut kept = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let key = path_key(&candidate.binary); + if seen.contains(&key) { + continue; + } + seen.push(key); + kept.push(candidate); + } + kept +} + +/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。 +/// +/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`] +/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。 +/// +/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后 +/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。 +#[must_use] +pub fn scan( + candidates: &[Candidate], + mut on_progress: F, + cancelled: &dyn Fn() -> bool, +) -> Vec +where + F: FnMut(usize, usize) -> bool, +{ + let total = candidates.len(); + let mut found: Vec = Vec::with_capacity(total); + for (index, candidate) in candidates.iter().enumerate() { + if cancelled() || !on_progress(index, total) { + break; + } + // 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。 + // 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。 + let (runtime, _output) = super::manual::probe_with_timeout( + &candidate.binary, + candidate.source.origin(), + super::proc::PROBE_TIMEOUT, + cancelled, + ); + found.push(runtime); + } + found.sort_by(|left, right| { + left.major + .unwrap_or(u32::MAX) + .cmp(&right.major.unwrap_or(u32::MAX)) + .then_with(|| left.binary.cmp(&right.binary)) + }); + found +} + +/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。 +#[must_use] +pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let list = candidates(managed_roots, manual, system); + scan(&list, |_, _| true, &|| false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-staged-{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).expect("临时目录"); + dir + } + + /// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」, + /// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。 + fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("bin")).expect("bin"); + std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java"); + dir + } + + #[test] + fn the_list_comes_from_the_disk_not_from_a_constant() { + let root = temp("list"); + let one = fake_jdk(&root, "jdk-a"); + let two = fake_jdk(&root, "jdk-b"); + + let found = scan_roots(&[], &[one.clone(), two.clone()], false); + assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}"); + assert!(found + .iter() + .any(|runtime| runtime.binary == one.join("bin").join(java_binary_name()))); + + // 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。 + std::fs::remove_dir_all(&one).expect("删 jdk-a"); + let after = scan_roots(&[], &[one, two], false); + assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_managed_directory_without_a_stamp_is_not_claimed() { + let root = temp("stamp"); + // 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。 + fake_jdk(&root, "jdk-21-temurin"); + let found = scan_roots(std::slice::from_ref(&root), &[], false); + assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn progress_reports_every_candidate_and_cancel_stops_early() { + let root = temp("progress"); + let paths: Vec = (0..3) + .map(|n| fake_jdk(&root, &format!("jdk-{n}"))) + .collect(); + let list = candidates(&[], &paths, false); + assert_eq!(list.len(), 3); + + let mut seen = Vec::new(); + let all = scan( + &list, + |done, total| { + seen.push((done, total)); + true + }, + &|| false, + ); + assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度"); + assert_eq!(all.len(), 3); + + let stopped = scan(&list, |done, _| done < 1, &|| false); + assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn candidates_dedupe_the_same_jdk_written_two_ways() { + let root = temp("dedupe"); + let jdk = fake_jdk(&root, "jdk-x"); + let binary = jdk.join("bin").join(java_binary_name()); + // 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。 + let list = candidates(&[], &[jdk, binary], false); + assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}"); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 9f35a38..ada28a0 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -33,12 +33,12 @@ pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"]; /// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。 pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; -/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。 -/// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层), -/// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。 +/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。与 `game::java::vendor::VENDOR_IDS` +/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。 pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; -/// 自更新两键(M161;拆到自己的文件守 400 行)。 +/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)。主表各留一行登记。 +pub mod font; pub mod update; /// launcher.json 当前 schema 版本。 @@ -54,17 +54,17 @@ pub mod update; /// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。 /// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移 /// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。 +/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → /// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。 -/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、 -/// `save` 跳过),不是「只是少一档」。这是 minor 提升的既定代价——也正是这条键必须推 minor -/// 而不是偷偷塞进 1.4.0 的原因:回退到旧版本会出现「设置改不动」,而原因只在 -/// `$schema` 那一行里,不看版本号无从排查。 +/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过), +/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的 +/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 6, 0) + SchemaVersion::new(1, 7, 0) } /// `config/launcher.json` 的 schema。 @@ -396,4 +396,5 @@ pub fn launcher_schema() -> SettingSchema { "启动器日志级别", )) .with_all(update::specs()) + .with(font::cdn_spec()) } diff --git a/crates/core/src/setting/keys/font.rs b/crates/core/src/setting/keys/font.rs new file mode 100644 index 0000000..6f3897c --- /dev/null +++ b/crates/core/src/setting/keys/font.rs @@ -0,0 +1,59 @@ +//! 中文字体下载地址的覆盖键(M168:D28 后半「下载开源中文字体」)。 +//! +//! 从 `keys.rs` 拆出(那边贴着 400 行硬线):主表只留一行 `.with(font::cdn_spec())`。 +//! +//! ## 为什么需要这个键 +//! +//! 内置 pin(`unlucky_i18n::font::NOTO_SANS_SC`)把 URL 钉到了 jsDelivr 的一个 git commit 上 +//! ——那是**一个**出口。用户所在网络如果连不上它(或想走自建镜像 / 代理),要有办法换,否则 +//! 「自动下载中文字体」在某些网络上等于不存在。 +//! +//! ## 为什么只换 URL,不换哈希 +//! +//! 字体是**注册进渲染器的可执行内容**,不是一张图。这个键只允许改「从哪拿」,`sha256` 与字节数 +//! 仍取内置那一份:镜像给了别的内容 ⇒ 校验当场拒 ⇒ 降级回系统字体,并在设置页那一行说「镜像 +//! 内容校验不通过」。**宁可不下载,也不把没校验过的东西注册成字体。** +//! +//! 想让用户能换「整套字体」(换族、换子集)就得连哈希一起进配置——那是另一件事:现在的 pin 是 +//! 代码里的常量,改它要发版。理由是此刻只有一套官方子集,多条可写路径只会多一个「校验值和字体 +//! 对不上」的支持面。 + +use crate::setting::spec::{AiPolicy, SettingSpec}; + +/// `ui.font_cdn` 的登记项(走 [`SettingSpec::text`]:空串 = 用内置镜像)。 +#[must_use] +pub fn cdn_spec() -> SettingSpec { + SettingSpec::text( + "ui.font_cdn", + "", + 512, + AiPolicy::audited(), + "中文字体下载地址(空 = 内置国内可达镜像;填整条 https URL,内容必须与内置 sha256 一致)", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 默认值是空串(= 用内置镜像),且不是 `Path` 类型——空串对 `Path` 会被 schema 拒。 + /// + /// 抓得住:有人把键改成 `SettingSpec::path(...)`(空串过不了校验 ⇒ 出厂配置自己就非法)。 + #[test] + fn the_cdn_key_defaults_to_the_builtin_mirror() { + let spec = cdn_spec(); + assert_eq!(spec.key, "ui.font_cdn"); + assert_eq!(spec.default, json!("")); + assert!( + spec.kind.check(&json!("")).is_ok(), + "空串(= 内置镜像)必须过校验" + ); + assert!( + spec.kind + .check(&json!("https://mirror.example/NotoSansSC.ttf")) + .is_ok(), + "一条 https URL 必须过校验" + ); + } +} diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs index 6fba1e5..de76825 100644 --- a/crates/core/tests/auth/main.rs +++ b/crates/core/tests/auth/main.rs @@ -7,6 +7,7 @@ //! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断); //! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); //! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; +//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口); //! - `authlib_api`:Yggdrasil 四动作的报文级对接。 mod support; @@ -16,6 +17,7 @@ mod authlib_api; mod microsoft_authcode; mod microsoft_diagnostics; mod microsoft_flow; +mod microsoft_msalogin; mod microsoft_refresh; mod offline_uuid; mod store_separation; diff --git a/crates/core/tests/auth/microsoft_msalogin.rs b/crates/core/tests/auth/microsoft_msalogin.rs new file mode 100644 index 0000000..141da54 --- /dev/null +++ b/crates/core/tests/auth/microsoft_msalogin.rs @@ -0,0 +1,274 @@ +//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。 +//! +//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回 +//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页, +//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。 +//! +//! 本文件钉四件事(都是能给答案「能不能变红」的): +//! +//! | 守卫 | 用例 | 抓到什么 | +//! |---|---|---| +//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) | +//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) | +//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) | +//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 | +//! +//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。 + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use unlucky_core::auth::microsoft::{ + AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints, +}; +use unlucky_core::auth::AuthError; + +use crate::support::{full_routes, AuthMock, MockReply}; + +const CLIENT_ID: &str = "test-client-id"; +/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。 +const INVALID_REDIRECT_PAGE: &str = + "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."; +/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。 +const SIGN_IN_PAGE: &str = "Sign in to your account\ +
"; + +/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。 +type Seen = Arc)>>>; + +fn policy(state: &str) -> AuthCodePolicy { + AuthCodePolicy { + callback_timeout: Duration::from_millis(2_000), + bind_attempts: 1, + port: None, + state: Some(state.to_string()), + } +} + +/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。 +fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec)> { + let mut routes = full_routes(); + routes.push(("/authorize", vec![reply])); + routes +} + +fn auth(base: &str, browser_first: bool) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(crate::support::microsoft::fast_poll()) + .with_authcode_policy(policy("state-abc")) + .with_browser_first(browser_first) +} + +/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。 +#[tokio::test] +async fn unregistered_redirect_falls_back_to_the_device_code() { + let mock = AuthMock::start(routes_with_authorize(MockReply::text( + 400, + INVALID_REDIRECT_PAGE, + ))) + .await; + + let seen: Seen = Arc::new(Mutex::new(None)); + let seen_for_device = Arc::clone(&seen); + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"), + move |prompt, fallback| { + *seen_for_device.lock().unwrap() = + Some((prompt.user_code.clone(), fallback.map(|note| note.reason))); + }, + ) + .await + .expect("必须回退到设备码并成功"); + + // ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文) + let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示"); + assert_eq!(user_code, "ABCD-1234"); + assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered)); + assert_eq!( + reason.map(BrowserFallbackReason::code), + Some("redirect_not_registered") + ); + assert_eq!(outcome.session.profile_name, "Steve"); + let note = outcome.browser.expect("必须带回退说明"); + assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered); + // 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。 + assert!(!note.detail.is_empty()); + + // ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」) + assert_eq!( + outcome.listeners_opened, 0, + "没登记回环时不许绑本地端口(预检必须在绑定之前)" + ); + // ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。 + // 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。 + assert_eq!( + mock.bodies_on("/authorize").len(), + 1, + "浏览器优先路必须真的发一次回环注册预检" + ); + mock.stop(); +} + +/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。 +#[tokio::test] +async fn device_code_default_never_opens_a_listening_port() { + let mock = AuthMock::start(full_routes()).await; + let device_seen = Arc::new(Mutex::new(None)); + let device_flag = Arc::clone(&device_seen); + + let outcome = auth(&mock.base(), false) + .browser_then_device_login( + |_| panic!("默认路不许碰浏览器回调"), + move |prompt, fallback| { + *device_flag.lock().unwrap() = Some(prompt.user_code.clone()); + assert!(fallback.is_none(), "没走浏览器就不该有回退说明"); + }, + ) + .await + .expect("设备码登录成功"); + + assert_eq!( + device_seen.lock().unwrap().clone().as_deref(), + Some("ABCD-1234") + ); + assert!(outcome.browser.is_none()); + assert_eq!( + outcome.session.profile_id, + crate::support::microsoft::PROFILE_ID + ); + assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口"); + // 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳) + assert!(mock.bodies_on("/authorize").is_empty()); + mock.stop(); +} + +/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。 +#[tokio::test] +async fn registered_loopback_still_runs_the_auth_code_flow() { + let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await; + + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("{redirect}?code=the-auth-code&state=state-abc"); + let response = reqwest::get(&url).await.expect("浏览器回调"); + assert_eq!(response.status(), 200); + }); + }, + |_prompt, _note| panic!("登记了回环就不该回退设备码"), + ) + .await + .expect("授权码流应当走通"); + + assert_eq!(outcome.session.profile_name, "Steve"); + assert!(outcome.browser.is_none(), "走通了就不该有回退说明"); + assert_eq!( + outcome.listeners_opened, 1, + "登记了回环就应当真的绑端口(否则加速路名存实亡)" + ); + // 换 token 用的是**授权码** grant(不是设备码那条)。 + let token_bodies = mock.bodies_on("/token"); + assert_eq!(token_bodies.len(), 1, "{token_bodies:?}"); + assert!(token_bodies[0].contains("grant_type=authorization_code")); + mock.stop(); +} + +/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。 +#[tokio::test] +async fn user_cancel_on_the_browser_page_does_not_fall_back() { + let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await; + let error = auth(&mock.base(), true) + .browser_then_device_login( + |prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("{redirect}?error=access_denied&state=state-abc"); + let _ = reqwest::get(&url).await; + }); + }, + |_prompt, _note| panic!("用户取消后不许自动换流"), + ) + .await + .expect_err("取消应当是最终结果"); + assert_eq!(error, AuthError::Canceled); + assert!( + mock.bodies_on("/devicecode").is_empty(), + "取消之后不该再去要设备码" + ); + mock.stop(); +} + +/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。 +#[tokio::test] +async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() { + // 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。 + let mock = AuthMock::start(full_routes()).await; + let mut endpoints = MicrosoftEndpoints::all_on(&mock.base()); + endpoints.authorize = "http://127.0.0.1:1/authorize".to_string(); + let reason = Arc::new(Mutex::new(None)); + let reason_flag = Arc::clone(&reason); + let outcome = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(endpoints) + .with_poll_policy(crate::support::microsoft::fast_poll()) + .with_authcode_policy(policy("state-abc")) + .with_browser_first(true) + .browser_then_device_login( + |_prompt| panic!("预检失败时不许弹浏览器"), + move |_prompt, fallback| { + *reason_flag.lock().unwrap() = fallback.map(|note| note.reason); + }, + ) + .await + .expect("预检失败不该拦住设备码"); + assert_eq!( + *reason.lock().unwrap(), + Some(BrowserFallbackReason::PreflightUnavailable) + ); + assert_eq!( + outcome.browser.map(|note| note.reason), + Some(BrowserFallbackReason::PreflightUnavailable) + ); + assert_eq!(outcome.session.profile_name, "Steve"); + assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口"); + mock.stop(); +} + +/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。 +#[tokio::test] +async fn aadsts50011_page_is_not_treated_as_registered() { + let mock = AuthMock::start(routes_with_authorize(MockReply::text( + 200, + "AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured", + ))) + .await; + let note = Arc::new(Mutex::new(None)); + let note_flag = Arc::clone(¬e); + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |_| panic!("AADSTS50011 的页面不许当已登记"), + move |_prompt, fallback| { + *note_flag.lock().unwrap() = fallback.map(|note| note.reason); + }, + ) + .await + .expect("回退设备码后应当成功"); + assert_eq!( + *note.lock().unwrap(), + Some(BrowserFallbackReason::RedirectNotRegistered) + ); + assert_eq!( + outcome.browser.map(|note| note.reason), + Some(BrowserFallbackReason::RedirectNotRegistered) + ); + mock.stop(); +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 993fbee..3eb5910 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() { fn schema_registration_shape_is_right() { assert_eq!( launcher_version().minor, - 6, - "1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)" + 7, + "1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)" ); let schema = launcher_schema(); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 7f479de..60d98a9 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,10 +128,9 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.6.0 登记 M161 的自更新两键(`update.auto_check` / `update.channel`; - // 1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, - // 1.2.0 = D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0)); + // 1.7.0 登记 M168 的 `ui.font_cdn`(1.6.0 登记 M161 的自更新两键,1.5.0 = M116 窗口圆角, + // 1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,1.2.0 = D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index 3088eba..0168f55 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,8 +66,10 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - // 1.6.0 = M161 的自更新两键(此前 1.5.0 是 M116 窗口圆角)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0)); + // 1.7.0 = M168 的中文字体下载地址覆盖键(`ui.font_cdn`;此前 1.6.0 是 M161 的自更新两键, + // 1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,不是巧合: + // 老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); assert_eq!( launcher_version().major, 1, diff --git a/crates/core/tests/setting/update_keys.rs b/crates/core/tests/setting/update_keys.rs index 646d89b..02f98c8 100644 --- a/crates/core/tests/setting/update_keys.rs +++ b/crates/core/tests/setting/update_keys.rs @@ -6,8 +6,9 @@ use serde_json::json; use unlucky_core::setting::document::{RenderMode, SettingDocument}; -use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::keys::update::UPDATE_CHANNELS; +use unlucky_core::setting::keys::{launcher_schema, launcher_version}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion}; use unlucky_core::setting::spec::{Sensitivity, ValueKind}; use unlucky_core::setting::store::SettingStore; use unlucky_core::setting::update_prefs::{ @@ -286,16 +287,32 @@ fn the_receipt_gate_skips_a_replayed_source_byte_for_byte() { /// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。 /// -/// 造法:主配置的 `$schema` 写成**更高的 minor**(1.7.0 > 1.6.0)⇒ `SchemaCompat` 判 -/// `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。 +/// 造法:主配置的 `$schema` 写成**比当前更高的 minor**(`launcher_version().minor + 1`)⇒ +/// `SchemaCompat` 判 `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。 +/// +/// **这里刻意不写死数字**(M168 复审 p1-1):上一版把「更高」写成了字面量 `1.7.0`,于是 +/// `launcher_version()` 一推到 1.7.0 这个夹具就变成「同版本」——文件可写 ⇒ 迁移真的执行 ⇒ +/// 旧文件被退役,`Refused` 分支再也走不到,而**断言还在,绿着**:一条守护用户数据的用例 +/// 就这样静默失效了。相对推导(current + 1)不会过期,版本往后推多少次它都还是「更高一档」。 /// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。 #[test] fn a_read_only_launcher_config_never_retires_the_legacy_file() { + // 比当前更高一档的 minor:`SchemaRef::url()` 是 `$schema` 的唯一产地,不手拼字符串。 + let newer = SchemaRef { + id: SchemaId::LAUNCHER, + version: SchemaVersion::new( + launcher_version().major, + launcher_version().minor + 1, + launcher_version().patch, + ), + }; + let newer_schema = newer.url(); + let temp = TempDir::new("m161-refused"); temp.write( "config/launcher.json", &json!({ - "$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.7.0", + "$schema": newer_schema, "schemaVersion": 1 }) .to_string(), @@ -323,9 +340,9 @@ fn a_read_only_launcher_config_never_retires_the_legacy_file() { ); // 不写收据(没迁成就不该有收据)。 assert!(!temp.exists(&format!("config/{RECEIPT_FILE}"))); - // 主配置一个字都没改(还是那份 1.7.0)。 + // 主配置一个字都没改(还是那份「更高 minor」的 `$schema`)。 let text = temp.read("config/launcher.json"); - assert!(text.contains("1.7.0"), "{text}"); + assert!(text.contains(&newer_schema), "主配置被改了:{text}"); assert!(!text.contains("auto_check"), "只读文件不许被写:{text}"); } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index d6a5f44..ad363e6 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -118,6 +118,7 @@ mod content; mod core_text; mod download; mod font; +mod font_install; mod general; mod instances; mod launch; @@ -186,6 +187,7 @@ use content::content_entries; use core_text::core_text_entries; use download::{download_entries, version_entries}; use font::font_entries; +use font_install::font_install_entries; use general::general_entries; use instances::instances_entries; use launch::launch_entries; @@ -228,6 +230,9 @@ nav_entries! { account_entries, // M159:自更新(设置页「更新」分组 + 顶部提示条)。同样只追加在链尾之后。 update_entries, + // M168:中文字体下载状态行(设置页 General 组)。同样只追加在链尾之后—— + // `catalog/font.rs` 那四条老文案在链中间,往那里追加会平移其后所有 key(金表整片红)。 + font_install_entries, finish, {} } diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index c498e62..56bcdd1 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -57,7 +57,9 @@ macro_rules! account_entries { AccountDeviceRetry => ("Try again", "重试"), AccountDeviceCancel => ("Cancel", "取消"), AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"), - AccountDeviceUseCode => ("Use a device code instead", "改用设备码"), + // M178 复审 r1 P2-5:这是**主按钮**(设备码流),不能读起来像备选。 + AccountDeviceSignIn => ("Sign in with your Microsoft account", "使用 Microsoft 账户登录"), + AccountDeviceFallbackHint => ("No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"), // ── 第三方站表单 AccountServerLabel => ("Server address", "服务器地址"), AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), @@ -115,6 +117,23 @@ macro_rules! account_entries { // ── 重新登录的引导 AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"), AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + // ── 浏览器登录回退到设备码的说明(M178) + // 口径:这不是「登录失败」——设备码接着跑。第一句说清「已改用设备码」, + // 需要改配置的那一条**必须给可照抄的下一步**(哪个门户、加什么值、端口不参与匹配), + // 且**不许**出现 AAD 的原始报文(`invalid_request` / `AADSTS…` / JSON)—— + // 用户 2026-11-08 实测撞到的就是那张原始错误页。 + AccountBrowserFallbackNotRegistered => ( + "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", + "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。" + ), + AccountBrowserFallbackUnverified => ( + "Could not confirm that browser sign-in is usable, so it switched to a device code.", + "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。" + ), + AccountBrowserFallbackFailed => ( + "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", + "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。" + ), } } }; } @@ -125,6 +144,48 @@ pub(crate) use account_entries; mod tests { use crate::{Catalog, Key, Lang}; + /// **M178**:浏览器登录回退到设备码时那一句,必须「说清为什么 + 怎么修」,且**不许**把 + /// AAD 的原始报文搬上屏(用户实测撞到的正是那张 `invalid_request ... redirect_uri` 错误页)。 + /// + /// 抓得住:文案退回「登录失败,完整原因在日志里」、删掉可照抄的重定向 URI、或有人把 + /// AAD 原始错误码/JSON 塞进词条。 + /// 抓不住:读起来顺不顺(那靠 review 与截图)。 + #[test] + fn the_browser_fallback_copy_explains_itself_without_raw_aad_text() { + for &lang in Lang::ALL { + let catalog = Catalog::of(lang); + let not_registered = catalog.text(Key::AccountBrowserFallbackNotRegistered); + let unverified = catalog.text(Key::AccountBrowserFallbackUnverified); + let failed = catalog.text(Key::AccountBrowserFallbackFailed); + // 「已改用设备码」这条结论三句里都得有(用户要知道画面为什么变了)。 + for copy in [not_registered, unverified, failed] { + assert!( + copy.contains("device code") || copy.contains("设备码"), + "{}: {copy}", + lang.tag() + ); + // 原文不上屏:这两条是 AAD 的机器码,出现即语言/脱敏纪律失效。 + assert!(!copy.contains("invalid_request"), "{}: {copy}", lang.tag()); + assert!(!copy.contains("AADSTS"), "{}: {copy}", lang.tag()); + assert!(!copy.contains('{'), "{}: {copy}", lang.tag()); + } + // 「没登记回环」这一条必须给**可照抄**的下一步(门户 + 值 + 端口不参与匹配)。 + assert!( + not_registered.contains("http://localhost/auth"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("redirect uri") + || not_registered.contains("重定向 URI"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("port") || not_registered.contains("端口"), + "{not_registered}" + ); + } + } + /// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。 /// /// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却 diff --git a/crates/i18n/src/catalog/font_install.rs b/crates/i18n/src/catalog/font_install.rs new file mode 100644 index 0000000..e4056e1 --- /dev/null +++ b/crates/i18n/src/catalog/font_install.rs @@ -0,0 +1,72 @@ +//! 中文字体**下载状态行**的文案(M168):设置页 General 组那一行的四态文字 + 三类失败原因。 +//! +//! ## 为什么单开一个域文件(而不是塞进 `font.rs`) +//! +//! `catalog/font.rs` 那四条(`FontCjkNeedsDownload` / `FontCjkDownloading` / `FontCjkDownloadFailed` +//! / `FontCjkReady`)是 M92「只发信号」时代的文案,位置在装配链**中间**。往那个域后面追加会把 +//! 其后所有 key 在 `Key::ALL` 里整体平移一位 ⇒ 金表(逐 index 逐字节)整片红。 +//! +//! 本域挂在**链尾**(`update_entries` 之后),于是 `Key::ALL` 的既有顺序一个不动,金表只需在 +//! 末尾追加(M104 / M106 / M107 / M109 / M114 / M159 都是这条做法)。 +//! +//! ## 四条状态各一句话(tower 定死的口径) +//! +//! | 状态 | 文案说什么 | +//! |---|---| +//! | 下载中 | 「正在获取中文字体…(16.9 MB)」——**没有**假进度条,有真实量(字节数)才写 | +//! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) | +//! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 | +//! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) | +//! +//! 「探测不出」也要有话说:那是「不知道」,既不是「没有」也不是「不需要」——写清楚「未下载」, +//! 免得用户以为我们偷偷下过一份。 +//! +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。 + +macro_rules! font_install_entries { + ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { + $next! { $($rest,)* { + $($acc)* + // ── 中文字体下载状态行(M168:设置页 General 组) + FontCjkStatusLabel => ("Chinese font", "中文字体"), + FontCjkStatusNotNeeded => ( + "Not needed for this interface language", + "当前界面语言不需要中文字体" + ), + FontCjkStatusSystem => ("Ready (system font: {family})", "已就绪(系统字体:{family})"), + FontCjkStatusDownloading => ( + "Fetching a Chinese font… ({size})", + "正在获取中文字体…({size})" + ), + FontCjkStatusFailed => ( + "Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.", + "获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。" + ), + // 超时也归这一档(M168 复审 P2-2):用户要分清「太慢/没网」与「内容不对」, + // 但不需要知道我们内部把它叫 Timeout 还是 Network。 + FontCjkReasonNetwork => ( + "network unreachable or too slow (timed out)", + "网络不通或太慢(超时)" + ), + FontCjkReasonIntegrity => ( + "the mirror sent content that failed verification", + "镜像内容校验不通过" + ), + FontCjkReasonDisk => ("cannot write to the data directory", "写不进数据目录"), + FontCjkStatusUnknown => ( + "Not determined yet; nothing was downloaded", + "尚未判定;没有下载任何字体" + ), + FontCjkStatusUnprobeable => ( + "System fonts cannot be probed, so nothing was downloaded", + "无法探测系统字体,因此没有下载" + ), + FontCjkStatusHint => ( + "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", + "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。" + ), + } } + }; +} + +pub(crate) use font_install_entries; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 74a851d..8972de0 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -29,6 +29,118 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + // M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。 + // 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、 + // `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在 + // `SettingsAppearanceCornerSuspended` 与 `SettingsGroupAssistant` 之间(顺序 = `Key::ALL`)。 + ("SettingsJavaScan", "Scan now", "立即扫描"), + ("SettingsJavaScanningStart", "Scanning…", "正在扫描…"), + ("SettingsJavaScanning", "Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + ("SettingsJavaCancelling", "Cancelling…", "正在取消…"), + ("SettingsJavaCancel", "Cancel", "取消"), + ("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"), + ("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"), + ("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"), + ("SettingsJavaNeverScannedBody", "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"), + ("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"), + ("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"), + ("SettingsJavaDefault", "Default", "默认"), + ("SettingsJavaSetDefault", "Use as default", "设为默认"), + ("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"), + ("SettingsJavaTooOld", "Below the requirement: this instance needs Java {minimum}+", "低于要求:当前实例需要 Java {minimum} 及以上"), + ("SettingsJavaDefaultSet", "Default JDK is now {path}", "默认 JDK 已设为 {path}"), + ("SettingsJavaSourceManaged", "installed by this launcher", "本启动器安装"), + ("SettingsJavaSourceEnv", "JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + ("SettingsJavaSourcePath", "on PATH", "PATH 上"), + ("SettingsJavaSourceSystem", "system install", "系统安装"), + ("SettingsJavaSourceManual", "added by you", "手动添加"), + ("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"), + ("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"), + ("SettingsJavaAddHint", "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"), + ("SettingsJavaAddAction", "Add", "添加"), + ("SettingsJavaAdded", "Added {path}", "已添加 {path}"), + ("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"), + ("SettingsJavaMemoryHint", "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"), + ("SettingsJavaLayerGlobal", "In effect: {mb} MB, from the global default.", "生效:{mb} MB,来自全局默认值。"), + ("SettingsJavaLayerInstance", "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"), + ("SettingsJavaJvm", "Extra JVM arguments", "额外 JVM 参数"), + ("SettingsJavaJvmPlaceholder", "-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + ("SettingsJavaJvmHint", "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"), + ("SettingsJavaJvmSaved", "Saved value: {value}", "已保存的值:{value}"), + ("SettingsJavaJvmSavedNone", "No extra JVM arguments are saved yet.", "还没有保存任何额外参数。"), + ("SettingsJavaSave", "Save", "保存"), + ("SettingsJavaSaved", "Saved and read back from disk. The next launch uses the new value.", "已保存,并从磁盘读回确认。下次启动就用新值。"), + ("SettingsJavaUnsaved", "There are unsaved changes.", "有未保存的改动。"), + ("SettingsJavaRefused", "Not saved: {reason}", "未保存:{reason}"), + ("SettingsJavaSkipNotDirty", "there was nothing to change, so no file was written", "没有任何改动,因此没有写文件"), + ("SettingsJavaSkipReadOnly", "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", "配置文件只读:它被更新版本的启动器写过,或被标记为不可信"), + ("SettingsJavaSkipUnreadable", "the settings file is untrusted, so it is treated as read-only", "配置文件不可信,因此按只读处理"), + ("SettingsJavaNoStorage", "No settings file is available in this run, so nothing can be scanned or saved.", "这一次运行没有可用的设置文件,扫描与保存都做不了。"), + ("SettingsJavaReasonMemoryEmpty", "the memory field is empty", "内存没填"), + ("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"), + ("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"), + ("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"), + ("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"), + ("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"), + ("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"), + ("SettingsJavaReasonAddNoVersion", "java -version did not answer there, so it is not a usable JDK", "在那里跑不出 java -version,不是一份能用的 JDK"), + ("SettingsJavaReasonAddTooLong", "the path is too long to be a JDK location", "路径太长,不像是一个 JDK 位置"), + ("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"), + ("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"), + ("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"), + // M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。 + // 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、 + // `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与 + // `CoreTextUntranslated` 之间(顺序 = `Key::ALL`)。 + ("SettingsGroupAssistant", "Assistant", "助手"), + ("SettingsAiStatusLabel", "Status: {status}", "状态:{status}"), + ("SettingsAiStatusUnconfigured", "not configured", "未配置"), + ("SettingsAiStatusConfigured", "configured", "已配置"), + ("SettingsAiStatusVerified", "verified", "连接已通过"), + ("SettingsAiStatusTestFailed", "test failed", "测试失败"), + ("SettingsAiEnable", "Enable the assistant", "启用助手"), + ("SettingsAiEnableHint", "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"), + ("SettingsAiProviderLabel", "Provider", "提供商"), + ("SettingsAiProviderOpenAi", "OpenAI compatible", "OpenAI 兼容"), + ("SettingsAiProviderAnthropic", "Anthropic", "Anthropic 原生"), + ("SettingsAiProviderOllama", "Ollama", "Ollama 原生"), + ("SettingsAiProviderDisabled", "Disabled", "已禁用"), + ("SettingsAiBaseUrl", "Endpoint (base_url)", "端点(base_url)"), + ("SettingsAiBaseUrlPlaceholder", "http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"), + ("SettingsAiBaseUrlHint", "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"), + ("SettingsAiEffective", "Effective: {value}", "生效:{value}"), + ("SettingsAiModel", "Model name", "模型名称"), + ("SettingsAiModelPlaceholder", "deepseek-chat", "例:deepseek-chat"), + ("SettingsAiApiKey", "API key", "API 密钥"), + ("SettingsAiApiKeyPlaceholder", "Paste the key", "粘贴密钥"), + ("SettingsAiApiKeyConfigured", "A key is stored ({chars} characters). Leave this empty to keep it.", "已保存密钥({chars} 个字符)。留空则保持不变。"), + ("SettingsAiApiKeyMissing", "No key stored yet", "还没有保存密钥"), + ("SettingsAiShow", "Show", "显示"), + ("SettingsAiHide", "Hide", "隐藏"), + ("SettingsAiSave", "Save", "保存"), + ("SettingsAiTest", "Test connection", "测试连接"), + ("SettingsAiTesting", "Testing…", "正在测试…"), + ("SettingsAiSaved", "Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"), + ("SettingsAiRefused", "Not saved: {reason}", "未保存:{reason}"), + ("SettingsAiUnsaved", "There are unsaved changes.", "有未保存的改动。"), + ("SettingsAiNoStorage", "No settings file is available in this run, so nothing can be saved or tested.", "这一次运行没有可用的设置文件,保存与测试都做不了。"), + ("SettingsAiTestDisabled", "Fill in the missing field before testing: {missing}.", "先补上这一项再测试:{missing}。"), + ("SettingsAiMissingDisabled", "the assistant is disabled", "助手处于关闭状态"), + ("SettingsAiMissingBaseUrl", "the endpoint address", "端点地址"), + ("SettingsAiMissingModel", "the model name", "模型名称"), + ("SettingsAiMissingApiKey", "the API key", "API 密钥"), + ("SettingsAiReasonProvider", "the provider value is not recognised", "提供商取值不认识"), + ("SettingsAiReasonBaseUrl", "the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"), + ("SettingsAiReasonModel", "the model name is required", "必须填模型名称"), + ("SettingsAiTestPassed", "Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"), + ("SettingsAiTestAuth", "Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"), + ("SettingsAiTestModel", "The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"), + ("SettingsAiTestUnreachable", "The endpoint is unreachable.", "端点不可达。"), + ("SettingsAiTestTimeout", "Timed out while waiting for the endpoint.", "等端点回应超时了。"), + ("SettingsAiTestOther", "The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"), + ("SettingsAiTestNotConfigured", "Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"), + ("SettingsAiTestTarget", "Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"), + ("SettingsAiTestDetail", "Detail: {detail}", "详情:{detail}"), ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), ("DownloadTitle", "Version management", "版本管理"), ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index d22447e..fab5ef2 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -135,7 +135,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("AccountDeviceRetry", "Try again", "重试"), ("AccountDeviceCancel", "Cancel", "取消"), ("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"), - ("AccountDeviceUseCode", "Use a device code instead", "改用设备码"), + ("AccountDeviceSignIn", "Sign in with your Microsoft account", "使用 Microsoft 账户登录"), + ("AccountDeviceFallbackHint", "No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"), ("AccountServerLabel", "Server address", "服务器地址"), ("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), ("AccountServerProbe", "Check server", "校验服务器"), @@ -180,6 +181,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("AccountNoticeTitleDone", "Account updated", "账户已更新"), ("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"), ("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + ("AccountBrowserFallbackNotRegistered", "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。"), + ("AccountBrowserFallbackUnverified", "Could not confirm that browser sign-in is usable, so it switched to a device code.", "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。"), + ("AccountBrowserFallbackFailed", "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。"), ("SettingsGroupUpdate", "Updates", "更新"), ("SettingsUpdateCurrent", "Current version", "当前版本"), ("SettingsUpdateChannel", "Channel", "更新渠道"), @@ -209,4 +213,16 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("UpdateBannerDismiss", "Skip this version", "忽略这个版本"), ("UpdateBannerRestartNow", "Restart now", "立即重启"), ("UpdateBannerRetry", "Try again", "重试"), + // M168:中文字体下载状态行(链尾追加,既有顺序不动) + ("FontCjkStatusLabel", "Chinese font", "中文字体"), + ("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"), + ("FontCjkStatusSystem", "Ready (system font: {family})", "已就绪(系统字体:{family})"), + ("FontCjkStatusDownloading", "Fetching a Chinese font… ({size})", "正在获取中文字体…({size})"), + ("FontCjkStatusFailed", "Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.", "获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"), + ("FontCjkReasonNetwork", "network unreachable or too slow (timed out)", "网络不通或太慢(超时)"), + ("FontCjkReasonIntegrity", "the mirror sent content that failed verification", "镜像内容校验不通过"), + ("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"), + ("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"), + ("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"), + ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), ]; diff --git a/crates/i18n/src/catalog/settings/ai.rs b/crates/i18n/src/catalog/settings/ai.rs index 17f2514..0525fb9 100644 --- a/crates/i18n/src/catalog/settings/ai.rs +++ b/crates/i18n/src/catalog/settings/ai.rs @@ -1,12 +1,95 @@ -//! 设置页「AI / About」分组文案:**预留**(页面上还没有这个分组,见 `pages/settings/mod.rs` 的 -//! 分组注册表)。 +//! 设置页「助手(AI)」分组文案(M174):状态矩阵四档 + 五个输入项 + 测试连接六档。 //! -//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在 +//! `settings_download_entries` 之后、`core_text_entries` 之前(设置页五域的位置), +//! 所以金表那几行也要插在 `SettingsAppearanceCornerSuspended` 之后——**顺序即 `Key::ALL`**。 +//! +//! 三条口径: +//! +//! 1. **失败四种各写各的句子**(鉴权 / 模型不存在 / 不可达 / 超时):合成一句「测试失败」等于 +//! 让用户自己猜是 key 错了还是地址错了; +//! 2. 密钥的**值**永远不进文案:只有 `{chars}`(已存字符数)与「未配置」两种说法; +//! 3. 技术名词不翻:`base_url` / `OpenAI` / `Anthropic` / `Ollama` 原样。 macro_rules! settings_ai_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 左列分组名(与 NavSettings 同源:左列是导航,标题在正文里) + SettingsGroupAssistant => ("Assistant", "助手"), + // ── 状态矩阵(未配置 / 已配置 / 已通过 / 测试失败) + SettingsAiStatusLabel => ("Status: {status}", "状态:{status}"), + SettingsAiStatusUnconfigured => ("not configured", "未配置"), + SettingsAiStatusConfigured => ("configured", "已配置"), + SettingsAiStatusVerified => ("verified", "连接已通过"), + SettingsAiStatusTestFailed => ("test failed", "测试失败"), + // ── 开关 + SettingsAiEnable => ("Enable the assistant", "启用助手"), + SettingsAiEnableHint => ( + "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", + "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。" + ), + // ── 提供商 + SettingsAiProviderLabel => ("Provider", "提供商"), + SettingsAiProviderOpenAi => ("OpenAI compatible", "OpenAI 兼容"), + SettingsAiProviderAnthropic => ("Anthropic", "Anthropic 原生"), + SettingsAiProviderOllama => ("Ollama", "Ollama 原生"), + SettingsAiProviderDisabled => ("Disabled", "已禁用"), + // ── 端点 / 模型 + SettingsAiBaseUrl => ("Endpoint (base_url)", "端点(base_url)"), + SettingsAiBaseUrlPlaceholder => ("http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"), + SettingsAiBaseUrlHint => ( + "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", + "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。" + ), + SettingsAiEffective => ("Effective: {value}", "生效:{value}"), + SettingsAiModel => ("Model name", "模型名称"), + SettingsAiModelPlaceholder => ("deepseek-chat", "例:deepseek-chat"), + // ── 密钥(只有字符数与掩码,没有值) + SettingsAiApiKey => ("API key", "API 密钥"), + SettingsAiApiKeyPlaceholder => ("Paste the key", "粘贴密钥"), + SettingsAiApiKeyConfigured => ( + "A key is stored ({chars} characters). Leave this empty to keep it.", + "已保存密钥({chars} 个字符)。留空则保持不变。" + ), + SettingsAiApiKeyMissing => ("No key stored yet", "还没有保存密钥"), + SettingsAiShow => ("Show", "显示"), + SettingsAiHide => ("Hide", "隐藏"), + // ── 动作与回执 + SettingsAiSave => ("Save", "保存"), + SettingsAiTest => ("Test connection", "测试连接"), + SettingsAiTesting => ("Testing…", "正在测试…"), + SettingsAiSaved => ("Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"), + SettingsAiRefused => ("Not saved: {reason}", "未保存:{reason}"), + SettingsAiUnsaved => ("There are unsaved changes.", "有未保存的改动。"), + SettingsAiNoStorage => ( + "No settings file is available in this run, so nothing can be saved or tested.", + "这一次运行没有可用的设置文件,保存与测试都做不了。" + ), + SettingsAiTestDisabled => ( + "Fill in the missing field before testing: {missing}.", + "先补上这一项再测试:{missing}。" + ), + // ── 缺项(四种各说各的) + SettingsAiMissingDisabled => ("the assistant is disabled", "助手处于关闭状态"), + SettingsAiMissingBaseUrl => ("the endpoint address", "端点地址"), + SettingsAiMissingModel => ("the model name", "模型名称"), + SettingsAiMissingApiKey => ("the API key", "API 密钥"), + // ── 拒绝保存的原因(本地校验) + SettingsAiReasonProvider => ("the provider value is not recognised", "提供商取值不认识"), + SettingsAiReasonBaseUrl => ("the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"), + SettingsAiReasonModel => ("the model name is required", "必须填模型名称"), + // ── 测试结论(六档) + SettingsAiTestPassed => ("Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"), + SettingsAiTestAuth => ("Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"), + SettingsAiTestModel => ("The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"), + SettingsAiTestUnreachable => ("The endpoint is unreachable.", "端点不可达。"), + SettingsAiTestTimeout => ("Timed out while waiting for the endpoint.", "等端点回应超时了。"), + SettingsAiTestOther => ("The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"), + SettingsAiTestNotConfigured => ("Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"), + SettingsAiTestTarget => ("Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"), + SettingsAiTestDetail => ("Detail: {detail}", "详情:{detail}"), + } } }; } diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs index db75b75..40e0c53 100644 --- a/crates/i18n/src/catalog/settings/java.rs +++ b/crates/i18n/src/catalog/settings/java.rs @@ -1,11 +1,154 @@ -//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。 +//! 设置页「Java」分组文案(M175):扫描 / 运行时列表 / 默认 JDK / 内存 / JVM 参数。 //! -//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在 +//! `settings_appearance_entries` 之后、`settings_download_entries` 之前,所以金表那批行要插在 +//! `SettingsAppearanceCornerSuspended` 之后、`SettingsGroupAssistant`(M174 的助手域)之前 +//! ——**顺序即 `Key::ALL`**。 +//! +//! 三条口径: +//! +//! 1. 技术名词不翻:`JDK` / `JVM` / `-version` / `bin/java` / `java.selected_path` 原样; +//! 2. **不许出现写死的版本号或数字**:本组以前那三行(`Temurin 21.0.4` / `4 GiB`)正是被用户 +//! 当成假数据的那三条,现在所有数值都由页面从真读盘的结果里格式化进来; +//! 3. 拒绝原因一句一条(空 / 不存在 / 不是 java / 跑不出 -version / 重复),合成一句 +//! 「路径无效」等于让用户自己猜。 macro_rules! settings_java_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 扫描(状态 / 进度 / 取消) + SettingsJavaScan => ("Scan now", "立即扫描"), + SettingsJavaScanningStart => ("Scanning…", "正在扫描…"), + SettingsJavaScanning => ("Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + SettingsJavaCancelling => ("Cancelling…", "正在取消…"), + SettingsJavaCancel => ("Cancel", "取消"), + SettingsJavaCancelled => ( + "The scan was cancelled; the list below is from the previous run.", + "扫描已取消;下面的列表还是上一次的结果。" + ), + SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"), + SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"), + SettingsJavaNeverScannedBody => ( + "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", + "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。" + ), + SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"), + SettingsJavaEmptyBody => ( + "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", + "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。" + ), + // ── 运行时列表 + SettingsJavaDefault => ("Default", "默认"), + SettingsJavaSetDefault => ("Use as default", "设为默认"), + SettingsJavaFits => ("Meets the current instance requirement", "满足当前实例的要求"), + SettingsJavaTooOld => ( + "Below the requirement: this instance needs Java {minimum}+", + "低于要求:当前实例需要 Java {minimum} 及以上" + ), + SettingsJavaDefaultSet => ("Default JDK is now {path}", "默认 JDK 已设为 {path}"), + SettingsJavaSourceManaged => ("installed by this launcher", "本启动器安装"), + SettingsJavaSourceEnv => ("JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + SettingsJavaSourcePath => ("on PATH", "PATH 上"), + SettingsJavaSourceSystem => ("system install", "系统安装"), + SettingsJavaSourceManual => ("added by you", "手动添加"), + // ── 手动添加 + SettingsJavaAdd => ("Add a JDK path", "手动添加 JDK 路径"), + SettingsJavaAddPlaceholder => ( + "C:\\Program Files\\Java\\jdk-21", + "例:C:\\Program Files\\Java\\jdk-21" + ), + SettingsJavaAddHint => ( + "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", + "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。" + ), + SettingsJavaAddAction => ("Add", "添加"), + SettingsJavaAdded => ("Added {path}", "已添加 {path}"), + // ── 内存 + SettingsJavaMemory => ("Default memory (MB)", "默认内存(MB)"), + SettingsJavaMemoryHint => ( + "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", + "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。" + ), + SettingsJavaLayerGlobal => ( + "In effect: {mb} MB, from the global default.", + "生效:{mb} MB,来自全局默认值。" + ), + SettingsJavaLayerInstance => ( + "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", + "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。" + ), + // ── JVM 参数 + SettingsJavaJvm => ("Extra JVM arguments", "额外 JVM 参数"), + SettingsJavaJvmPlaceholder => ("-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + SettingsJavaJvmHint => ( + "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", + "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。" + ), + SettingsJavaJvmSaved => ("Saved value: {value}", "已保存的值:{value}"), + SettingsJavaJvmSavedNone => ( + "No extra JVM arguments are saved yet.", + "还没有保存任何额外参数。" + ), + // ── 保存与回执 + SettingsJavaSave => ("Save", "保存"), + SettingsJavaSaved => ( + "Saved and read back from disk. The next launch uses the new value.", + "已保存,并从磁盘读回确认。下次启动就用新值。" + ), + SettingsJavaUnsaved => ("There are unsaved changes.", "有未保存的改动。"), + SettingsJavaRefused => ("Not saved: {reason}", "未保存:{reason}"), + SettingsJavaSkipNotDirty => ( + "there was nothing to change, so no file was written", + "没有任何改动,因此没有写文件" + ), + SettingsJavaSkipReadOnly => ( + "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", + "配置文件只读:它被更新版本的启动器写过,或被标记为不可信" + ), + SettingsJavaSkipUnreadable => ( + "the settings file is untrusted, so it is treated as read-only", + "配置文件不可信,因此按只读处理" + ), + SettingsJavaNoStorage => ( + "No settings file is available in this run, so nothing can be scanned or saved.", + "这一次运行没有可用的设置文件,扫描与保存都做不了。" + ), + // ── 拒绝原因(一条一句) + SettingsJavaReasonMemoryEmpty => ("the memory field is empty", "内存没填"), + SettingsJavaReasonMemoryNotNumber => ("the memory must be a whole number of MB", "内存必须是整数 MB"), + SettingsJavaReasonMemoryRange => ( + "the memory is outside the allowed range", + "内存超出允许范围" + ), + SettingsJavaReasonJvmQuotes => ( + "the JVM arguments have an unclosed quote", + "JVM 参数里有没配对的引号" + ), + SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"), + SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"), + SettingsJavaReasonAddNotJava => ( + "there is no java executable at that path", + "那个路径里没有 java 可执行文件" + ), + SettingsJavaReasonAddNoVersion => ( + "java -version did not answer there, so it is not a usable JDK", + "在那里跑不出 java -version,不是一份能用的 JDK" + ), + SettingsJavaReasonAddTooLong => ( + "the path is too long to be a JDK location", + "路径太长,不像是一个 JDK 位置" + ), + SettingsJavaReasonAddWriteFailed => ( + "the JDK is fine, but the list file could not be written (disk or permission)", + "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)" + ), + SettingsJavaReasonAddDuplicate => ("that JDK is already in the list", "这份 JDK 已经在列表里了"), + SettingsJavaReasonWriteFailed => ( + "the settings file could not be written (disk full, no permission, or the value was rejected)", + "设置写不进去(磁盘满、没权限,或这个值被拒绝)" + ), + } } }; } diff --git a/crates/i18n/src/font.rs b/crates/i18n/src/font.rs index fb04985..634238a 100644 --- a/crates/i18n/src/font.rs +++ b/crates/i18n/src/font.rs @@ -1,4 +1,4 @@ -//! 中文字体策略(D28 后半):**只做判据,不联网**。 +//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。 //! //! ## 三句话 //! @@ -6,22 +6,28 @@ //! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。 //! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。 //! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作—— -//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。 +//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是 +//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持 +//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。 //! -//! ## 扫描为什么在 UI 壳层而不是这里 +//! ## 谁在什么时刻问这里 //! -//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台 -//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool` -//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。 +//! | 调用点 | 时机 | 后果 | +//! |---|---|---| +//! | `crates/app-iced/src/fonts.rs::resolve` | 启动(按系统 locale) | 打一行 `cjk=…` 日志 | +//! | `crates/app-iced/src/fonts.rs::cjk_for` | 设置页切语言 | 复算 + 需要时**触发下载** | +//! +//! 两条路都**只**在 [`Strategy::NeedsDownload`] 时才碰 [`Fetcher`]([`ensure`] 的判据), +//! 英文环境与「系统已有字体」两条路一次网络调用都没有(守卫见本文件的单测与 +//! `crates/app-iced/src/fonts/install/tests.rs`)。 //! //! ## 诚实边界(抓得住什么 / 抓不住什么) //! //! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定; //! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集, -//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 mission 不引依赖); -//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task` -//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」 -//! 属于下载 mission,本 crate 不碰。 +//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖); +//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux +//! 没有真机验证,见 M168 报告)。 use crate::lang::Lang; @@ -56,13 +62,30 @@ pub const CJK_FAMILIES: &[&str] = &[ /// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。 pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700]; -/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。 +/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Source { - /// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点), - /// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**: - /// 猜错的 CDN 路径会被后面所有人当事实用。 - PendingPin, + /// 钉死的一套下载参数(M168)。 + /// + /// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、 + /// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit** + /// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见 + /// `unlucky_core::download::font`)。 + /// + /// `urls` 是**候选**:首选挂了才试下一个。两个候选**必须是不同的失败域**(Cloudflare 侧 + + /// Fastly 侧,见 [`NOTO_SANS_SC`] 的实测表);同一家 CDN 的两个域名不算冗余。 + /// + /// 每次尝试与整条取回都有整体时限(`unlucky_core::download::font` 的 `ATTEMPT_DEADLINE` / + /// `TOTAL_DEADLINE`):慢而不死的连接(IPv6 爬行实测 67 KB/s)会在时限处被打断、转下一个 + /// 候选,而不是让用户在设置页干等四分钟。超时归「网络」档,界面文案是「网络不通或太慢」。 + Pinned { + /// URL 候选(按顺序尝试)。 + urls: &'static [&'static str], + /// 期望 `sha256`(小写十六进制,64 位)。 + sha256: &'static str, + /// 期望字节数(进度预算 + 大小上限判据)。 + bytes: u64, + }, } /// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。 @@ -74,16 +97,57 @@ pub struct CjkFont { pub license: &'static str, /// 这套字体可用的字重。 pub weights: &'static [u16], - /// 下载源(尚未钉死)。 + /// 下载源(URL 候选 + sha256 + 字节数,见 [`Source::Pinned`])。 pub source: Source, } -/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。 +/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)**可变字体**(wght 100–900,整个简体字符集)。 +/// +/// 钉死的东西(M168 实测抓的,不是猜的): +/// +/// | 项 | 值 | +/// |---|---| +/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` | +/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) | +/// | 字节数 | 17 772 300(16.95 MiB) | +/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` | +/// +/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下 +/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。 +/// +/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB, +/// 而可变字体一份 16.9 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见 +/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。 pub const NOTO_SANS_SC: CjkFont = CjkFont { family: "Noto Sans SC", license: "OFL-1.1", - weights: &[300, 400, 500, 700, 900], - source: Source::PendingPin, + weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900], + source: Source::Pinned { + // 两个候选**必须是不同的失败域**(M168 复审 P2-1):`cdn.jsdelivr.net` 与 + // `fastly.jsdelivr.net` 都 CNAME 到 `jsdelivr.map.fastly.net`(同一批 Fastly 边缘), + // 「一个坏了换另一个」等于没有冗余——上一版就是这样,被复审当场打掉。 + // + // 实测(两份独立的国内网络取证,同一份 17 772 300 B 内容,sha256 都是 a3041811…): + // + // | 域名 | 解析 | 本机(本 worktree 开发机) | 复审机(无代理) | + // |---|---|---|---| + // | gcore.jsdelivr.net | Cloudflare 104.16.x | 13.3 s 完整 | 16.6 s 完整 | + // | testingcf.jsdelivr.net | Cloudflare 104.16.x | 11.8 s 完整 | 14.8 s 完整 | + // | cdn.jsdelivr.net | Fastly 151.101.x | 11.9 s 完整(另两次 5.7 s / 14.9 s)| 1.1 s,但**走 IPv6 时 3 次都掉到 ≈67 KB/s**(60 s 只到 4.0 MB)| + // | fastly.jsdelivr.net | 同上(Fastly)| — | 20 s 只到 2.5 MB(比它「要备的那条」还慢)| + // + // 排序依据(不是「听说更稳」):**首选放 Cloudflare 那一侧**——Fastly 侧的 IPv6 边缘 + // 在复审环境里会掉进 60~77 KB/s 的慢路径(那条路上 17.7 MB 要 ≈4 分钟),而 Cloudflare + // 侧两份取证都完整下完、且是独立的失败域。二选留 Fastly 侧:本机它反而最快(5.7~11.9 s), + // 保留它是为了「Cloudflare 一侧整体不可用时还有另一家 CDN」。 + // 先后只影响速度,不影响正确性:sha256 只认一份,镜像给了别的内容会被当场拒。 + urls: &[ + "https://gcore.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", + "https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", + ], + sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da", + bytes: 17_772_300, + }, }; /// 字体策略结论。 @@ -134,22 +198,50 @@ pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy { }) } -/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。 +/// 字体下载器接口(**注入式**:i18n 不碰网络,真实现由 app 层提供)。 pub trait Fetcher { /// 取一套字体(返回原始字节,装进字体系统是调用方的事)。 /// + /// 契约:**同一个进程里第二次调用应当零网络**(缓存命中),且返回的字节已通过校验 + /// (实现方的责任,见 `unlucky_core::download::font`)。 + /// /// # Errors - /// 网络失败 / 校验失败等人话错误。 + /// 网络失败 / 校验失败 / 磁盘失败三类人话错误。 fn fetch(&self, font: &'static CjkFont) -> Result, FetchError>; } /// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。 +/// +/// 三类对应三句不同的话:网络类「过会儿再试」、校验类「这个源给的东西不对」、磁盘类 +/// 「腾点空间」。UI 文案按 [`FetchError::class`] 选词,**不要**按错误串猜。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum FetchError { /// 网络层失败(超时 / 连不上 / 非 2xx)。 Network(String), /// 校验失败(哈希 / 长度不符,或不是字体文件)。 Integrity(String), + /// 本地磁盘失败(建目录 / 写文件 / 换名)。 + Disk(String), +} + +impl FetchError { + /// 稳定类别名(`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, + } + } } /// [`ensure`] 的结局。 @@ -182,150 +274,5 @@ pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result impl Fn(&str) -> bool { - move |family| installed.contains(&family) - } - - /// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。 - struct FakeFetcher { - calls: Cell, - result: Result, FetchError>, - seen: Cell>, - } - - impl FakeFetcher { - fn new(result: Result, FetchError>) -> Self { - FakeFetcher { - calls: Cell::new(0), - result, - seen: Cell::new(None), - } - } - } - - impl Fetcher for FakeFetcher { - fn fetch(&self, font: &'static CjkFont) -> Result, FetchError> { - self.calls.set(self.calls.get() + 1); - self.seen.set(Some(font.family)); - self.result.clone() - } - } - - #[test] - fn english_never_wants_a_cjk_font() { - // 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。 - // 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。 - assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded); - assert_eq!( - decide(Lang::En, &only(&["Microsoft YaHei"])), - Strategy::NotNeeded, - "装了中文字体也不该进入中文分支" - ); - } - - #[test] - fn chinese_prefers_the_first_installed_candidate() { - // 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。 - // 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。 - assert_eq!( - decide( - Lang::ZhCn, - &only(&["Segoe UI", "Microsoft YaHei", "SimSun"]) - ), - Strategy::System { - family: "Microsoft YaHei" - } - ); - assert_eq!( - decide(Lang::ZhCn, &only(&["PingFang SC"])), - Strategy::System { - family: "PingFang SC" - } - ); - assert_eq!( - decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])), - Strategy::System { - family: "Noto Sans CJK SC" - } - ); - } - - #[test] - fn chinese_without_cjk_fonts_emits_the_download_signal() { - // 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。 - // 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。 - let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"])); - let Strategy::NeedsDownload(font) = strategy else { - panic!("应当要求下载:{strategy:?}"); - }; - assert_eq!(font.family, "Noto Sans SC"); - assert_eq!(font.license, "OFL-1.1"); - assert_eq!(font.source, Source::PendingPin, "URL/哈希留给下载 mission"); - assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)"); - } - - #[test] - fn download_fonts_cover_the_required_weights() { - // 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。 - // 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。 - for weight in REQUIRED_WEIGHTS { - assert!( - NOTO_SANS_SC.weights.contains(weight), - "缺 {weight} 档:{:?}", - NOTO_SANS_SC.weights - ); - } - } - - #[test] - fn ensure_only_touches_the_fetcher_when_download_is_needed() { - // 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求); - // 也抓得住下载失败被吞掉(错误必须原样透出)。 - // 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。 - let idle = FakeFetcher::new(Ok(vec![1, 2, 3])); - assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo)); - assert_eq!( - ensure(Strategy::System { family: "SimSun" }, &idle), - Ok(Fetched::AlreadySystem { family: "SimSun" }) - ); - assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown)); - assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网"); - - let ok = FakeFetcher::new(Ok(vec![9; 4])); - assert_eq!( - ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok), - Ok(Fetched::Downloaded(vec![9; 4])) - ); - assert_eq!(ok.calls.get(), 1); - assert_eq!(ok.seen.get(), Some("Noto Sans SC")); - - let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned()))); - assert_eq!( - ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken), - Err(FetchError::Network("超时".to_owned())) - ); - } - - #[test] - fn strategy_describe_is_one_line_for_every_case() { - // 抓得住:日志片段里混进换行(启动日志是按行解析的)。 - // 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。 - let cases = [ - Strategy::NotNeeded, - Strategy::System { family: "SimSun" }, - Strategy::NeedsDownload(&NOTO_SANS_SC), - Strategy::Unprobeable, - ]; - for case in cases { - let line = case.describe(); - assert_eq!(line.lines().count(), 1, "{line}"); - assert!(line.starts_with("cjk="), "{line}"); - } - assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed"); - assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable"); - } -} +#[path = "font/tests.rs"] +mod tests; diff --git a/crates/i18n/src/font/tests.rs b/crates/i18n/src/font/tests.rs new file mode 100644 index 0000000..db52bfc --- /dev/null +++ b/crates/i18n/src/font/tests.rs @@ -0,0 +1,177 @@ +//! `i18n::font` 的单测(拆出来只为守 400 行硬线)。 + +use std::cell::Cell; + +use super::*; + +fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |family| installed.contains(&family) +} + +/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。 +struct FakeFetcher { + calls: Cell, + result: Result, FetchError>, + seen: Cell>, +} + +impl FakeFetcher { + fn new(result: Result, FetchError>) -> Self { + FakeFetcher { + calls: Cell::new(0), + result, + seen: Cell::new(None), + } + } +} + +impl Fetcher for FakeFetcher { + fn fetch(&self, font: &'static CjkFont) -> Result, FetchError> { + self.calls.set(self.calls.get() + 1); + self.seen.set(Some(font.family)); + self.result.clone() + } +} + +#[test] +fn english_never_wants_a_cjk_font() { + // 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。 + // 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。 + assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded); + assert_eq!( + decide(Lang::En, &only(&["Microsoft YaHei"])), + Strategy::NotNeeded, + "装了中文字体也不该进入中文分支" + ); +} + +#[test] +fn chinese_prefers_the_first_installed_candidate() { + // 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。 + // 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。 + assert_eq!( + decide( + Lang::ZhCn, + &only(&["Segoe UI", "Microsoft YaHei", "SimSun"]) + ), + Strategy::System { + family: "Microsoft YaHei" + } + ); + assert_eq!( + decide(Lang::ZhCn, &only(&["PingFang SC"])), + Strategy::System { + family: "PingFang SC" + } + ); + assert_eq!( + decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])), + Strategy::System { + family: "Noto Sans CJK SC" + } + ); +} + +#[test] +fn chinese_without_cjk_fonts_emits_the_download_signal() { + // 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。 + // 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。 + let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"])); + let Strategy::NeedsDownload(font) = strategy else { + panic!("应当要求下载:{strategy:?}"); + }; + assert_eq!(font.family, "Noto Sans SC"); + assert_eq!(font.license, "OFL-1.1"); + assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)"); +} + +/// pin 的形态守卫(M168):URL 走 https、哈希是 64 位小写十六进制、字节数在大小上限内。 +/// +/// 抓得住:①有人把 URL 改成 `@main` 这类会漂移的引用(不是不行,但那样哈希必须跟着变, +/// 这条断言至少逼他看见自己在动什么);②手抄哈希抄错一位(下一个人只会在真机上看到 +/// 「校验失败」然后降级);③把三个静态子集塞进来却没改字节数。 +/// 抓不住:CDN 上今天还发不发得出这份字节(要真联网,报告里写明未验)。 +#[test] +fn the_pinned_source_is_well_formed() { + let Source::Pinned { + urls, + sha256, + bytes, + } = NOTO_SANS_SC.source; + assert!(!urls.is_empty(), "至少要有一个 URL"); + for url in urls { + assert!(url.starts_with("https://"), "字体必须走 https:{url}"); + assert!( + !url.contains("@main"), + "钉 commit 而不是会漂移的分支:{url}" + ); + } + assert_eq!(sha256.len(), 64, "{sha256}"); + assert!( + sha256.bytes().all(|byte| byte.is_ascii_hexdigit()), + "{sha256}" + ); + assert_eq!(sha256, sha256.to_ascii_lowercase(), "哈希用小写:{sha256}"); + assert!(bytes > 0 && bytes < 32 * 1024 * 1024, "{bytes}"); +} + +#[test] +fn download_fonts_cover_the_required_weights() { + // 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。 + // 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。 + for weight in REQUIRED_WEIGHTS { + assert!( + NOTO_SANS_SC.weights.contains(weight), + "缺 {weight} 档:{:?}", + NOTO_SANS_SC.weights + ); + } +} + +#[test] +fn ensure_only_touches_the_fetcher_when_download_is_needed() { + // 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求); + // 也抓得住下载失败被吞掉(错误必须原样透出)。 + // 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。 + let idle = FakeFetcher::new(Ok(vec![1, 2, 3])); + assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo)); + assert_eq!( + ensure(Strategy::System { family: "SimSun" }, &idle), + Ok(Fetched::AlreadySystem { family: "SimSun" }) + ); + assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown)); + assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网"); + + let ok = FakeFetcher::new(Ok(vec![9; 4])); + assert_eq!( + ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok), + Ok(Fetched::Downloaded(vec![9; 4])) + ); + assert_eq!(ok.calls.get(), 1); + assert_eq!(ok.seen.get(), Some("Noto Sans SC")); + + let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned()))); + assert_eq!( + ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken), + Err(FetchError::Network("超时".to_owned())) + ); +} + +#[test] +fn strategy_describe_is_one_line_for_every_case() { + // 抓得住:日志片段里混进换行(启动日志是按行解析的)。 + // 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。 + let cases = [ + Strategy::NotNeeded, + Strategy::System { family: "SimSun" }, + Strategy::NeedsDownload(&NOTO_SANS_SC), + Strategy::Unprobeable, + ]; + for case in cases { + let line = case.describe(); + assert_eq!(line.lines().count(), 1, "{line}"); + assert!(line.starts_with("cjk="), "{line}"); + } + assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed"); + assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable"); +} diff --git a/crates/presenter/src/dto/accounts.rs b/crates/presenter/src/dto/accounts.rs index eda6885..3e12bf7 100644 --- a/crates/presenter/src/dto/accounts.rs +++ b/crates/presenter/src/dto/accounts.rs @@ -18,7 +18,7 @@ mod redact; #[path = "accounts/tests.rs"] mod tests; -pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure}; +pub use failure::{AccountNotice, AccountNoticeCode, BrowserLoginNote, LoginFailure}; pub use kind::AccountKind; pub use name::valid_offline_name; pub use redact::{looks_like_jwt, redact, REDACTED}; @@ -36,12 +36,11 @@ pub struct AccountView { pub kind: AccountKind, /// 档案 UUID(公开) pub profile_id: String, - /// 登录名(微软轨才有;可空) + /// 登录名(微软轨才有) pub login_name: String, - /// 创建时间展示串(`2026-10-31 20:12`) + /// 创建时间展示串(`2026-10-31 20:12`)。 /// - /// 口径:core 的 `AccountMeta` 只有 `created_at_ms`,**没有 last-used**,所以列表上贴的是 - /// 「添加于 {created}」,不伪造「最近使用」(缺口登记在 M114 报告)。 + /// core 的 `AccountMeta` 只有 `created_at_ms`(无 last-used),所以列表贴「添加于 {created}」,不伪造「最近使用」。 pub created: String, /// 是否是当前启动使用的账户 pub active: bool, @@ -55,8 +54,6 @@ pub struct AccountView { impl AccountView { /// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。 - /// - /// 放在 DTO 而不是页面里,是为了「没有皮肤时画什么」这件事只有一处判据,两个 UI 版一致。 #[must_use] pub fn monogram(&self) -> String { self.name @@ -66,7 +63,7 @@ impl AccountView { .unwrap_or_else(|| "?".to_string()) } - /// 首字母块的取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次都是同一个色块。 + /// 首字母块取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次同色。 #[must_use] pub fn monogram_tone(&self) -> u8 { let mut hash: u32 = 2_166_136_261; @@ -78,10 +75,8 @@ impl AccountView { } } -/// 一张皮肤原图。 -/// -/// **停在「原图」这一层是刻意的**:presenter 不引入图片解码依赖(`presenter-no-ui` + -/// 依赖极简),裁剪/缩放/取首字母块都由 UI 层自己做。离线轨永远没有它——没有皮肤就不给假皮肤。 +/// 一张皮肤原图。**停在「原图」这层是刻意的**:presenter 不引图片解码依赖,裁剪/缩放归 UI 层; +/// 离线轨永远没有它——没有皮肤就不给假皮肤。 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SkinImage { /// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32) @@ -127,8 +122,7 @@ impl DeviceLoginPhase { /// 设备码登录的可见状态。 /// -/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`—— -/// 它们本来就是给用户看的,不是凭据。 +/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`(给用户看的,不是凭据)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceLoginView { /// 阶段 @@ -137,7 +131,7 @@ pub struct DeviceLoginView { pub user_code: String, /// 验证地址(`https://www.microsoft.com/link`) pub verification_uri: String, - /// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开) + /// 二维码内容(= 带码的链接) pub qr_link: String, /// 有效期(秒;0 = 未知) pub expires_in: u64, @@ -146,6 +140,9 @@ pub struct DeviceLoginView { /// 失败原因码(`None` = 不是失败态 / 未分类) #[serde(default, skip_serializing_if = "Option::is_none")] pub failure: Option, + /// 浏览器登录没走成、已回退设备码的说明码(M178;`None` = 没这回事) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_note: Option, /// 成功后入库的账户展示名 pub account_name: String, } @@ -167,6 +164,7 @@ impl DeviceLoginView { expires_in: 0, message: String::new(), failure: None, + browser_note: None, account_name: String::new(), } } @@ -194,14 +192,14 @@ impl DeviceLoginView { expires_in ), failure: None, + browser_note: None, account_name: String::new(), } } /// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。 /// - /// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/ - /// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。 + /// 面板不展示用户码,改展示**可复制的授权链接**;`warning` 是「浏览器没拉起来」这类非致命提示。 pub fn browser_waiting( authorization_url: &str, expires_in: u64, @@ -222,10 +220,18 @@ impl DeviceLoginView { expires_in, message, failure: None, + browser_note: None, account_name: String::new(), } } + /// 附一条回退说明码(M178)。与 `message`(原文只进日志)分开:页面只拿码去查 i18n。 + #[must_use] + pub fn with_browser_note(mut self, note: BrowserLoginNote) -> Self { + self.browser_note = Some(note); + self + } + /// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。 pub fn failed(message: impl Into) -> Self { Self::failed_as(LoginFailure::Other, message) @@ -256,8 +262,7 @@ impl DeviceLoginView { /// 第三方轨(authlib-injector)登录请求。 /// -/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的 -/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、 +/// **凭据红线**:`password` 只是「这一次输入的请求值」,host 取走即弃——不入账户库、不进日志、 /// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibRequest { @@ -305,14 +310,14 @@ impl AuthlibRequest { } } -/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:都是「申请 → 等待 → 成/败」)。 +/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:「申请 → 等待 → 成/败」)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibLoginView { /// 阶段 pub phase: DeviceLoginPhase, /// 认证服务器地址(规范化后的 API 根) pub server: String, - /// 服务发现里的服务器名(校验通过后才有;空 = 没校验/没名字) + /// 服务发现里的服务器名(校验通过后才有;空 = 没校验) pub server_name: String, /// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**) pub message: String, diff --git a/crates/presenter/src/dto/accounts/failure.rs b/crates/presenter/src/dto/accounts/failure.rs index ac07437..2ecd2b1 100644 --- a/crates/presenter/src/dto/accounts/failure.rs +++ b/crates/presenter/src/dto/accounts/failure.rs @@ -75,8 +75,34 @@ impl LoginFailure { } } -/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 +/// 「浏览器登录没走成,已自动改用设备码」的说明码(M178)。 /// +/// 为什么与 [`LoginFailure`] 分开:这不是登录失败——设备码流接上了,用户照常在等码。 +/// 它解释的是「为什么面板变成了设备码」以及**要不要去改应用注册**,所以单独一个码族, +/// 页面画成一行提示而不是错误横幅。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowserLoginNote { + /// 应用注册里没有回环重定向 URI(授权端点当场拒绝)——**唯一带「怎么修」的一句** + RedirectNotRegistered, + /// 预检没问出结论(网络/超时),只能先用设备码 + PreflightUnavailable, + /// 预检通过、授权码流仍然失败(超时 / 换 token 被拒 / 端口绑不上) + AttemptFailed, +} + +impl BrowserLoginNote { + /// 稳定 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", + } + } +} + +/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 /// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。 /// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index b8710b2..a5d6ea6 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -35,8 +35,8 @@ mod versions; pub use accounts::{ looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, - AccountView, AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure, - SkinImage, REDACTED, + AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, + DeviceLoginView, LoginFailure, SkinImage, REDACTED, }; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use content::{ diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 3edcca3..e98095a 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -3,9 +3,10 @@ //! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, //! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。 //! -//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调, -//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到 -//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。 +//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项), +//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路, +//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用: +//! 同一个取消槽位、同一段账户入库、同一个刷新链。 //! //! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 @@ -17,7 +18,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; +use unlucky_core::auth::microsoft::{ + now_ms, BrowserFallbackReason, MicrosoftAuth, MicrosoftSession, +}; use unlucky_core::auth::offline::offline_meta; use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; use unlucky_core::download::blocking::block_on; @@ -25,7 +28,7 @@ use unlucky_core::download::blocking::block_on; use crate::dto::CoreEvent; use crate::dto::{ valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, - DeviceLoginView, LoginFailure, + BrowserLoginNote, DeviceLoginView, LoginFailure, }; use super::env::HostEnv; @@ -183,10 +186,13 @@ impl LoginJob { } } -/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 +/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// +/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, +/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { - let auth = match microsoft_auth(&env) { + let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; @@ -203,40 +209,135 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { }); } -/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。 +/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 /// -/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的 -/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。 +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 +/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-authcode", move || { - let auth = match microsoft_auth(&env) { + spawn("msa-browser", move || { + // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, + // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 + let auth = match msa_auth(&env, MsaEntry::Browser, &job) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; - let tx_prompt = tx.clone(); - let result = block_on(auth.authcode_login(|prompt| { - // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 - let opened = opener::open_url(&prompt.authorization_url); - let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( - &prompt.authorization_url, - prompt.timeout.as_secs(), - opened.err().as_deref(), - )))); - })); - finish(&env, &tx, &job, result); + let tx_browser = tx.clone(); + let tx_device = tx.clone(); + let result = block_on(auth.browser_then_device_login( + |prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = + tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + }, + |prompt, fallback| { + let view = DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + ); + let view = match fallback { + Some(fallback) => { + // 回退原文只进日志;面板只看码。 + eprintln!( + "[auth] 浏览器登录未走成({}),已回退设备码:{}", + fallback.reason.code(), + fallback.detail + ); + view.with_browser_note(browser_note(fallback.reason)) + } + None => view, + }; + let _ = tx_device.send(HostTask::Login(Box::new(view))); + }, + )); + finish(&env, &tx, &job, result.map(|outcome| outcome.session)); }); } -/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 -fn microsoft_auth(env: &HostEnv) -> AuthResult { +/// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。 +fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { + match reason { + BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered, + BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable, + BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed, + } +} + +/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MsaEntry { + /// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。 + DeviceCode, + /// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。 + Browser, +} + +impl MsaEntry { + /// 这个入口要不要把 `browser_first` 置位。 + const fn browser_first(self) -> bool { + matches!(self, Self::Browser) + } +} + +/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 +/// +/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, +/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 +fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { + #[cfg(test)] + test_seam::record(_job, entry); MicrosoftAuth::from_env().map(|auth| { - auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))) + auth.with_browser_first(entry.browser_first()) + .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) }) } -/// `microsoft_auth` 失败 → 原因码。 +/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。 +/// +/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到 +/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用 +/// 的一刻就把模式记下来,把调用点变成可断言的事实。 +/// +/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局 +/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。 +#[cfg(test)] +mod test_seam { + use std::sync::{Arc, Mutex}; + + use super::{LoginJob, MsaEntry}; + + static ENTRIES: Mutex> = Mutex::new(Vec::new()); + + fn key(job: &LoginJob) -> usize { + Arc::as_ptr(&job.cancelled) as usize + } + + pub(super) fn record(job: &LoginJob, entry: MsaEntry) { + let key = key(job); + let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); + entries.push((key, entry.browser_first())); + } + + /// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。 + pub(super) fn mode_for(job: &LoginJob) -> Option { + let key = key(job); + let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); + entries + .iter() + .find(|(recorded, _)| *recorded == key) + .map(|(_, browser_first)| *browser_first) + } +} + +/// `msa_auth` 失败 → 原因码。 /// /// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的 /// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是 diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index cce5d36..ba5558e 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -185,3 +185,105 @@ fn login_job_cancel_flag_is_shared() { job.cancel(); assert!(clone.is_cancelled()); } + +/// **M178 复审 r1 P1-1 的守卫**:生产路径上,「用浏览器登录」这颗按钮必须真的把 +/// `browser_first` 置位,设备码那颗必须不置位。 +/// +/// 修复前实测过的形态:`start_browser_login` 用共享的 `microsoft_auth(&env)`(默认 +/// `browser_first=false`),于是 `browser_then_device_login` 里整段预检/回退编排不执行—— +/// 按钮静默退化成设备码、连一句说明都不出,`preflight` / `BrowserFallbackReason` / +/// `BrowserLoginNote` / 三条 i18n 文案全成不可达代码。 +/// +/// 抓得住(本用例就是为此写的):把 `msa_auth(&env, MsaEntry::Browser)` 改回 +/// `MsaEntry::DeviceCode`(或删掉 `.with_browser_first(...)` 那一跳以外的模式选择), +/// 记录会变成 `false` ⇒ 红。 +/// 抓不住:真的去连微软(无 client id 的测试构建里两条入口都在网络之前早退)。 +#[test] +fn the_browser_button_actually_turns_on_browser_first() { + let dir = std::env::temp_dir().join(format!("ull-msa-entry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + + // 设备码入口:不许碰浏览器(也不许碰回环预检)。 + let device_job = LoginJob::new(); + let (tx, _rx) = super::super::job::channel(); + start_login(env.clone(), tx, device_job.clone()); + assert_eq!( + wait_for_entry(&device_job), + Some(false), + "设备码入口把 browser_first 置成了 true:默认路会去开监听口/弹浏览器" + ); + + // 浏览器入口:**必须**置位,否则预检与回退整段不可达。 + let browser_job = LoginJob::new(); + let (tx, _rx) = super::super::job::channel(); + start_browser_login(env, tx, browser_job.clone()); + assert_eq!( + wait_for_entry(&browser_job), + Some(true), + "「用浏览器登录」没有置位 browser_first:按钮会静默退化成设备码(复审 r1 P1-1 的洞)" + ); +} + +/// 等登录线程把「这个 job 的入口模式」记下来(线程是 `spawn` 出去的,最多等这么久)。 +/// +/// 按 job 分桶而不是读「最后一次」:同一进程里别的用例也在调这两个入口(实测会串台假红)。 +fn wait_for_entry(job: &LoginJob) -> Option { + for _ in 0..200 { + if let Some(flag) = super::test_seam::mode_for(job) { + return Some(flag); + } + std::thread::sleep(Duration::from_millis(10)); + } + None +} + +/// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。 +/// +/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一, +/// 就会出现「日志说置位了、实际没置位」这类假绿。 +#[test] +fn the_entry_mode_lands_on_the_auth_instance() { + assert!(!MsaEntry::DeviceCode.browser_first()); + assert!(MsaEntry::Browser.browser_first()); + // 无 client id 时 `from_env` 早退,所以这里用显式 id 走同一个 builder 链。 + let auth = unlucky_core::auth::microsoft::MicrosoftAuth::new("0000-entry-test") + .expect("构造") + .with_browser_first(MsaEntry::Browser.browser_first()); + assert!( + auth.browser_first(), + "with_browser_first 没把字段置上(core 里 browser_then_device_login 会整段跳过)" + ); +} + +/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同—— +/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。 +/// +/// 抓得住:core 加/改一个回退原因而 presenter 忘了接(`browser_note` 是 `match`, +/// 新增变体会编译失败;这里再把**码的字符串**钉住,防的是两侧各改各的)。 +#[test] +fn browser_fallback_reasons_map_to_the_page_notes_with_the_same_codes() { + for (reason, note) in [ + ( + BrowserFallbackReason::RedirectNotRegistered, + BrowserLoginNote::RedirectNotRegistered, + ), + ( + BrowserFallbackReason::PreflightUnavailable, + BrowserLoginNote::PreflightUnavailable, + ), + ( + BrowserFallbackReason::AttemptFailed, + BrowserLoginNote::AttemptFailed, + ), + ] { + assert_eq!(browser_note(reason), note); + assert_eq!(note.code(), reason.code()); + } + // 「没登记回环」是唯一带「怎么修」的那一条,码也必须稳定(排障脚本按它 grep 日志)。 + assert_eq!( + BrowserLoginNote::RedirectNotRegistered.code(), + "redirect_not_registered" + ); +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index be2e7c4..92e6073 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -24,7 +24,9 @@ //! |---|---| //! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | //! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | -//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 | +//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 | +//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 | +//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 | //! //! ## 整合包与 CF 的边界(都写进报告) //! @@ -32,8 +34,10 @@ //! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。 //! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。 +mod downloads; mod http; mod install; +mod plan; // M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod) mod pack; mod sources; diff --git a/crates/presenter/src/host/addon/downloads.rs b/crates/presenter/src/host/addon/downloads.rs new file mode 100644 index 0000000..3d91061 --- /dev/null +++ b/crates/presenter/src/host/addon/downloads.rs @@ -0,0 +1,315 @@ +//! 下载阶段的**可测那一段**:把计划里的文件逐个下下来,失败进名单而不中断。 +//! +//! # 为什么单独一个文件 +//! +//! 这段逻辑以前整块躺在 `install::run` 里,而 `run` 需要实例目录 + 真引擎 ⇒ 没有用例能驱动它, +//! 于是「下载失败记进两档名单」这条改动**整段删掉也不会红一条**(M186 r2 复审实测: +//! 整段包进 `if false` 后 `cargo test -p unlucky-presenter --lib host::addon` 仍 36 passed)。 +//! 抽成只依赖 [`ArtifactFetcher`] 的函数之后,假下载器就能驱动它。 +//! +//! # 三条不变量(每条都有用例) +//! +//! 1. **失败不中断**:某个文件下载失败 ⇒ 记进 [`DependencyPlan::record_failure`],继续下一个; +//! 2. **已装的不回滚**:成功的文件留在盘上,并出现在返回的「已装」名单里; +//! 3. **两处取消点**:进入本阶段先查一次(`run` 解析完就调它 ⇒ 解析阶段的取消生效, +//! 全部文件都「已存在」而 `nodes` 为空时也不会漏掉取消),循环每次迭代再查一次。 + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +use unlucky_core::addon::online::{ + download_verified, ArtifactFetcher, DependencyFailure, DependencyPlan, FailureKind, + OnlineError, PlannedNode, +}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{ContentFailure, InstallProgressView}; + +use super::super::job::{HostTask, JobSender}; +use super::install::{local, node_label, safe_file_name}; + +/// 下载失败 → 两档失败里的一条(`Unreachable` + 叶子成因 `cause`)。 +/// +/// 为什么归 `Unreachable`:`download_verified` 的失败就是「取不到这份字节」——没网 / 站点错 / +/// 内容与声明不符 / 本地 IO,叶子成因留在 `cause` 上,上层照 `sources::failure_of` 映射成 +/// DTO 大类;**不是** `Incompatible`(换版本/加载器也救不了一次断网)。 +#[must_use] +pub(super) fn download_failure(node: &PlannedNode, error: &OnlineError) -> DependencyFailure { + DependencyFailure { + requested_as: node.requested_as.clone(), + required_by: node.required_by.clone(), + depth: node.depth, + kind: FailureKind::Unreachable, + cause: Some(error.kind()), + message: format!( + "下载 {} 失败({}):{}", + node.file.filename, + error.kind().as_str(), + error.message() + ), + } +} + +/// 下载阶段:先查一次取消,再逐个下(失败进名单、不中断)。 +/// +/// 返回**真落盘**的节点(失败的不在里面),调用方据此拼「装了 N 个」。 +/// +/// # Errors +/// 用户取消时返回一条 `ContentFailure`(detail 以「已取消」开头,`install()` 据此画 `Cancelled`)。 +pub(super) fn download_stage( + plan: &mut DependencyPlan, + target_dir: &Path, + fetcher: &dyn ArtifactFetcher, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result, ContentFailure> { + // 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说 + // (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。 + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载开始前)")); + } + let mut installed: Vec = Vec::new(); + for index in 0..plan.nodes.len() { + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载中途)")); + } + let node = plan.nodes[index].clone(); + progress.message = node_label(&node); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let dest = target_dir.join(safe_file_name(&node.file.filename)); + let outcome = block_on(download_verified( + fetcher, + &node.file.url, + node.file.sha1(), + node.file.sha512(), + (node.file.size > 0).then_some(node.file.size), + &dest, + )); + match outcome { + Ok(_) => installed.push(node), + Err(error) => { + // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): + // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 + plan.record_failure(download_failure(&node, &error)); + continue; + } + } + progress.files_done += 1; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + } + Ok(installed) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + use std::sync::Mutex; + + use unlucky_core::addon::online::{ + ArtifactFetcher, DependencyPlan, FailureKind, FetchArgs, OnlineError, OnlineErrorKind, + OnlineFuture, PlannedNode, ResolveRequest, VersionFile, + }; + + use crate::dto::InstallProgressView; + + use super::{download_failure, download_stage}; + + /// 假下载器:`fail_on` 里的文件名一律「没联网」,其余写一行字节(**不校验摘要**)。 + struct FakeFetcher { + fail_on: Vec<&'static str>, + attempts: Mutex>, + } + + impl FakeFetcher { + fn new(fail_on: &[&'static str]) -> Self { + Self { + fail_on: fail_on.to_vec(), + attempts: Mutex::new(Vec::new()), + } + } + + fn attempts(&self) -> Vec { + self.attempts.lock().expect("锁没中毒").clone() + } + } + + impl ArtifactFetcher for FakeFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + let name = args + .dest + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + self.attempts.lock().expect("锁没中毒").push(name.clone()); + if self.fail_on.contains(&name.as_str()) { + return Err(OnlineError::offline("假装的断网")); + } + std::fs::write(args.dest, b"jar").map_err(|error| { + OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}")) + })?; + Ok(4) + }) + } + } + + fn node(project: &str, file: &str, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 0, + ..VersionFile::default() + }, + depth: u8::from(!required_by.is_empty()), + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } + } + + fn plan(nodes: Vec) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("AANobbMI"), + nodes, + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + unresolved_required: Vec::new(), + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m186-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + /// 失败的那一条**进名单**、父边与叶子成因都带着(守的是 [`download_failure`] 的映射)。 + #[test] + fn a_download_error_becomes_an_unreachable_failure_with_its_leaf_cause() { + let node = node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]); + let failure = download_failure(&node, &OnlineError::offline("连不上 cdn.modrinth.com")); + + assert_eq!(failure.kind, FailureKind::Unreachable); + assert_eq!(failure.cause, Some(OnlineErrorKind::NotConnected)); + assert_eq!(failure.requested_as, "P7dR8mSH"); + assert_eq!(failure.required_by, ["AANobbMI".to_string()]); + assert_eq!(failure.depth, 1); + assert!( + failure.message.contains("fabric-api.jar"), + "{}", + failure.message + ); + assert!( + failure.message.contains("online_not_connected"), + "成因要带 code:{}", + failure.message + ); + } + + /// 第 2 个文件失败:**不中断**、已装的不回滚、失败进名单、第 3 个仍会被尝试。 + #[test] + fn a_failed_file_does_not_stop_the_rest_and_keeps_what_already_landed() { + let dir = temp_dir("download-partial"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + node("1eAoo2KR", "yacl.jar", &["P7dR8mSH"]), + ]); + let fetcher = FakeFetcher::new(&["fabric-api.jar"]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView { + files_total: 3, + ..InstallProgressView::new() + }; + + let installed = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(false), + &mut progress, + &tx, + 7, + ) + .expect("没取消就不该回 Err"); + + let names: Vec<&str> = installed + .iter() + .map(|node| node.file.filename.as_str()) + .collect(); + assert_eq!( + names, + ["sodium.jar", "yacl.jar"], + "成功的不回滚、后面的照下" + ); + // 失败的那个会被 `download_verified` 重试 `RETRY_ATTEMPTS` 次(这里 3 次), + // 关键是**它之后**还会去试第 3 个文件。 + let attempts = fetcher.attempts(); + assert_eq!(attempts.first().map(String::as_str), Some("sodium.jar")); + assert_eq!(attempts.last().map(String::as_str), Some("yacl.jar")); + assert_eq!( + attempts + .iter() + .filter(|name| *name == "fabric-api.jar") + .count(), + 3, + "失败的尝试 + 重试都发生在第 3 个文件之前:{attempts:?}" + ); + assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures); + assert_eq!(plan.failures[0].requested_as, "P7dR8mSH"); + assert!(plan.failures[0].message.contains("fabric-api.jar")); + assert_eq!(progress.files_done, 2, "进度只数真落盘的"); + assert!(dir.join("sodium.jar").exists(), "已下好的文件不许删"); + assert!(!dir.join("fabric-api.jar").exists()); + assert!(dir.join("yacl.jar").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// 解析完就取消 ⇒ 一条文件都不下(r2 nit②:main 有这条检查,我们曾把它丢了)。 + #[test] + fn cancelling_right_after_resolution_touches_nothing() { + let dir = temp_dir("download-cancelled"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + ]); + let fetcher = FakeFetcher::new(&[]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView::new(); + + let error = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(true), + &mut progress, + &tx, + 7, + ) + .expect_err("取消必须回 Err"); + + assert!(error.detail.contains("已取消"), "{}", error.detail); + assert!(fetcher.attempts().is_empty(), "一份都不许下"); + assert!(plan.failures.is_empty(), "取消不是失败,不进名单"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index e71388d..b3d2a4b 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -1,19 +1,37 @@ -//! 「一键装入指定实例」(M109)。 +//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。 //! //! ## 网络只走 code 的在线内容源(M162 r1) //! -//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、 -//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。 -//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 +//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在 +//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼 +//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 //! //! ## 它到底做了什么(诚实版) //! //! | 内容类型 | 做什么 | 状态 | //! |---|---|---| -//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 | +//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) | //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! +//! ## 前置的两档失败与重试名单(M186) +//! +//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 +//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 +//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, +//! `DependencyPlan::retry_request()` 已经把请求备好了。 +//! +//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, +//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 +//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。 +//! +//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」) +//! +//! | 内容 | 检索 | 装入 | 差在哪 | +//! |---|---|---|---| +//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 | +//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 | +//! //! ## 为什么进度只有阶段级,没有字节条 //! //! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有 @@ -26,7 +44,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery, + resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -39,7 +57,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::sources::failure_of; +use super::{downloads, plan}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -53,7 +71,8 @@ pub(super) fn install( version: request.title.clone(), instance: request.instance.clone(), loader: request.loader.label().to_string(), - files_total: 1, + // 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。 + files_total: 0, ..InstallProgressView::new() }; @@ -75,8 +94,7 @@ pub(super) fn install( }); match run(env, request, cancel, &mut progress, tx, epoch) { Ok(done) => { - progress.stage = InstallStage::Done; - progress.files_done = 1; + finish(&mut progress); progress.message = done; let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), @@ -112,7 +130,10 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu }); } -/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。 +/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。 +/// +/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 +/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -130,37 +151,87 @@ fn run( let target_dir = game_dir.join(dir); std::fs::create_dir_all(&target_dir) .map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?; - - let file = resolve_file(env, request)?; - if cancel.load(Ordering::SeqCst) { - return Err(local("已取消(下载开始前)")); + if request.source != ContentSource::Modrinth { + return Err(ContentFailure::unsupported( + "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", + )); } + + // 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。 + let resolve_request = plan::request( + &request.project, + &request.game_version, + loader_facet(request.loader), + plan::present_files(&target_dir), + ); + let core_engine = super::super::engine::engine(env); + let mut dependency_plan = { + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + block_on(resolve(&client, &resolve_request)) + }; + progress.stage = InstallStage::Downloading; - progress.message = file.name.clone(); + progress.files_total = dependency_plan.nodes.len() as u64; + progress.files_done = 0; + progress.message = if dependency_plan.nodes.is_empty() { + "没有要下载的文件".to_string() + } else { + format!( + "{} 个文件(含 {} 个前置)", + dependency_plan.nodes.len(), + dependency_plan + .nodes + .iter() + .filter(|node| !node.is_root()) + .count() + ) + }; let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), epoch, }); - let dest = target_dir.join(&file.name); - // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, - // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 - let core_engine = super::super::engine::engine(env); let fetcher = EngineArtifactFetcher::new(&core_engine); - block_on(download_verified( + // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 + let installed = downloads::download_stage( + &mut dependency_plan, + &target_dir, &fetcher, - &file.url, - file.sha1.as_deref(), - file.sha512.as_deref(), - file.size, - &dest, - )) - .map_err(failure_of)?; - Ok(format!("{}({})", file.name, dest.display())) + cancel, + progress, + tx, + epoch, + )?; + + // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), + // 并列出「可只重试这 N 个」。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { + return Err(failure); + } + Ok(plan::summary(&dependency_plan, &installed)) +} + +/// 完成态:`files_done` 对齐 `files_total`。 +/// +/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 +/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。 +pub(super) fn finish(progress: &mut InstallProgressView) { + progress.stage = InstallStage::Done; + progress.files_done = progress.files_total; +} + +/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 +pub(super) fn node_label(node: &PlannedNode) -> String { + if node.is_root() { + node.file.filename.clone() + } else { + format!("{}(前置)", node.file.filename) + } } /// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 -fn local(reason: impl Into) -> ContentFailure { +pub(super) fn local(reason: impl Into) -> ContentFailure { ContentFailure::new(ContentFailureKind::Other, reason) } @@ -187,69 +258,6 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result, - sha512: Option, - size: Option, -} - -/// 解析「这个项目下最适合目标实例的那个文件」。 -fn resolve_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - match request.source { - ContentSource::Modrinth => modrinth_file(env, request), - ContentSource::Curseforge => Err(ContentFailure::unsupported( - "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", - )), - } -} - -/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。 -/// -/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑, -/// `Version::primary_file()` 就是为这件事存在的。 -fn modrinth_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - let core_engine = super::super::engine::engine(env); - let transport = EngineTransport::new(&core_engine); - let client = Modrinth::new(&transport); - let versions = block_on(client.versions(&VersionQuery { - project: request.project.clone(), - game_version: Some(request.game_version.clone()), - loader: loader_facet(request.loader).map(str::to_string), - })) - .map_err(failure_of)?; - let version = versions.first().ok_or_else(|| { - ContentFailure::new( - ContentFailureKind::NotFound, - "这个项目没有匹配目标实例(版本 / 加载器)的文件", - ) - })?; - let file = version - .primary_file() - .ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?; - if file.url.trim().is_empty() { - return Err(ContentFailure::new( - ContentFailureKind::Unsupported, - "版本里的主文件没有下载地址", - )); - } - Ok(RemoteFile { - url: file.url.clone(), - name: safe_file_name(&file.filename), - sha1: file.sha1().map(ToString::to_string), - sha512: file.sha512().map(ToString::to_string), - size: (file.size > 0).then_some(file.size), - }) -} - /// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉 /// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。 pub(super) fn safe_file_name(raw: &str) -> String { diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs new file mode 100644 index 0000000..f09986c --- /dev/null +++ b/crates/presenter/src/host/addon/plan.rs @@ -0,0 +1,261 @@ +//! 依赖计划的**宿主侧那一半**:扫目录、把两档失败映成 DTO 大类、拼给用户看的那句话。 +//! +//! # 为什么判定在 core、说话在 presenter +//! +//! 「哪个前置不兼容、哪个前置取不到」由 `unlucky_core::addon::online::resolve` 判定并可离线 +//! 测试(录制 JSON 夹具)。本文件只做三件宿主才知道的事: +//! +//! | 事 | 为什么只能在这里 | +//! |---|---| +//! | 目标目录里有什么 | core 不读盘(`PresentFile` 由调用方扫) | +//! | 失败大类怎么映 | DTO 在 presenter(`ContentFailureKind`) | +//! | 文案怎么说 | 界面文案不归 core | +//! +//! # 两档失败**分开说** +//! +//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作, +//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 +//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 +//! +//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) +//! +//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 +//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, +//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 +//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 +//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 + +use std::path::Path; + +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, + ResolveRequest, +}; + +use crate::dto::ContentFailureKind; + +use super::sources::failure_of; + +/// 扫目标目录(不递归)里已存在的文件名。 +/// +/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 +/// 最常见形态(我们落盘用站点给的文件名,不重命名)。 +pub(super) fn present_files(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().is_file()) + .map(|entry| PresentFile::new(entry.file_name().to_string_lossy())) + .collect() +} + +/// 一次装入要用的解析请求(过滤条件与落盘目录一致)。 +pub(super) fn request( + project: &str, + game_version: &str, + loader: Option<&str>, + present: Vec, +) -> ResolveRequest { + ResolveRequest { + roots: vec![project.to_string()], + game_version: Some(game_version.to_string()), + loader: loader.map(str::to_string), + present, + ..ResolveRequest::default() + } +} + +/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。 +/// +/// `installed` = **真落盘的**文件数(下载失败的不算):失败文案里要能说清「主文件已装、 +/// 缺的是谁的前置」,所以不能拿 `plan.nodes.len()` 冒充。 +pub(super) fn failure_of_plan( + plan: &DependencyPlan, + installed: usize, +) -> Option { + if plan.is_clean() { + return None; + } + Some(crate::dto::ContentFailure::new( + overall_kind(plan), + report(plan, installed), + )) +} + +/// 整体大类:按「用户接下来该做什么」排序,离线 > 查无此项 > 站点错 > 其它 > 不兼容。 +fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind { + let kinds: Vec = plan.failures.iter().map(kind_of).collect(); + for wanted in [ + ContentFailureKind::Offline, + ContentFailureKind::NotFound, + ContentFailureKind::Server, + ContentFailureKind::Other, + ContentFailureKind::Unsupported, + ] { + if kinds.contains(&wanted) { + return wanted; + } + } + ContentFailureKind::Unsupported +} + +/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 +fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { + match failure.cause { + Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, + // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 + None => ContentFailureKind::Unsupported, + } +} + +/// 失败清单(两档各起一段,各自的条数分开数)。 +fn report(plan: &DependencyPlan, installed: usize) -> String { + let mut lines: Vec = Vec::new(); + let incompatible: Vec = plan + .failures_of(FailureKind::Incompatible) + .map(describe) + .collect(); + let unreachable: Vec = plan + .failures_of(FailureKind::Unreachable) + .map(describe) + .collect(); + if !incompatible.is_empty() { + lines.push(format!( + "不兼容所选版本/加载器 {} 个:{}", + incompatible.len(), + incompatible.join(";") + )); + } + if !unreachable.is_empty() { + lines.push(format!( + "取不到(网络 / 项目不存在){} 个:{}", + unreachable.len(), + unreachable.join(";") + )); + } + if installed > 0 { + lines.push(format!("已装入 {installed} 个文件(已下好的不回滚)")); + } + if !plan.skipped.is_empty() { + lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len())); + } + if let Some(note) = unresolved_note(plan) { + lines.push(note); + } + if !plan.truncated.is_empty() { + lines.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + // 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的) + let retry = plan.retry_request().roots; + if !retry.is_empty() { + lines.push(format!( + "可只重试这 {} 个:{}", + retry.len(), + retry.join("、") + )); + } + lines.join("\n") +} + +fn describe(failure: &DependencyFailure) -> String { + if failure.required_by.is_empty() { + format!("{}({})", failure.requested_as, failure.message) + } else { + format!( + "{}({};被 {} 要求)", + failure.requested_as, + failure.message, + failure.required_by.join("、") + ) + } +} + +/// 「有 required 但没有项目 id」那一档的提示(解析不了,但也不是没事发生)。 +fn unresolved_note(plan: &DependencyPlan) -> Option { + if plan.unresolved_required.is_empty() { + return None; + } + let items: Vec = plan + .unresolved_required + .iter() + .map(|item| { + let what = item + .version_id + .clone() + .or_else(|| item.file_name.clone()) + .unwrap_or_else(|| "未给版本".to_string()); + format!("{}(被 {} 要求)", what, item.parent) + }) + .collect(); + Some(format!( + "有 {} 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:{}", + items.len(), + items.join(";") + )) +} + +/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 +/// +/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, +/// 不从 `plan.nodes` 来——否则「装成了 N 个」会把下载失败的也数进去。 +pub(super) fn summary(plan: &DependencyPlan, installed: &[PlannedNode]) -> String { + if installed.is_empty() && plan.skipped.is_empty() && plan.unresolved_required.is_empty() { + return "没有要装的文件".to_string(); + } + let mut parts: Vec = Vec::new(); + if !installed.is_empty() { + let dependencies = installed.iter().filter(|node| !node.is_root()).count(); + // 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标) + let names: Vec = installed + .iter() + .map(|node| { + if node.is_root() { + node.file.filename.clone() + } else { + format!( + "{}(前置,被 {} 要求)", + node.file.filename, + node.required_by.join("、") + ) + } + }) + .collect(); + let head = if dependencies == 0 { + format!("装了 {} 个文件", installed.len()) + } else { + format!( + "装了 {} 个文件(含 {} 个前置)", + installed.len(), + dependencies + ) + }; + parts.push(format!("{head}:{}", names.join("、"))); + } + if !plan.skipped.is_empty() { + let names: Vec<&str> = plan + .skipped + .iter() + .map(|node| node.file_name.as_str()) + .collect(); + parts.push(format!( + "跳过 {} 个(目录里已存在,没有重复下载):{}", + plan.skipped.len(), + names.join("、") + )); + } + if let Some(note) = unresolved_note(plan) { + parts.push(note); + } + if !plan.truncated.is_empty() { + parts.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + parts.join("\n") +} diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 3c29d1e..89e6436 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -115,3 +115,265 @@ fn core_errors_map_to_dto_failure_kinds() { "connect timed out" ); } + +// ------------------------------------------------ M186:前置解析的两档失败与可重试名单 + +use super::install::finish; +use super::plan::{failure_of_plan, summary}; +use crate::dto::{InstallProgressView, InstallStage}; +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason, + SkippedNode, UnresolvedRequired, VersionFile, +}; + +fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 1024, + ..VersionFile::default() + }, + depth, + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } +} + +fn failure( + project: &str, + kind: FailureKind, + cause: Option, + message: &str, +) -> DependencyFailure { + DependencyFailure { + requested_as: project.to_string(), + required_by: vec!["AANobbMI".to_string()], + depth: 1, + kind, + cause, + message: message.to_string(), + } +} + +fn plan( + failures: Vec, + nodes: Vec, + skipped: Vec, +) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("sodium"), + nodes, + skipped, + failures, + truncated: Vec::new(), + unresolved_required: Vec::new(), + } +} + +/// 全是「不兼容」时:说换版本/加载器,而不是「下载失败」,并且照样给重试名单。 +#[test] +fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Unsupported); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); + assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); + assert!( + !failure.detail.contains("取不到"), + "两档不许混:{}", + failure.detail + ); +} + +/// 两档同时出现时:两段都写,大类按「用户接下来该做什么」选(查无此项 > 不兼容)。 +#[test] +fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::NotFound); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 2 个")); +} + +/// 没网那一档:大类是离线(页面据此画「检查网络」而不是「换个关键词」)。 +#[test] +fn an_offline_jump_wins_over_everything_else() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "连不上 api.modrinth.com", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!(failure.is_offline()); +} + +/// 全装成的那句话要点出「含 N 个前置」与「跳过 M 个已存在」。 +#[test] +fn the_success_message_counts_dependencies_and_skipped_files() { + let plan = plan( + Vec::new(), + vec![ + node("AANobbMI", "sodium.jar", 0, &[]), + node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]), + ], + vec![SkippedNode { + project: "1eAoo2KR".to_string(), + requested_as: "1eAoo2KR".to_string(), + file_name: "yacl.jar".to_string(), + depth: 2, + required_by: vec!["P7dR8mSH".to_string()], + reason: SkipReason::AlreadyPresent, + }], + ); + assert!(failure_of_plan(&plan, 2).is_none()); + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); + assert!( + message.contains("装了 2 个文件(含 1 个前置)"), + "{message}" + ); + // 「为什么装」:前置那句要带出要求它的项目 + assert!( + message.contains("fabric-api.jar(前置,被 AANobbMI 要求)"), + "{message}" + ); + assert!(message.contains("跳过 1 个(目录里已存在,没有重复下载):yacl.jar")); +} + +/// 扫目录只认文件、不认子目录(子目录不是 jar,混进来会让去重误判)。 +#[test] +fn present_files_lists_only_files_in_the_target_directory() { + let dir = std::env::temp_dir().join(format!("ull-m186-present-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("nested")).expect("建临时目录"); + std::fs::write(dir.join("sodium.jar"), b"x").expect("写文件"); + std::fs::write(dir.join("yacl.jar.disabled"), b"x").expect("写文件"); + let mut names: Vec = super::plan::present_files(&dir) + .into_iter() + .map(|file| file.name) + .collect(); + names.sort(); + assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]); + let _ = std::fs::remove_dir_all(&dir); +} + +// ------------------------------------------------ M186 r1:完成态进度、下载失败上下文、无地址前置 + +/// 完成态不许把进度倒回 `1/N`(r1 P2-3:这里曾写死 `files_done = 1`)。 +#[test] +fn finishing_aligns_the_terminal_progress_with_the_total() { + let mut progress = InstallProgressView { + files_total: 3, + files_done: 3, + stage: InstallStage::Downloading, + ..InstallProgressView::new() + }; + finish(&mut progress); + assert_eq!(progress.stage, InstallStage::Done); + assert_eq!(progress.files_done, progress.files_total); + assert_eq!(progress.ratio(), 1.0, "装完了条必须满"); + + // 全部跳过(total = 0)时也不许变成 1/0 + let mut skipped_all = InstallProgressView { + files_total: 0, + ..InstallProgressView::new() + }; + finish(&mut skipped_all); + assert_eq!(skipped_all.files_done, 0); + assert_eq!(skipped_all.stage, InstallStage::Done); +} + +/// 下载阶段的失败也要带上下文:已装几个 + 缺的是谁的前置 + 可只重试谁。 +#[test] +fn a_download_failure_keeps_the_already_installed_context() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan, 1).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!( + failure.detail.contains("已装入 1 个文件(已下好的不回滚)"), + "{}", + failure.detail + ); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); +} + +/// 只有版本 id、没有项目 id 的必备前置:既装不了,也不许静默吞掉。 +#[test] +fn a_required_dependency_without_a_project_id_is_reported() { + let mut plan = plan( + Vec::new(), + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + plan.unresolved_required = vec![UnresolvedRequired { + parent: "AANobbMI".to_string(), + version_id: Some("ver-embedded".to_string()), + file_name: Some("embedded-lib.jar".to_string()), + }]; + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); + assert!( + message.contains("有 1 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:ver-embedded(被 AANobbMI 要求)"), + "{message}" + ); +} diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index acb633e..572c78e 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -1,14 +1,12 @@ //! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。 //! -//! 三件纪律(AGENTS §6): -//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源); -//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝); -//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。 +//! 三件纪律(AGENTS §6):① 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / +//! AI `set_config` 同源);② 不自己拼 JSON、不绕 schema(非法键/越界值由 `store.set` 拒绝); +//! ③ 这里只认「键名 + 字符串值」,领域语义留在 core。 //! //! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。 use serde_json::Value; -use unlucky_core::game::java::JdkVendor; use unlucky_core::setting::spec::ValueKind; use crate::dto::{SettingGroupView, SettingRowView}; @@ -243,10 +241,22 @@ fn data_group(env: &HostEnv) -> SettingGroupView { )) } -/// AI(D4):开关/端点/模型/YOLO。 +/// AI(D4):开关/端点/模型/密钥状态/YOLO。 +/// **选项表直接取 schema 的 `AI_PROVIDERS`**(M174):原先手写三项、漏了 `disabled`,于是被别的 +/// 写者设成 `disabled` 的机器在这一页会显示成 `openai_compatible`(`selected()` 找不到就回 0)。 +/// **密钥只显示状态不显示值**:`ai.api_key` 是 `locked` 凭据,进 DTO 就等于进事件流/日志。 fn ai_group(env: &HostEnv) -> SettingGroupView { - let providers = ["openai_compatible", "anthropic", "ollama"]; + let providers = unlucky_core::setting::keys::AI_PROVIDERS; let provider = text(env, "ai.provider"); + let chosen = if provider.is_empty() { + "openai_compatible" + } else { + provider.as_str() + }; + let key_state = match text(env, "ai.api_key").trim().chars().count() { + 0 => "未配置(在 iced 设置页填写)".to_string(), + chars => format!("已配置({chars} 个字符)"), + }; SettingGroupView::new("ai", "AI 助手(D4)") .with(SettingRowView::toggle( "ai.enabled", @@ -258,15 +268,8 @@ fn ai_group(env: &HostEnv) -> SettingGroupView { "ai.provider", "模型端点", "OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama", - &providers, - selected( - &providers, - if provider.is_empty() { - "openai_compatible" - } else { - &provider - }, - ), + providers, + selected(providers, chosen), )) .with(SettingRowView::text( "ai.base_url", @@ -280,6 +283,7 @@ fn ai_group(env: &HostEnv) -> SettingGroupView { "如 deepseek-chat", &text(env, "ai.model"), )) + .with(SettingRowView::read_only("API 密钥", &key_state)) .with(SettingRowView::toggle( "ai.yolo", "YOLO 模式", @@ -338,11 +342,8 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> { /// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。 /// -/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool -/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`, -/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。 -/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是 -/// `every_float_key_in_the_schema_can_be_written_back`。 +/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String +/// 送进 `store.set` ⇒ `InvalidValue` ⇒ `save()` 不执行。守门:`every_float_key_in_the_schema_can_be_written_back`。 fn parse_value(key: &str, value: &str) -> Result { let schema = unlucky_core::setting::keys::launcher_schema(); let Some(spec) = schema.spec(key) else { @@ -391,7 +392,6 @@ pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> { .set("java.vendorPriority", Value::Array(Vec::new())) .and_then(|()| store.save()) .map_err(|error| format!("清空发行版优先级失败:{error}"))?; - let _ = JdkVendor::ALL; Ok(()) } diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 3b9021c..31a6196 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -21,6 +21,11 @@ use unlucky_core::setting::store::SettingStore; use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode}; +#[path = "settings_bridge/write.rs"] +mod write; + +pub(crate) use write::Edit; + /// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。 const DATA_ROOT_KEYS: [&str; 4] = [ "core.data_mode", @@ -119,27 +124,28 @@ impl SettingsBridge { } } - /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// + /// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的 + /// (启动时 load 一次),而 `SettingStore::save` 是**整份文档**落盘。不先重读就会拿「≤1 秒前 + /// 的旧快照」整体覆盖文件:用户刚存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒 + /// 被外观写回抹掉。现在「现读」这一步收在唯一写入口里,本函数只管改哪些键。 pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { - let Some(store) = self.store.as_mut() else { - return; - }; - let mut snapshot = Appearance::from_store(store); - snapshot.theme_mode = theme.as_str().to_owned(); - snapshot.bg_image_enabled = appearance.bg_image_enabled; - snapshot.bg_image = appearance.bg_image.clone(); - snapshot.bg_fill = appearance.bg_fill; - snapshot.bg_dim = f64::from(appearance.bg_dim); - snapshot.panel_alpha = f64::from(appearance.panel_alpha); - snapshot.glass = appearance.glass; - snapshot.accent_color = appearance.accent_color.clone(); - if snapshot.write(store).is_err() { - return; - } - let _ = store.save(); - if let Some(path) = self.path() { - self.last_mtime = mtime(path); - } + let _ = self.write_with(|store| { + let mut snapshot = Appearance::from_store(store); + snapshot.theme_mode = theme.as_str().to_owned(); + snapshot.bg_image_enabled = appearance.bg_image_enabled; + snapshot.bg_image = appearance.bg_image.clone(); + snapshot.bg_fill = appearance.bg_fill; + snapshot.bg_dim = f64::from(appearance.bg_dim); + snapshot.panel_alpha = f64::from(appearance.panel_alpha); + snapshot.glass = appearance.glass; + snapshot.accent_color = appearance.accent_color.clone(); + if snapshot.write(store).is_err() { + return Edit::Rejected; + } + Edit::Changed + }); } /// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。 @@ -288,6 +294,42 @@ mod tests { panic!("外部修改没有被 mtime 热切捕获"); } + /// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。 + #[test] + fn persist_keeps_keys_another_writer_just_saved() { + let path = temp_config("persist-keeps-ai"); + let mut bridge = SettingsBridge::with_path(&path); + // 先让本桥真的加载过一份文件(旧快照从这里来)。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + + { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + // 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。 + let mut store = SettingStore::load(&path, launcher_schema()); + for (key, value) in [ + ("ai.base_url", "https://api.deepseek.com/v1"), + ("ai.model", "deepseek-chat"), + ("ai.api_key", "sk-must-survive"), + ] { + store.set(key, value.to_string().into()).expect("写 AI 键"); + } + store.save().expect("落盘"); + } + + // 同一秒内改主题:外观写回。 + 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("deepseek-chat"), "model 也被盖掉了:{text}"); + assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + #[test] fn accent_preset_and_validation() { let mut appearance = AppearancePrefs::factory(); diff --git a/crates/presenter/src/settings_bridge/write.rs b/crates/presenter/src/settings_bridge/write.rs new file mode 100644 index 0000000..528ced9 --- /dev/null +++ b/crates/presenter/src/settings_bridge/write.rs @@ -0,0 +1,90 @@ +//! 设置桥的**唯一写入口**(M174 r2 复审 P2-1)。 +//! +//! ## 为什么要把「写」收敛成一处 +//! +//! [`SettingsBridge`] 的 store 是**长生命**的(启动时 `SettingStore::load` 一次,之后只读), +//! 而 `SettingStore::save` 写的是**整份文档**(`core::setting::store` 的 `write_document` +//! 序列化 `doc.to_value()`,不是脏字段补丁)。于是任何「拿旧快照直接 save」的入口都会把别人 +//! 刚写进文件的键整片抹掉: +//! +//! | 轮次 | 入口 | 现象 | +//! |---|---|---| +//! | r1 | [`SettingsBridge::persist`](外观/主题) | 保存 AI 配置后 1 秒内改主题 ⇒ `ai.*` 连密钥一起消失 | +//! | r2 | `persist_ui_language`(界面语言) | 保存 AI 配置后 1 秒内在同一页切成 English ⇒ 同样消失,而且函数**返回 true**(静默) | +//! +//! 两轮都是「同一类缺陷隔一个文件复发一次」。所以写这件事只留 [`SettingsBridge::write_with`] +//! 一个出口:**现读 → 改 → 落盘 → 刷 mtime**,新入口只能传闭包,忘不了 `reload()`。 +//! +//! ## 为什么返回 [`WriteOutcome`] 而不是 `bool` +//! +//! 「没写」有三种原因(内存态 / 没有改动 / 被 schema 只读闸门拦下 / 写盘失败),调用方要能分清: +//! 语言入口要回答「真的落盘了吗」(旧版把 `Skipped` 也当成功),诊断要能说出原因。 + +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use super::{mtime, SettingsBridge}; + +/// 闭包对文档做的事。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Edit { + /// 文档改过了(需要落盘)。 + Changed, + /// 没有可改的(幂等路径):不落盘,也不算失败。 + Unchanged, + /// 改不动(`store.set` 被 schema / 只读闸门拒绝)。 + Rejected, +} + +/// 一次写的结果(`Skipped` 的载荷给诊断用;界面只关心 [`WriteOutcome::is_saved`])。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WriteOutcome { + /// 数据根解析不出来(内存态桥):什么都没写。 + NoStorage, + /// 闭包说没有改动:没落盘。 + Unchanged, + /// 真的写进磁盘了。 + Saved, + /// `save()` 跳过了(只读 / 不可信文件 / 没有脏键)。 + Skipped(SkipReason), + /// 写盘报错(序列化 / 备份 / IO)。 + Failed, +} + +impl WriteOutcome { + /// 磁盘上真的有新内容吗(**只有这一档算成功**)。 + #[must_use] + pub(crate) const fn is_saved(self) -> bool { + matches!(self, Self::Saved) + } +} + +impl SettingsBridge { + /// 长生命 store 的**唯一写入口**:现读 → 改 → 落盘 → 刷 mtime。 + /// + /// `edit` 拿到的 `&mut SettingStore` **已经是刚从磁盘重读的那一份**(这一句就是 r1/r2 两个 + /// 缺陷的修法:任何入口都不许拿启动时的旧快照整份覆盖)。 + pub(crate) fn write_with(&mut self, edit: F) -> WriteOutcome + where + F: FnOnce(&mut SettingStore) -> Edit, + { + let Some(store) = self.store.as_mut() else { + return WriteOutcome::NoStorage; + }; + // ← 这一行是两轮复审的修复本体。 + store.reload(); + match edit(store) { + Edit::Changed => {} + Edit::Unchanged => return WriteOutcome::Unchanged, + Edit::Rejected => return WriteOutcome::Failed, + } + let outcome = match store.save() { + Ok(SaveOutcome::Saved(_)) => WriteOutcome::Saved, + Ok(SaveOutcome::Skipped(reason)) => WriteOutcome::Skipped(reason), + Err(_) => WriteOutcome::Failed, + }; + if let Some(path) = self.path() { + self.last_mtime = mtime(path); + } + outcome + } +} diff --git a/crates/presenter/src/settings_bridge_lang.rs b/crates/presenter/src/settings_bridge_lang.rs index b433302..b1bb957 100644 --- a/crates/presenter/src/settings_bridge_lang.rs +++ b/crates/presenter/src/settings_bridge_lang.rs @@ -20,7 +20,7 @@ use unlucky_core::setting::document::SettingDocument; use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::store::SettingStore; -use crate::settings_bridge::SettingsBridge; +use crate::settings_bridge::{Edit, SettingsBridge}; /// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。 /// @@ -44,27 +44,39 @@ impl SettingsBridge { /// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。 /// - /// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。 + /// 返回是否**真的落了盘**(内存态 / 只读 / 文件被更新版本写过 ⇒ `false`,配置写不进去不该把 + /// UI 卡住)。写法走 [`SettingsBridge::write_with`](M174 r2 复审 P2-1):与外观写回同一个 + /// 唯一出口,**入口自己不再碰 `store.save()`**——r1 修了外观那条、这一条漏着,于是「保存 AI + /// 配置后 1 秒内切界面语言」照样把 `ai.*` 连密钥一起抹掉,而且本函数还返回 `true`。 + /// + /// 为什么不自己 `store.reload()` 了事:这是**同一类缺陷的第二个入口**(第一个是 + /// [`SettingsBridge::persist`])。收敛成 `write_with` 之后,下一个写入口在类型上就拿不到 + /// 「不重读的 save」——而不是靠每个人记得。 pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool { - let Some(store) = self.store.as_mut() else { - return false; - }; - let changed = match value { - Some(language) => store - .set(UI_LANGUAGE, serde_json::Value::String(language.to_owned())) - .is_ok(), - None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store), - }; - if !changed { - return false; - } - let saved = store.save().is_ok(); - if let Some(path) = self.path() { - self.last_mtime = std::fs::metadata(path) - .ok() - .and_then(|meta| meta.modified().ok()); - } - saved + let outcome = self.write_with(|store| match value { + Some(language) => { + if store + .set(UI_LANGUAGE, serde_json::Value::String(language.to_owned())) + .is_err() + { + return Edit::Rejected; + } + Edit::Changed + } + // 「跟随系统」= 删键:`SettingDocument` 没有 remove API,而落盘写的是全量视图,所以 + // 把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键原样带回)。 + None => { + if store.doc().get(UI_LANGUAGE).is_none() { + return Edit::Unchanged; + } + if remove_ui_language(store) { + Edit::Changed + } else { + Edit::Unchanged + } + } + }); + outcome.is_saved() } } @@ -105,6 +117,85 @@ mod tests { path } + /// **P2-1(r2)的回归**:语言写回不许盖掉别人刚写的键。 + /// + /// 现场(复审探针 `rev2_ai_keys_survive_a_language_write`):保存 AI 配置后 1 秒内在同一页把 + /// 界面语言切成 English ⇒ `persist_ui_language` 拿旧快照整份覆盖 ⇒ 文件里只剩 `appearance` + + /// `ui`,`ai` 整段消失,而本函数**返回 true**。 + /// + /// 抓得住:`write_with` 少了 `store.reload()`(或某个入口又自己 `store.save()` 绕过它)。 + #[test] + fn persisting_the_language_keeps_keys_another_writer_just_saved() { + let path = temp_config("lang-keeps-ai"); + let mut bridge = SettingsBridge::with_path(&path); + // 先让本桥真的加载过一次文件(长生命 store 的旧快照从这里来)。 + bridge.persist( + &crate::prefs::AppearancePrefs::factory(), + crate::prefs::ThemeMode::Light, + ); + + // 另一个写者(M174 的助手设置页)写进三个 AI 键。 + { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + let mut store = SettingStore::load(&path, launcher_schema()); + for (key, value) in [ + ("ai.base_url", "https://api.deepseek.com/v1"), + ("ai.model", "deepseek-chat"), + ("ai.api_key", "sk-must-survive-lang"), + ] { + store.set(key, value.to_string().into()).expect("写 AI 键"); + } + store.save().expect("落盘"); + } + + // 同一秒内切界面语言(语言入口写回它那份整文档)。 + assert!( + bridge.persist_ui_language(Some("en_us")), + "语言这一项该写进去" + ); + + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("sk-must-survive-lang"), + "语言写回把刚保存的密钥盖掉了:{text}" + ); + assert!(text.contains("deepseek-chat"), "模型名也被盖掉了:{text}"); + assert!(text.contains("\"en_us\""), "语言这一项该写进去:{text}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + /// 「真的落盘了吗」不许撒谎:只读文件(`$schema` 认不出 ⇒ `FileAccess::ReadOnly`)上 + /// `persist_ui_language` 必须是 `false`。 + #[test] + fn persisting_the_language_reports_false_when_the_file_is_read_only() { + let path = temp_config("lang-read-only"); + // `Invalid` 兼容档 ⇒ 只读:值照读,但不许被我们改写。 + std::fs::write( + &path, + r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ui":{"language":"zh_cn"}}"#, + ) + .expect("写配置"); + let before = std::fs::read_to_string(&path).expect("读配置"); + + let mut bridge = SettingsBridge::with_path(&path); + assert_eq!( + bridge.load_ui_language().as_deref(), + Some("zh_cn"), + "值照读" + ); + assert!( + !bridge.persist_ui_language(Some("en_us")), + "只读文件上写成功是谎话" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("读配置"), + before, + "被只读闸门拦下的写不许改动文件" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + /// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文); /// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。 /// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。