r1 复审的两条 P2。
## P2-1:外部实例不再一律 isolated
`versions/<id>/` 里出现 `mods` / `saves` / `config` / `options.txt` 任一 ⇒ `isolated`
(gameDir = `versions/<id>`);否则 ⇒ `shared`(gameDir = 游戏根 `.minecraft`)。
判据只 `exists()`,只读,不改盘;写盘仍然只在用户第一次改设置时(`ensure_profile`)。
- 判据收在 `host/game/discovered.rs::base_mode()`,签名 `(env, id, version_id)`:
**有档案的实例返回全局 `instances.mode`**(D20 原口径,一字不变),自动发现的按痕迹判。
- 派生运行目录的五处(启动 / mods / saves / addon install / addon pack)从传
`global_mode` 改成传 `base_mode` ⇒ 「读到的存档/模组在哪」与磁贴上的 ISOLATION 同一份判据。
- `isolation_of(env, id, version_id)` 多收一个版本 id:实例文件优先,缺省 `base_mode`。
于是懒创建档案(只写 launch 段、isolation 仍为空)之后,磁贴不会从「共用目录」翻回「独立目录」。
- 磁贴/详情:自动发现的实例多一句「运行目录由盘上已有的文件决定」(i18n 中英);
按启动时再弹一条说清**这一次**用哪一边(`pages::instances::discovered_run_toast`,
同文案在屏不重复叠加)。
- `dto::DISCOVERED_ID_PREFIX` / `is_discovered_instance` 成为「自动发现」的公开判据
(不给 `InstanceSummary` 加字段:那要改 24 处结构体字面量,跨 agent / cli)。
## P2-2:空态用例的覆盖面
r1 复审实测:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`(0 实例时反而没按钮),
当时三条用例全绿。现在:
- `detail_empty` 拆成 `empty_not_selected` / `empty_no_instances` 两个函数,分支各自可断言;
- `the_cta_lives_only_in_the_no_instances_branch`:CTA 只在「一个都没有」那一支、
「没选中」那一支必须是 `Vec::new()`,且分派必须写成 `if has_instances {`(取反即红);
- `the_page_asks_the_empty_state_with_the_negated_row_count`:两条调用点(宽窗 + 窄窗)
都必须传 `!block.rows.is_empty()`,条数也钉住(多一条即红)。
- 两个变异已自证:传参取反 ⇒ 后一条红;CTA 挪支 ⇒ 前一条红(红原文见交付报告)。
## P3
- `java::target_major()` 改读并集快照:只有自动发现实例的用户原来会拿到默认 JDK17 而不是
该版本要求的 JDK(`profiles.json` 是空的)。有档案时顺序与旧写法一致。
- `instances()` 全量扫仍在 UI 线程——本轮不动,刷新时机与代价写进报告(塔另开一条)。
## 结构
`host/game.rs` 394 → 286 行:自动发现那一簇(痕迹判据 / 扫出来的版本 / 合成视图)搬到
`host/game/discovered.rs`(138 行),只为守 400 行硬线;`game::base_mode` 仍是同一条路径。
## 门禁
app-iced lib 973 passed / presenter all-targets 386 passed / i18n 36 passed /
fmt 0 / clippy -D warnings 0。
312 lines
12 KiB
Rust
312 lines
12 KiB
Rust
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
|
||
//!
|
||
//! 回退链的两态:
|
||
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
|
||
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
|
||
//!
|
||
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
|
||
|
||
use unlucky_core::game::java::probe::JavaOrigin;
|
||
use unlucky_core::game::java::source::ChainLog;
|
||
use unlucky_core::game::java::{self, JdkVendor};
|
||
|
||
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||
|
||
use super::env::HostEnv;
|
||
use super::job::{spawn, HostTask, JobSender};
|
||
|
||
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
|
||
pub const DEFAULT_JDK_MAJOR: u32 = 17;
|
||
|
||
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
|
||
///
|
||
/// 「第一个实例」读的是**并集快照**([`super::game::instances`]),不是 `profiles.json`:
|
||
/// M273 之后只有自动发现实例的用户(别的启动器装好了版本、ULL 一个档案都没有)完全合法,
|
||
/// 按旧写法他们会拿到默认 JDK17 而不是那个版本真正要求的 JDK,而 `profiles.json` 里
|
||
/// 恰好什么都没有。有档案时顺序与旧写法一致(档案在前),所以既有行为不变。
|
||
pub fn target_major(env: &HostEnv) -> u32 {
|
||
super::game::instances(env)
|
||
.first()
|
||
.map(|instance| {
|
||
java::requirement_for_version(&env.game, &instance.version_id)
|
||
.minimum
|
||
.max(8)
|
||
})
|
||
.unwrap_or(DEFAULT_JDK_MAJOR)
|
||
}
|
||
|
||
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
|
||
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
|
||
let domestic = super::engine::domestic(env);
|
||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||
let table = unlucky_core::download::MirrorTable::builtin();
|
||
order
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, vendor)| {
|
||
let id = vendor.id();
|
||
JavaVendorView {
|
||
id: id.to_string(),
|
||
display: vendor.display().to_string(),
|
||
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
|
||
majors: vendor
|
||
.hint_majors()
|
||
.iter()
|
||
.map(u32::to_string)
|
||
.collect::<Vec<_>>()
|
||
.join("/"),
|
||
sources: sources_of(&table, *vendor),
|
||
installed: installed_vendors.iter().any(|known| known == id),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 一家的源清单(与 CLI 展示同形)。
|
||
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
|
||
match vendor {
|
||
JdkVendor::Temurin => table
|
||
.adoptium
|
||
.iter()
|
||
.filter(|mirror| mirror.enabled)
|
||
.map(|mirror| mirror.name.clone())
|
||
.chain(std::iter::once(
|
||
"GitHub 官方(国内套 gh-proxy)".to_string(),
|
||
))
|
||
.collect(),
|
||
JdkVendor::Oracle => table
|
||
.oracle_mirrors()
|
||
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
|
||
.collect(),
|
||
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
|
||
JdkVendor::Zulu => table
|
||
.zulu()
|
||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||
.unwrap_or_default(),
|
||
JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS
|
||
.iter()
|
||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
|
||
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
|
||
let roots = super::engine::java_scan_roots(env);
|
||
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
|
||
candidates
|
||
.iter()
|
||
.map(|runtime| JavaRuntimeView {
|
||
binary: runtime.binary.display().to_string(),
|
||
version: runtime
|
||
.version
|
||
.clone()
|
||
.unwrap_or_else(|| "版本未知".to_string()),
|
||
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
|
||
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
|
||
origin: runtime.origin.category().to_string(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 托管目录(展示「装到哪」)。
|
||
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
|
||
super::engine::java_scan_roots(env)
|
||
.iter()
|
||
.map(|root| root.display().to_string())
|
||
.collect()
|
||
}
|
||
|
||
/// 计划链(按生效顺序铺「待尝试」)。
|
||
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
|
||
let domestic = super::engine::domestic(env);
|
||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||
order
|
||
.iter()
|
||
.map(|vendor| JavaChainStep {
|
||
vendor: vendor.id().to_string(),
|
||
outcome: JavaChainOutcome::Pending,
|
||
detail: String::new(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 真实链(`ChainLog` → DTO)。
|
||
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
|
||
log.attempts()
|
||
.iter()
|
||
.map(|attempt| JavaChainStep {
|
||
vendor: attempt.vendor.id().to_string(),
|
||
outcome: if attempt.ok {
|
||
JavaChainOutcome::Hit
|
||
} else if attempt.detail.contains("不支持") {
|
||
JavaChainOutcome::Skip
|
||
} else {
|
||
JavaChainOutcome::Failed
|
||
},
|
||
detail: attempt.detail.clone(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 整页快照。
|
||
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
|
||
let runtimes = runtimes(env);
|
||
let installed_vendors: Vec<String> = runtimes
|
||
.iter()
|
||
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
|
||
.map(|runtime| runtime.vendor.clone())
|
||
.collect();
|
||
let vendor_rows = vendors(env, &installed_vendors);
|
||
let domestic = super::engine::domestic(env);
|
||
let priority_set = !super::engine::java_priority(env).is_empty();
|
||
let chain = if chain.is_empty() {
|
||
planned_chain(env)
|
||
} else {
|
||
chain
|
||
};
|
||
JavaPageView {
|
||
region: if domestic { "domestic" } else { "global" }.to_string(),
|
||
priority_source: if priority_set {
|
||
"java.vendorPriority"
|
||
} else {
|
||
"region_default"
|
||
}
|
||
.to_string(),
|
||
order_text: vendor_rows
|
||
.iter()
|
||
.map(|vendor| vendor.display.clone())
|
||
.collect::<Vec<_>>()
|
||
.join(" → "),
|
||
vendors: vendor_rows,
|
||
chain,
|
||
runtimes,
|
||
managed_roots: managed_roots(env),
|
||
busy,
|
||
message: message.to_string(),
|
||
}
|
||
}
|
||
|
||
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
|
||
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
|
||
spawn("java-install", move || {
|
||
let major = target_major(&env);
|
||
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
|
||
busy.busy = true;
|
||
let _ = tx.send(HostTask::Java(Box::new(busy)));
|
||
|
||
let engine = super::engine::engine(&env);
|
||
let root = match super::engine::java_install_root(&env) {
|
||
Ok(root) => root,
|
||
Err(reason) => {
|
||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||
&env,
|
||
Vec::new(),
|
||
false,
|
||
&format!("解析 Java 安装位置失败:{reason}"),
|
||
))));
|
||
return;
|
||
}
|
||
};
|
||
let request = match vendor {
|
||
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
|
||
.with_priority(super::engine::java_priority(&env)),
|
||
None => {
|
||
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
|
||
}
|
||
};
|
||
let result =
|
||
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
|
||
match result {
|
||
Ok(installed) => {
|
||
let message = if installed.reused() {
|
||
format!(
|
||
"Java {}({})已就绪:{}",
|
||
installed.major,
|
||
installed.vendor.id(),
|
||
installed.root.display()
|
||
)
|
||
} else {
|
||
format!(
|
||
"已安装 Java {}({})→ {} · {} 个文件",
|
||
installed.major,
|
||
installed.vendor.display(),
|
||
installed.root.display(),
|
||
installed.files
|
||
)
|
||
};
|
||
let chain = chain_steps(&installed.chain);
|
||
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
|
||
}
|
||
Err(error) => {
|
||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||
&env,
|
||
Vec::new(),
|
||
false,
|
||
&format!("Java 安装失败:{error}"),
|
||
))));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
|
||
pub fn install_hint(env: &HostEnv) -> String {
|
||
let major = target_major(env);
|
||
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn planned_chain_covers_four_vendors() {
|
||
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let env = HostEnv::at_root(&dir);
|
||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||
let chain = planned_chain(&env);
|
||
assert_eq!(chain.len(), 4);
|
||
assert!(chain
|
||
.iter()
|
||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn chain_log_maps_to_steps() {
|
||
let mut log = ChainLog::new();
|
||
log.skip(
|
||
JdkVendor::Microsoft,
|
||
"不支持 Java 8(microsoft 只发 11/17/21/25)",
|
||
);
|
||
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
|
||
let steps = chain_steps(&log);
|
||
assert_eq!(steps.len(), 2);
|
||
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
|
||
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
|
||
}
|
||
|
||
#[test]
|
||
fn page_reports_region_and_priority_source() {
|
||
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let env = HostEnv::at_root(&dir);
|
||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||
std::fs::write(
|
||
env.launcher_config(),
|
||
r#"{"java":{"vendorPriority":["zulu"]}}"#,
|
||
)
|
||
.expect("写配置");
|
||
let page = page(&env, Vec::new(), false, "");
|
||
assert_eq!(page.priority_source, "java.vendorPriority");
|
||
assert_eq!(page.vendors[0].id, "zulu");
|
||
assert_eq!(page.vendors[0].rank, 1);
|
||
assert!(page.order_text.contains("Azul Zulu"));
|
||
assert_eq!(page.vendors.len(), 4);
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
}
|