Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/main/core/archive.ts
Original file line number Diff line number Diff line change
@@ -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)
}
112 changes: 112 additions & 0 deletions src/main/core/javaProvision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* 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<JavaInfo> {
const target = adoptiumTarget(process.platform, process.arch)
if (!target) throw new Error('unsupported-platform')
onProgress?.({ major, phase: 'resolve' })

const assets = await httpJson<AdoptiumAsset[]>(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 })
// A fixed name — never the API-supplied pkg.name — so an external string is
// never used as a path component. adm-zip reads by content, not filename.
const archivePath = join(staging, 'jre.zip')
// 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 })
}
}
12 changes: 12 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down
50 changes: 49 additions & 1 deletion src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -947,6 +955,46 @@ export async function runJavaSmoke(): Promise<void> {
}
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) {
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}

Expand Down
72 changes: 57 additions & 15 deletions src/renderer/src/components/ArgsEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<JavaInstallPhase, string> = {
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 {
Expand All @@ -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<JavaInfo | null>(null)
/** Non-null while a JRE download/extract is in flight. */
const [installing, setInstalling] = useState<JavaInstallProgress | null>(null)

// Re-sync when switching servers.
useEffect(() => setJava(server.java), [server.id, server.java])
Expand All @@ -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.
Expand Down Expand Up @@ -125,6 +140,29 @@ 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 runInstall = async (major: number): Promise<void> => {
setInstalling({ major, phase: 'resolve' })
try {
const info = await window.msms.installJava(major)
await loadInstalls(true)
// Persist immediately onto the *saved* config — installing is a heavyweight
// action and the toast says "now selected", so it must survive without a
// separate Save. Spread server.java (not local `java`) so an unrelated
// unsaved form edit isn't silently committed alongside it.
await updateServer(server.id, { java: { ...server.java, javaPath: info.path } })
set('javaPath', info.path)
toast('success', 'args.javaInstalled', { major: info.major })
} catch (e) {
// "unsupported-platform"/"unsupported-package" isn't a transient failure —
// this OS/arch has no .zip build we auto-install, so say that, not "retry".
const msg = String((e as Error)?.message ?? e)
toast('error', /unsupported/.test(msg) ? 'args.javaInstallUnsupported' : 'args.javaInstallFailed')
} finally {
setInstalling(null)
}
}

return (
<div className="panel" style={{ maxWidth: '100%' }}>
<div className="section-title" style={{ marginTop: 0 }}>
Expand Down Expand Up @@ -250,19 +288,23 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
</button>
</div>
)}
{/*
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 && (
<div className="java-compat bad">
<AlertTriangle size={12} />{' '}
{t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })}
</div>
)}
{provision?.kind === 'install' &&
(installing ? (
<div className="java-provision">
<RefreshCw size={13} className="spin" />
<span className="mono">
{t(PHASE_KEY[installing.phase])}
{installing.percent != null ? ` ${installing.percent}%` : ''}
</span>
</div>
) : (
<div className="java-provision">
<span>{t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })}</span>
<button className="btn sm" onClick={() => void runInstall(provision.major)}>
<Download size={13} /> {t('args.javaInstall', { major: provision.major })}
</button>
</div>
))}
</div>
<label className="switch" style={{ marginTop: 24 }}>
<input
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,15 @@ export default {
javaRisky: 'Minecraft {{mc}} was built for Java {{min}}. Java {{java}} often breaks server software of that era; Java {{max}} or lower is safer.',
javaUse: 'Use Java {{major}}',
javaSwitchHint: 'A compatible Java {{major}} is already installed — one click to use it.',
javaNeedInstall: 'No compatible Java {{major}} is installed for Minecraft {{mc}}.'
javaNeedInstall: 'No compatible Java {{major}} is installed for Minecraft {{mc}}.',
javaInstall: 'Install Java {{major}}',
javaInstalled: 'Java {{major}} installed — now selected for this server.',
javaInstallFailed: "Couldn't install Java. Check your connection and try again.",
javaInstallUnsupported: "Auto-install isn't available for your operating system yet — set a Java path by hand.",
javaPhaseResolve: 'Finding the right build…',
javaPhaseDownload: 'Downloading…',
javaPhaseExtract: 'Extracting…',
javaPhaseDone: 'Done'
},
create: {
title: 'Create a new server',
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,15 @@ const tr: typeof en = {
javaRisky: 'Minecraft {{mc}}, Java {{min}} için yapıldı. Java {{java}} o dönemin sunucu yazılımlarını sık sık bozar; Java {{max}} ve altı daha güvenli.',
javaUse: 'Java {{major}} kullan',
javaSwitchHint: 'Uyumlu bir Java {{major}} zaten kurulu — tek tıkla kullan.',
javaNeedInstall: 'Minecraft {{mc}} için uyumlu Java {{major}} kurulu değil.'
javaNeedInstall: 'Minecraft {{mc}} için uyumlu Java {{major}} kurulu değil.',
javaInstall: 'Java {{major}} kur',
javaInstalled: 'Java {{major}} kuruldu — bu sunucu için seçildi.',
javaInstallFailed: 'Java kurulamadı. Bağlantını kontrol edip tekrar dene.',
javaInstallUnsupported: 'Otomatik kurulum işletim sistemin için henüz yok — Java yolunu elle ayarla.',
javaPhaseResolve: 'Uygun yapı bulunuyor…',
javaPhaseDownload: 'İndiriliyor…',
javaPhaseExtract: 'Çıkarılıyor…',
javaPhaseDone: 'Bitti'
},
create: {
title: 'Yeni sunucu oluştur',
Expand Down
Loading
Loading