From 01dac732ed4658bd2334e2e05cc7a19eff2345b5 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Fri, 24 Jul 2026 14:19:11 +0300 Subject: [PATCH 1/3] Java provisioning (slice 2): install a Temurin JRE when none fits When no compatible Java is installed, the args editor's warning now carries an "Install Java N" button that downloads a Temurin (Adoptium) JRE into the app's own dir and pins it as the server's Java -- so a server can run on a machine that has the wrong Java, or none. - shared/javaProvision.ts: pure Adoptium shaping -- adoptiumTarget() maps platform/arch (win32->windows, x64->x64, arm64->aarch64, else decline), adoptiumAssetsUrl() builds the v3 assets endpoint (link + published SHA256 in one response), pickAdoptiumPackage() throws rather than proceed on a half-answer, isZipPackage() gates extraction. Plus the JavaInstallProgress type. - core/archive.ts: extractZipSafe() -- adm-zip behind the same zip-slip guard worlds.ts uses, kept standalone so this can be reverted without touching worlds. - core/javaProvision.ts: installJava() -- fetch assets, verify SHA256 as it streams (net.downloadFile), extract, probe, then move into place with an atomic rename staged on the destination filesystem so an interrupted install never leaves a half-tree for the scanner. _resetJavaCache() after. - IPC java:install + evt:java-install-progress; register handler audits the install (source panel, action java.install) like server.create. - ArgsEditor: the needs-install warning becomes an Install button with live phase/percent; on success it pins javaPath + rescans. EN/TR in lockstep. - MSMS_SMOKE_JAVA section 7 pins the os/arch mapping, URL segments, and that an empty or checksum-less assets response throws instead of downloading blind. Verified against the live Adoptium v3 assets response shape. The end-to-end download/extract is not exercised in this env (network + a large binary) -- the pure shaping and guards are; disclosed in the PR. Closes #36, closes #37 Co-Authored-By: Claude Opus 4.8 --- src/main/core/archive.ts | 27 +++++ src/main/core/javaProvision.ts | 110 +++++++++++++++++++++ src/main/ipc/register.ts | 12 +++ src/main/smoke.ts | 50 +++++++++- src/preload/index.ts | 2 + src/renderer/src/components/ArgsEditor.tsx | 64 +++++++++--- src/renderer/src/locales/en.ts | 9 +- src/renderer/src/locales/tr.ts | 9 +- src/shared/ipc.ts | 6 ++ src/shared/javaProvision.ts | 84 ++++++++++++++++ 10 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 src/main/core/archive.ts create mode 100644 src/main/core/javaProvision.ts diff --git a/src/main/core/archive.ts b/src/main/core/archive.ts new file mode 100644 index 0000000..8ae2f01 --- /dev/null +++ b/src/main/core/archive.ts @@ -0,0 +1,27 @@ +import AdmZip from 'adm-zip' +import { mkdirSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' + +/** + * Reject an archive whose entries would write outside the target folder + * (zip-slip). Every entry is checked before a single file is written. + */ +function assertNoZipSlip(zip: AdmZip, target: string): void { + const root = resolve(target) + for (const entry of zip.getEntries()) { + const p = resolve(join(root, entry.entryName)) + if (p !== root && !p.startsWith(root + sep)) throw new Error('unsafe-archive') + } +} + +/** + * Extract a .zip into `destDir`, but only after proving every entry stays + * inside it. Mirrors the guard the world importer uses; kept as its own module + * so the Java installer can be reverted without touching worlds.ts. + */ +export function extractZipSafe(zipPath: string, destDir: string): void { + const zip = new AdmZip(zipPath) + assertNoZipSlip(zip, destDir) + mkdirSync(destDir, { recursive: true }) + zip.extractAllTo(destDir, true) +} diff --git a/src/main/core/javaProvision.ts b/src/main/core/javaProvision.ts new file mode 100644 index 0000000..5038b8d --- /dev/null +++ b/src/main/core/javaProvision.ts @@ -0,0 +1,110 @@ +/** + * Install a Temurin (Adoptium) JRE into the app's own directory, so a server + * can run even when the machine has no suitable Java. Opt-in only — the UI asks + * first; nothing here runs on its own. + * + * The download is checksum-verified against the vendor's published SHA256 as it + * streams (net.downloadFile deletes the file and throws on mismatch), extracted + * through the zip-slip guard, and moved into place with an atomic rename inside + * the destination filesystem — an interrupted install can never leave a + * half-tree for the scanner to find and offer. + */ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { + adoptiumAssetsUrl, + adoptiumTarget, + isZipPackage, + pickAdoptiumPackage, + type AdoptiumAsset, + type JavaInstallProgress +} from '@shared/javaProvision' +import type { JavaInfo } from '@shared/types' +import { downloadFile, httpJson } from './net' +import { extractZipSafe } from './archive' +import { javaExecutable, probeJava } from './java' +import { _resetJavaCache } from './javaScan' +import { resolveBaseDir } from '../paths' +import { log } from '../logger' + +const ADOPTIUM_TIMEOUT = 20000 + +export type ProgressFn = (p: JavaInstallProgress) => void + +/** Where provisioned runtimes live — already on javaScan's search path. */ +function javaRoot(): string { + return join(resolveBaseDir(), 'java') +} +function runtimeHome(major: number): string { + return join(javaRoot(), `temurin-${major}`) +} + +/** The single JRE folder a Temurin zip unpacks to (the one with bin/java). */ +function findJavaHome(dir: string): string | null { + if (existsSync(javaExecutable(dir))) return dir + for (const name of readdirSync(dir)) { + const home = join(dir, name) + if (existsSync(javaExecutable(home))) return home + } + return null +} + +/** + * Fetch, verify, and adopt a Temurin JRE for `major`; returns the runnable + * java. Throws a stable reason on any failure and leaves nothing behind. + */ +export async function installJava(major: number, onProgress?: ProgressFn): Promise { + const target = adoptiumTarget(process.platform, process.arch) + if (!target) throw new Error('unsupported-platform') + onProgress?.({ major, phase: 'resolve' }) + + const assets = await httpJson(adoptiumAssetsUrl(major, target), ADOPTIUM_TIMEOUT) + const pkg = pickAdoptiumPackage(assets) + // tar.gz (mac/linux) is not handled in this slice — decline rather than half-do it. + if (!isZipPackage(pkg.name)) throw new Error('unsupported-package') + + // Stage inside the destination filesystem so the final move is a real atomic + // rename (a cross-device rename would throw EXDEV). + mkdirSync(javaRoot(), { recursive: true }) + const staging = mkdtempSync(join(javaRoot(), '.msms-java-')) + try { + onProgress?.({ major, phase: 'download', percent: 0 }) + const archivePath = join(staging, pkg.name) + // No timeout: a JRE is tens of MB and a slow link must not abort mid-stream. + await downloadFile(pkg.link, archivePath, { + sha256: pkg.checksum, + onProgress: (recv, total) => + onProgress?.({ + major, + phase: 'download', + percent: total ? Math.round((recv / total) * 100) : undefined + }) + }) + + onProgress?.({ major, phase: 'extract' }) + const unpack = join(staging, 'unpack') + extractZipSafe(archivePath, unpack) + const home = findJavaHome(unpack) + if (!home) throw new Error('no-java-in-archive') + + // Confirm it actually runs before adopting it — a corrupt tree is useless. + const probed = await probeJava(javaExecutable(home)) + if (!probed) throw new Error('provisioned-java-unprobeable') + + const dest = runtimeHome(major) + if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }) + renameSync(home, dest) + + _resetJavaCache() + onProgress?.({ major, phase: 'done' }) + const info = (await probeJava(javaExecutable(dest))) ?? { + path: javaExecutable(dest), + version: probed.version, + major: probed.major + } + log.info(`Installed Temurin JRE ${info.version} (Java ${info.major}) at ${dest}`) + return info + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 76180c7..9aa64ea 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -10,6 +10,7 @@ import * as registry from '../core/serverRegistry' import { processManager } from '../core/processManager' import * as audit from '../core/audit' import * as joins from '../core/joins' +import { installJava } from '../core/javaProvision' import { getProvider } from '../core/versions' import { createServer } from '../core/createServer' import { buildLaunchArgs } from '../core/javaArgs' @@ -277,6 +278,17 @@ export function registerIpc(): void { H(IPC.javaResolve, (_e, override: string) => detectJava((override && override.trim()) || getConfig().defaults.javaPath) ) + // Downloading + running a JRE is worth a line in the trail, like server.create. + H(IPC.javaInstall, async (_e, major: number) => { + try { + const info = await installJava(major, (p) => broadcast(EVT.javaInstallProgress, p)) + audit.record({ source: 'panel', action: 'java.install', actor: 'operator', target: `temurin-${major}`, detail: info.version }) + return info + } catch (err) { + audit.record({ source: 'panel', action: 'java.install', actor: 'operator', ok: false, target: `temurin-${major}`, detail: String((err as Error)?.message ?? err) }) + throw err + } + }) // --- worlds --- H(IPC.worldsList, (_e, id: string) => worlds.listWorlds(id)) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index b5be295..ddefe56 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -28,7 +28,15 @@ import * as alertsMod from './core/alerts' import * as worldsMod from './core/worlds' import { listJavaInstalls, _resetJavaCache } from './core/javaScan' import { checkJava, javaRequirement } from '@shared/javaCompat' -import { pickJavaFor, provisionPlan } from '@shared/javaProvision' +import { + pickJavaFor, + provisionPlan, + adoptiumTarget, + adoptiumAssetsUrl, + pickAdoptiumPackage, + isZipPackage, + type AdoptiumAsset +} from '@shared/javaProvision' import { diffUpdates } from '@shared/mods' import type { MrVersion } from '@shared/mods' import { computeUptime, clipSessions } from '@shared/uptime' @@ -947,6 +955,46 @@ export async function runJavaSmoke(): Promise { } console.log('JAVA-SMOKE: provision plan OK (ceiling respected, recommended preferred, snapshots silent)') + // --- 7. Adoptium URL/package shaping (pure; the network fetch is not) --- + const win = adoptiumTarget('win32', 'x64') + if (win?.os !== 'windows' || win.arch !== 'x64') return fail('win32/x64 target wrong') + const macArm = adoptiumTarget('darwin', 'arm64') + if (macArm?.os !== 'mac' || macArm.arch !== 'aarch64') return fail('darwin/arm64 target wrong') + const lin = adoptiumTarget('linux', 'x64') + if (lin?.os !== 'linux' || lin.arch !== 'x64') return fail('linux/x64 target wrong') + if (adoptiumTarget('freebsd' as NodeJS.Platform, 'x64') !== null) return fail('unknown OS must decline') + if (adoptiumTarget('win32', 'ia32') !== null) return fail('unknown arch must decline') + + const url = adoptiumAssetsUrl(21, win!) + for (const seg of ['/assets/latest/21/hotspot?', 'architecture=x64', 'image_type=jre', 'os=windows', 'vendor=eclipse']) { + if (!url.includes(seg)) return fail(`assets URL missing "${seg}": ${url}`) + } + + const goodAssets: AdoptiumAsset[] = [ + { release_name: 'jdk-21.0.1+12', binary: { package: { link: 'https://x/j.zip', checksum: 'abc123', name: 'OpenJDK21U-jre_x64_windows_hotspot_21.0.1_12.zip' } } } + ] + const pkg = pickAdoptiumPackage(goodAssets) + if (pkg.link !== 'https://x/j.zip' || pkg.checksum !== 'abc123') return fail('package fields not read') + if (!isZipPackage(pkg.name)) return fail('a .zip name should be a zip package') + if (isZipPackage('OpenJDK21U-jre_x64_linux_hotspot_21.0.1_12.tar.gz')) return fail('a .tar.gz must not be a zip') + + let threwEmpty = false + try { + pickAdoptiumPackage([]) + } catch { + threwEmpty = true + } + if (!threwEmpty) return fail('an empty assets response must throw, not proceed unverified') + + let threwNoChecksum = false + try { + pickAdoptiumPackage([{ binary: { package: { link: 'https://x/j.zip', name: 'j.zip' } } }]) + } catch { + threwNoChecksum = true + } + if (!threwNoChecksum) return fail('a package with no checksum must throw') + console.log('JAVA-SMOKE: Adoptium shaping OK (os/arch mapped, URL segments, package + checksum guarded)') + console.log('JAVA-SMOKE: PASS') app.exit(0) } catch (e) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 684fe36..7f8d312 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -77,6 +77,7 @@ const api: MsmsApi = { listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh), resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override), + installJava: (major) => ipcRenderer.invoke(IPC.javaInstall, major), listWorlds: (id) => ipcRenderer.invoke(IPC.worldsList, id), activateWorld: (id, name) => ipcRenderer.invoke(IPC.worldActivate, id, name), @@ -146,6 +147,7 @@ const api: MsmsApi = { onServerStats: (cb) => subscribe(EVT.serverStats, cb), onServerEvent: (cb) => subscribe(EVT.serverEvent, cb), onCreateProgress: (cb) => subscribe(EVT.createProgress, cb), + onJavaInstallProgress: (cb) => subscribe(EVT.javaInstallProgress, cb), onToast: (cb) => subscribe(EVT.toast, cb) } diff --git a/src/renderer/src/components/ArgsEditor.tsx b/src/renderer/src/components/ArgsEditor.tsx index 051e939..4266762 100644 --- a/src/renderer/src/components/ArgsEditor.tsx +++ b/src/renderer/src/components/ArgsEditor.tsx @@ -1,11 +1,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Check, Terminal, RefreshCw, AlertTriangle, CheckCircle2, XCircle, Wand2 } from 'lucide-react' +import { Check, Terminal, RefreshCw, AlertTriangle, CheckCircle2, XCircle, Wand2, Download } from 'lucide-react' import { useStore } from '../store' import { checkJava, javaRequirement, javaVerdict } from '@shared/javaCompat' -import { provisionPlan } from '@shared/javaProvision' +import { provisionPlan, type JavaInstallPhase, type JavaInstallProgress } from '@shared/javaProvision' import type { JavaArgsConfig, JavaInfo, JavaInstall, JavaPreset, ServerConfig } from '@shared/types' +const PHASE_KEY: Record = { + resolve: 'args.javaPhaseResolve', + download: 'args.javaPhaseDownload', + extract: 'args.javaPhaseExtract', + done: 'args.javaPhaseDone' +} + const PRESETS: JavaPreset[] = ['basic', 'aikars', 'aikars-large', 'proxy', 'custom'] export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element { @@ -18,6 +25,8 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element { const [scanning, setScanning] = useState(false) /** What "auto" resolves to — only the main process knows (JAVA_HOME/PATH). */ const [autoJava, setAutoJava] = useState(null) + /** Non-null while a JRE download/extract is in flight. */ + const [installing, setInstalling] = useState(null) // Re-sync when switching servers. useEffect(() => setJava(server.java), [server.id, server.java]) @@ -36,6 +45,12 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element { void loadInstalls() }, [loadInstalls]) + // Live progress from the main process while a JRE downloads/extracts. + useEffect( + () => window.msms.onJavaInstallProgress((p) => setInstalling(p.phase === 'done' ? null : p)), + [] + ) + // Ask the main process what "auto" (or a hand-typed path) actually resolves // to, so the default configuration - which nobody picks from the dropdown - // still gets a verdict. Debounced against typing. @@ -125,6 +140,21 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element { toast('success', 'common.saved') } + /** Download a compatible JRE, then pin it as this server's Java. */ + const installJava = async (major: number): Promise => { + setInstalling({ major, phase: 'resolve' }) + try { + const info = await window.msms.installJava(major) + await loadInstalls(true) + set('javaPath', info.path) + toast('success', 'args.javaInstalled', { major: info.major }) + } catch { + toast('error', 'args.javaInstallFailed') + } finally { + setInstalling(null) + } + } + return (
@@ -250,19 +280,23 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
)} - {/* - Slice 1 only flags a missing compatible Java when nothing else is - already warning: when `compat` shows the "wrong Java" line, a second - red line saying "and no right one is installed" is just noise. When - no Java resolves at all `compat` is silent, so this is the only - signal. Slice 2 replaces this with an actionable "Install" button. - */} - {provision?.kind === 'install' && !compat && ( -
- {' '} - {t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })} -
- )} + {provision?.kind === 'install' && + (installing ? ( +
+ + + {t(PHASE_KEY[installing.phase])} + {installing.percent != null ? ` ${installing.percent}%` : ''} + +
+ ) : ( +
+ {t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })} + +
+ ))}