feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)

用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」

- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
  数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
  要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
  下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
  `java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
  各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
  `SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。

顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
This commit is contained in:
AskaEth committed 2026-11-08 20:49:56 +08:00
1 parent 329c529bea
commit ce463cc9b5
33 files changed
+3839 -322

No files matched your search

@@ -0,0 +1,246 @@
//! 三个设置键的真读写(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
//!
//! 与 `store.rs`(M175 的内存 / JVM / 默认 JDK)同一条通路:`core::setting::store::SettingStore`
//! —— 读一份 → 改几个键 → 存 → **再从磁盘读回来**。写盘只认 `Saved` 一档,其余一律当失败
//! (`WriteError` 的三档与 `store.rs` 同一套类型,界面文案也同一套)。
//!
//! ## 「生效值 + 从哪一层来」为什么在这里算
//!
//! 两个键都可能**留空**,而留空的语义分别指向另外两层:
//!
//! | 键 | 留空 = | 生效值的第二层 |
//! |---|---|---|
//! | `java.installRoot` | 按 `core.data_mode` 派生落点 | `JavaInstallRoot::resolve`(portable / standard) |
//! | `java.vendorPriority` | 按地区默认序 | `vendor::order(&[], domestic)` |
//!
//! 所以「现在到底装到哪 / 按什么顺序找」只有把**落盘值**代进 core 的纯函数才说得清。
//! 这两个纯函数本身就是真正下载时用的那一对(`install` 也走 `resolve` 与 `order`),
//! 于是界面上的「生效」与真跑起来的行为不会各说一套。
//!
//! ## 地区判定只做一次
//!
//! `domestic` 从 `cache/region.json` 读(引擎建过一次就有);读不到时按环境信号判一次并写回
//! ——与 `shared::build` 的做法一致。它**不联网**(`region_system` 只跑 `tzutil` / `reg`),
//! 且本函数只在首屏与配置文件变化时被调用,不进心跳。
use std::path::{Path, PathBuf};
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::game::java::vendor::{self, JdkVendor};
use unlucky_core::game::java::{JavaInstallRoot, LocationMode};
use unlucky_core::platform::data_dir::DataMode;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::prefs::{Effective, OrderLayer, Prefs, RootLayer, Snapshot};
use super::store::{unwritable, WriteError};
/// 读三键真值。
#[must_use]
pub fn load(config: &Path) -> Snapshot {
let store = SettingStore::load(config, launcher_schema());
snapshot(store.doc())
}
/// 写三键,返回**落盘后读回来**的真值。
///
/// # Errors
/// 本地校验不过([`WriteError::Refused`])、文件只读 / 不可信([`WriteError::Unwritable`])
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
pub fn save(config: &Path, prefs: &Prefs) -> Result<Snapshot, WriteError> {
prefs.validate().map_err(WriteError::Refused)?;
let mut store = SettingStore::load(config, launcher_schema());
let root = prefs.install_root.trim().to_string();
let ids = prefs.priority_ids();
// `java.vendorPriority` 是数组键,而 `SettingStore::set` 只收 `serde_json::Value`——
// app-iced 的依赖表里没有 serde_json(本 mission 也改不了 manifest),所以不现造一个
// `Value`:从文档里取**当前那个数组**(schema 默认就是空数组)清空再填,形状与 schema 一致。
let mut array = store
.doc()
.effective("java.vendorPriority")
.cloned()
.unwrap_or_default();
{
let Some(items) = array.as_array_mut() else {
return Err(WriteError::Schema(
"java.vendorPriority 当下不是数组(schema 的默认值是空数组)".to_string(),
));
};
items.clear();
for id in &ids {
items.push(id.as_str().into());
}
}
store
.set("java.installRoot", root.into())
.and_then(|()| store.set("java.auto_install", prefs.auto_install.into()))
.and_then(|()| store.set("java.vendorPriority", array))
.map_err(|error| WriteError::Schema(format!("{error}")))?;
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}"))),
}
}
/// 生效值 + 来源层(渲染器只翻文案)。
#[must_use]
pub fn effective(config: &Path, prefs: &Prefs) -> Effective {
let store = SettingStore::load(config, launcher_schema());
let doc = store.doc();
let data_mode = doc
.effective_str("core.data_mode")
.and_then(DataMode::parse)
.unwrap_or_else(|| DataMode::default_for(Platform::current()));
let override_root = {
let text = prefs.saved_install_root.trim();
(!text.is_empty()).then(|| PathBuf::from(text))
};
let layout = home_of(config).map(|home| LauncherLayout::new(home));
let (root, root_mode, root_layer, root_unresolved) = match &layout {
Some(layout) => match JavaInstallRoot::resolve(
data_mode,
layout,
override_root.as_deref(),
Platform::current(),
&ProcessEnv,
) {
Ok(resolved) => (
resolved.root.display().to_string(),
resolved.mode.as_str(),
layer_of(resolved.mode),
false,
),
Err(error) => {
eprintln!("[java] 解析安装根失败:{error}");
(
String::new(),
data_mode.as_str(),
if override_root.is_some() {
RootLayer::Setting
} else {
RootLayer::DataMode
},
true,
)
}
},
None => (String::new(), data_mode.as_str(), RootLayer::DataMode, true),
};
let domestic = domestic_of(config);
let priority = prefs
.saved_priority
.iter()
.filter_map(|id| JdkVendor::parse(id))
.collect::<Vec<_>>();
let order = vendor::order(&priority, domestic);
Effective {
root,
root_mode,
root_layer,
root_unresolved,
order: order.iter().map(|vendor| vendor.display()).collect(),
order_layer: if priority.is_empty() {
OrderLayer::Region
} else {
OrderLayer::Setting
},
domestic,
}
}
/// 现在的地区判定(`cache/region.json` 优先,没有就判一次并写回)。
///
/// 与 `core::download::shared::build` 同源:同一个 `region.json`、同一套信号。
/// 读不到缓存目录(配置路径不像 `<home>/config/launcher.json`)时按**国内**算——
/// 那是 D23 出厂口径,也是唯一一个「不知道还更保守」的取值(镜像先试不会更差)。
#[must_use]
pub fn domestic_of(config: &Path) -> bool {
let Some(home) = home_of(config) else {
return true;
};
let cache = LauncherLayout::new(home).cache();
let now = now_ms();
let path = cache.join("region.json");
if let Some(profile) = load_cached(&path, now) {
return profile.is_domestic();
}
let profile = decide_from(&detect_signals(), now);
let _ = std::fs::create_dir_all(&cache);
let _ = store_cached(&path, &profile);
profile.is_domestic()
}
/// 托管 JDK 该装到哪(下载作业用;与界面上的「生效」同一个函数)。
#[must_use]
pub fn install_root(config: &Path, saved_install_root: &str) -> Option<JavaInstallRoot> {
let store = SettingStore::load(config, launcher_schema());
let data_mode = store
.doc()
.effective_str("core.data_mode")
.and_then(DataMode::parse)
.unwrap_or_else(|| DataMode::default_for(Platform::current()));
let home = home_of(config)?;
let layout = LauncherLayout::new(home);
let override_root = {
let text = saved_install_root.trim();
(!text.is_empty()).then(|| PathBuf::from(text))
};
JavaInstallRoot::resolve(
data_mode,
&layout,
override_root.as_deref(),
Platform::current(),
&ProcessEnv,
)
.ok()
}
/// 下载引擎的缓存根(`<home>/cache`):让 JDK 归档与其它下载共用同一份缓存 / 健康度。
#[must_use]
pub fn cache_root(config: &Path) -> Option<PathBuf> {
home_of(config).map(|home| LauncherLayout::new(home).cache())
}
/// launcher home(`<home>/config/launcher.json` 的上一级再上一级)。
fn home_of(config: &Path) -> Option<&Path> {
config.parent().and_then(Path::parent)
}
/// 落点模式 → 「从哪一层来」。
fn layer_of(mode: LocationMode) -> RootLayer {
match mode {
LocationMode::Custom => RootLayer::Setting,
LocationMode::Standard | LocationMode::Portable => RootLayer::DataMode,
}
}
/// 文档 → 快照。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
Snapshot {
install_root: doc
.effective_str("java.installRoot")
.unwrap_or_default()
.trim()
.to_string(),
auto_install: doc
.effective_bool("java.auto_install")
.unwrap_or(super::prefs::DEFAULT_AUTO_INSTALL),
priority: doc
.effective("java.vendorPriority")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
}
}