feat: detect installed Java runtime

This commit is contained in:
emil28092005
2026-09-06 00:47:01 +03:00
parent d8a06bcee6
commit f4cfe601fd
5 changed files with 127 additions and 1 deletions
+1
View File
@@ -7,6 +7,7 @@
- интерактивный интерфейс профилей и настроек;
- Tauri 2 native shell с безопасной командой `native_host`;
- сохранение памяти профиля в локальном каталоге данных приложения;
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
- адаптивное окно для Windows, Linux и macOS;
- базовая структура, в которую будут добавлены манифесты, проверка файлов,
Java 21 и запуск Minecraft.
+89
View File
@@ -0,0 +1,89 @@
use serde::Serialize;
use std::{env, path::PathBuf, process::Command};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JavaInstallation {
pub executable: String,
pub major: u8,
pub version: String,
}
/// Finds a usable Java runtime without modifying the machine.
///
/// The launcher will later use this result to decide whether Java 21 needs to
/// be bundled. We deliberately check JAVA_HOME first: it is explicit and makes
/// development installations predictable across all supported platforms.
pub fn detect() -> Option<JavaInstallation> {
candidates().into_iter().find_map(check_candidate)
}
fn candidates() -> Vec<PathBuf> {
let executable = if cfg!(target_os = "windows") {
"java.exe"
} else {
"java"
};
let mut candidates = Vec::new();
if let Some(java_home) = env::var_os("JAVA_HOME") {
candidates.push(PathBuf::from(java_home).join("bin").join(executable));
}
candidates.push(PathBuf::from(executable));
candidates
}
fn check_candidate(candidate: PathBuf) -> Option<JavaInstallation> {
let output = Command::new(&candidate).arg("-version").output().ok()?;
if !output.status.success() {
return None;
}
let source = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let version = parse_version(&source)?;
let major = parse_major(&version)?;
Some(JavaInstallation {
executable: candidate.display().to_string(),
major,
version,
})
}
fn parse_version(source: &str) -> Option<String> {
let marker = "version \"";
let start = source.find(marker)? + marker.len();
let remainder = &source[start..];
let end = remainder.find('"')?;
Some(remainder[..end].to_owned())
}
fn parse_major(version: &str) -> Option<u8> {
let mut segments = version.split('.');
let first = segments.next()?.parse::<u8>().ok()?;
if first == 1 {
segments.next()?.parse::<u8>().ok()
} else {
Some(first)
}
}
#[cfg(test)]
mod tests {
use super::{parse_major, parse_version};
#[test]
fn parses_modern_java_version() {
let output = "openjdk version \"21.0.8\" 2025-07-15";
assert_eq!(parse_version(output).as_deref(), Some("21.0.8"));
assert_eq!(parse_major("21.0.8"), Some(21));
}
#[test]
fn parses_legacy_java_version() {
assert_eq!(parse_major("1.8.0_452"), Some(8));
}
}
+8
View File
@@ -1,3 +1,4 @@
mod java;
mod manifest;
mod profile;
mod settings;
@@ -29,6 +30,12 @@ fn native_host(app: AppHandle) -> Result<NativeHost, String> {
})
}
/// Detects an existing Java installation. This is read-only and never downloads Java.
#[tauri::command]
fn detect_java() -> Option<java::JavaInstallation> {
java::detect()
}
/// Validates an untrusted profile manifest before any file is downloaded.
#[tauri::command]
fn validate_manifest(manifest_json: String) -> Result<(), String> {
@@ -97,6 +104,7 @@ pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
native_host,
detect_java,
validate_manifest,
inspect_profile,
sync_profile,
+26 -1
View File
@@ -45,6 +45,12 @@ type NativeSettings = {
memoryMb: number
}
type JavaInstallation = {
executable: string
major: number
version: string
}
const servers: Server[] = [
{
id: 'aoc',
@@ -76,6 +82,7 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false)
const [ram, setRam] = useState(6)
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
useEffect(() => {
if (progress === null) return
@@ -96,6 +103,9 @@ function App() {
invoke<NativeSettings>('load_settings')
.then((settings) => setRam(settings.memoryMb / 1024))
.catch(() => undefined)
invoke<JavaInstallation | null>('detect_java')
.then(setJava)
.catch(() => setJava(null))
}, [])
const updateRam = (memoryGb: number) => {
@@ -248,7 +258,22 @@ function App() {
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</label>
<button className="setting-row"><span><FolderOpen />Папка игры</span><ChevronRight /></button>
<div className="setting-row static">
<span><FolderOpen />Папка игры</span>
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
</div>
<div className="setting-row static">
<span><Wrench />Java</span>
<small>
{java === undefined
? 'Проверяем…'
: java && java.major >= 21
? `Java ${java.major} найдена`
: java
? `Нужна Java 21 · найдена ${java.major}`
: 'Java не найдена'}
</small>
</div>
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
<div className="drawer-note">
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
+3
View File
@@ -141,6 +141,9 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.setting-row span { display: flex; align-items: center; gap: 11px; font-size: 12px; }
.setting-row svg { width: 17px; color: #677068; }
.setting-row:hover { color: white; }
.setting-row.static { cursor: default; }
.setting-row.static:hover { color: #abb3ac; }
.setting-row small { color: #7f8a80; font-size: 11px; }
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
@media (max-width: 1160px) {