Skip to content

Commit 23dd093

Browse files
CaYaturclaude
andauthored
Java provisioning (slice 2): install a Temurin JRE when none fits (#39)
* 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 <noreply@anthropic.com> * Review fix: clearer message when a platform can't auto-install Self-review of slice 2: installJava throws unsupported-platform / unsupported-package on mac/linux (tar.gz) or an odd arch, but the renderer showed one generic "check your connection and try again" toast -- misleading, since retrying never helps. Distinguish it: on an /unsupported/ error, tell the user auto-install isn't available for their OS and to set a path by hand (args.javaInstallUnsupported, EN+TR). Also renamed the local handler off the window.msms.installJava name it shadowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix: persist javaPath on install, not just in form state Advisor review caught the important one: runInstall ended with set('javaPath', ...) which only updates local form state, so the JRE it just downloaded was NOT saved unless the user then clicked Save -- while the toast said "now selected for this server". That silently reintroduces the exact failure this feature exists to fix (the next launch picks the wrong Java again). Persist it immediately with updateServer(id, { java: { ...server.java, javaPath }}) -- spreading the *saved* server.java so an unrelated unsaved form edit isn't committed alongside it. Hardening: stage the download under a fixed name (jre.zip) instead of the API-supplied package name, so an external string is never a path component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7a39dd4 commit 23dd093

10 files changed

Lines changed: 367 additions & 18 deletions

File tree

‎src/main/core/archive.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import AdmZip from 'adm-zip'
2+
import { mkdirSync } from 'node:fs'
3+
import { join, resolve, sep } from 'node:path'
4+
5+
/**
6+
* Reject an archive whose entries would write outside the target folder
7+
* (zip-slip). Every entry is checked before a single file is written.
8+
*/
9+
function assertNoZipSlip(zip: AdmZip, target: string): void {
10+
const root = resolve(target)
11+
for (const entry of zip.getEntries()) {
12+
const p = resolve(join(root, entry.entryName))
13+
if (p !== root && !p.startsWith(root + sep)) throw new Error('unsafe-archive')
14+
}
15+
}
16+
17+
/**
18+
* Extract a .zip into `destDir`, but only after proving every entry stays
19+
* inside it. Mirrors the guard the world importer uses; kept as its own module
20+
* so the Java installer can be reverted without touching worlds.ts.
21+
*/
22+
export function extractZipSafe(zipPath: string, destDir: string): void {
23+
const zip = new AdmZip(zipPath)
24+
assertNoZipSlip(zip, destDir)
25+
mkdirSync(destDir, { recursive: true })
26+
zip.extractAllTo(destDir, true)
27+
}

‎src/main/core/javaProvision.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Install a Temurin (Adoptium) JRE into the app's own directory, so a server
3+
* can run even when the machine has no suitable Java. Opt-in only — the UI asks
4+
* first; nothing here runs on its own.
5+
*
6+
* The download is checksum-verified against the vendor's published SHA256 as it
7+
* streams (net.downloadFile deletes the file and throws on mismatch), extracted
8+
* through the zip-slip guard, and moved into place with an atomic rename inside
9+
* the destination filesystem — an interrupted install can never leave a
10+
* half-tree for the scanner to find and offer.
11+
*/
12+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs'
13+
import { join } from 'node:path'
14+
import {
15+
adoptiumAssetsUrl,
16+
adoptiumTarget,
17+
isZipPackage,
18+
pickAdoptiumPackage,
19+
type AdoptiumAsset,
20+
type JavaInstallProgress
21+
} from '@shared/javaProvision'
22+
import type { JavaInfo } from '@shared/types'
23+
import { downloadFile, httpJson } from './net'
24+
import { extractZipSafe } from './archive'
25+
import { javaExecutable, probeJava } from './java'
26+
import { _resetJavaCache } from './javaScan'
27+
import { resolveBaseDir } from '../paths'
28+
import { log } from '../logger'
29+
30+
const ADOPTIUM_TIMEOUT = 20000
31+
32+
export type ProgressFn = (p: JavaInstallProgress) => void
33+
34+
/** Where provisioned runtimes live — already on javaScan's search path. */
35+
function javaRoot(): string {
36+
return join(resolveBaseDir(), 'java')
37+
}
38+
function runtimeHome(major: number): string {
39+
return join(javaRoot(), `temurin-${major}`)
40+
}
41+
42+
/** The single JRE folder a Temurin zip unpacks to (the one with bin/java). */
43+
function findJavaHome(dir: string): string | null {
44+
if (existsSync(javaExecutable(dir))) return dir
45+
for (const name of readdirSync(dir)) {
46+
const home = join(dir, name)
47+
if (existsSync(javaExecutable(home))) return home
48+
}
49+
return null
50+
}
51+
52+
/**
53+
* Fetch, verify, and adopt a Temurin JRE for `major`; returns the runnable
54+
* java. Throws a stable reason on any failure and leaves nothing behind.
55+
*/
56+
export async function installJava(major: number, onProgress?: ProgressFn): Promise<JavaInfo> {
57+
const target = adoptiumTarget(process.platform, process.arch)
58+
if (!target) throw new Error('unsupported-platform')
59+
onProgress?.({ major, phase: 'resolve' })
60+
61+
const assets = await httpJson<AdoptiumAsset[]>(adoptiumAssetsUrl(major, target), ADOPTIUM_TIMEOUT)
62+
const pkg = pickAdoptiumPackage(assets)
63+
// tar.gz (mac/linux) is not handled in this slice — decline rather than half-do it.
64+
if (!isZipPackage(pkg.name)) throw new Error('unsupported-package')
65+
66+
// Stage inside the destination filesystem so the final move is a real atomic
67+
// rename (a cross-device rename would throw EXDEV).
68+
mkdirSync(javaRoot(), { recursive: true })
69+
const staging = mkdtempSync(join(javaRoot(), '.msms-java-'))
70+
try {
71+
onProgress?.({ major, phase: 'download', percent: 0 })
72+
// A fixed name — never the API-supplied pkg.name — so an external string is
73+
// never used as a path component. adm-zip reads by content, not filename.
74+
const archivePath = join(staging, 'jre.zip')
75+
// No timeout: a JRE is tens of MB and a slow link must not abort mid-stream.
76+
await downloadFile(pkg.link, archivePath, {
77+
sha256: pkg.checksum,
78+
onProgress: (recv, total) =>
79+
onProgress?.({
80+
major,
81+
phase: 'download',
82+
percent: total ? Math.round((recv / total) * 100) : undefined
83+
})
84+
})
85+
86+
onProgress?.({ major, phase: 'extract' })
87+
const unpack = join(staging, 'unpack')
88+
extractZipSafe(archivePath, unpack)
89+
const home = findJavaHome(unpack)
90+
if (!home) throw new Error('no-java-in-archive')
91+
92+
// Confirm it actually runs before adopting it — a corrupt tree is useless.
93+
const probed = await probeJava(javaExecutable(home))
94+
if (!probed) throw new Error('provisioned-java-unprobeable')
95+
96+
const dest = runtimeHome(major)
97+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
98+
renameSync(home, dest)
99+
100+
_resetJavaCache()
101+
onProgress?.({ major, phase: 'done' })
102+
const info = (await probeJava(javaExecutable(dest))) ?? {
103+
path: javaExecutable(dest),
104+
version: probed.version,
105+
major: probed.major
106+
}
107+
log.info(`Installed Temurin JRE ${info.version} (Java ${info.major}) at ${dest}`)
108+
return info
109+
} finally {
110+
rmSync(staging, { recursive: true, force: true })
111+
}
112+
}

‎src/main/ipc/register.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as registry from '../core/serverRegistry'
1010
import { processManager } from '../core/processManager'
1111
import * as audit from '../core/audit'
1212
import * as joins from '../core/joins'
13+
import { installJava } from '../core/javaProvision'
1314
import { getProvider } from '../core/versions'
1415
import { createServer } from '../core/createServer'
1516
import { buildLaunchArgs } from '../core/javaArgs'
@@ -277,6 +278,17 @@ export function registerIpc(): void {
277278
H(IPC.javaResolve, (_e, override: string) =>
278279
detectJava((override && override.trim()) || getConfig().defaults.javaPath)
279280
)
281+
// Downloading + running a JRE is worth a line in the trail, like server.create.
282+
H(IPC.javaInstall, async (_e, major: number) => {
283+
try {
284+
const info = await installJava(major, (p) => broadcast(EVT.javaInstallProgress, p))
285+
audit.record({ source: 'panel', action: 'java.install', actor: 'operator', target: `temurin-${major}`, detail: info.version })
286+
return info
287+
} catch (err) {
288+
audit.record({ source: 'panel', action: 'java.install', actor: 'operator', ok: false, target: `temurin-${major}`, detail: String((err as Error)?.message ?? err) })
289+
throw err
290+
}
291+
})
280292

281293
// --- worlds ---
282294
H(IPC.worldsList, (_e, id: string) => worlds.listWorlds(id))

‎src/main/smoke.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ import * as alertsMod from './core/alerts'
2828
import * as worldsMod from './core/worlds'
2929
import { listJavaInstalls, _resetJavaCache } from './core/javaScan'
3030
import { checkJava, javaRequirement } from '@shared/javaCompat'
31-
import { pickJavaFor, provisionPlan } from '@shared/javaProvision'
31+
import {
32+
pickJavaFor,
33+
provisionPlan,
34+
adoptiumTarget,
35+
adoptiumAssetsUrl,
36+
pickAdoptiumPackage,
37+
isZipPackage,
38+
type AdoptiumAsset
39+
} from '@shared/javaProvision'
3240
import { diffUpdates } from '@shared/mods'
3341
import type { MrVersion } from '@shared/mods'
3442
import { computeUptime, clipSessions } from '@shared/uptime'
@@ -947,6 +955,46 @@ export async function runJavaSmoke(): Promise<void> {
947955
}
948956
console.log('JAVA-SMOKE: provision plan OK (ceiling respected, recommended preferred, snapshots silent)')
949957

958+
// --- 7. Adoptium URL/package shaping (pure; the network fetch is not) ---
959+
const win = adoptiumTarget('win32', 'x64')
960+
if (win?.os !== 'windows' || win.arch !== 'x64') return fail('win32/x64 target wrong')
961+
const macArm = adoptiumTarget('darwin', 'arm64')
962+
if (macArm?.os !== 'mac' || macArm.arch !== 'aarch64') return fail('darwin/arm64 target wrong')
963+
const lin = adoptiumTarget('linux', 'x64')
964+
if (lin?.os !== 'linux' || lin.arch !== 'x64') return fail('linux/x64 target wrong')
965+
if (adoptiumTarget('freebsd' as NodeJS.Platform, 'x64') !== null) return fail('unknown OS must decline')
966+
if (adoptiumTarget('win32', 'ia32') !== null) return fail('unknown arch must decline')
967+
968+
const url = adoptiumAssetsUrl(21, win!)
969+
for (const seg of ['/assets/latest/21/hotspot?', 'architecture=x64', 'image_type=jre', 'os=windows', 'vendor=eclipse']) {
970+
if (!url.includes(seg)) return fail(`assets URL missing "${seg}": ${url}`)
971+
}
972+
973+
const goodAssets: AdoptiumAsset[] = [
974+
{ 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' } } }
975+
]
976+
const pkg = pickAdoptiumPackage(goodAssets)
977+
if (pkg.link !== 'https://x/j.zip' || pkg.checksum !== 'abc123') return fail('package fields not read')
978+
if (!isZipPackage(pkg.name)) return fail('a .zip name should be a zip package')
979+
if (isZipPackage('OpenJDK21U-jre_x64_linux_hotspot_21.0.1_12.tar.gz')) return fail('a .tar.gz must not be a zip')
980+
981+
let threwEmpty = false
982+
try {
983+
pickAdoptiumPackage([])
984+
} catch {
985+
threwEmpty = true
986+
}
987+
if (!threwEmpty) return fail('an empty assets response must throw, not proceed unverified')
988+
989+
let threwNoChecksum = false
990+
try {
991+
pickAdoptiumPackage([{ binary: { package: { link: 'https://x/j.zip', name: 'j.zip' } } }])
992+
} catch {
993+
threwNoChecksum = true
994+
}
995+
if (!threwNoChecksum) return fail('a package with no checksum must throw')
996+
console.log('JAVA-SMOKE: Adoptium shaping OK (os/arch mapped, URL segments, package + checksum guarded)')
997+
950998
console.log('JAVA-SMOKE: PASS')
951999
app.exit(0)
9521000
} catch (e) {

‎src/preload/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const api: MsmsApi = {
7777

7878
listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh),
7979
resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override),
80+
installJava: (major) => ipcRenderer.invoke(IPC.javaInstall, major),
8081

8182
listWorlds: (id) => ipcRenderer.invoke(IPC.worldsList, id),
8283
activateWorld: (id, name) => ipcRenderer.invoke(IPC.worldActivate, id, name),
@@ -146,6 +147,7 @@ const api: MsmsApi = {
146147
onServerStats: (cb) => subscribe(EVT.serverStats, cb),
147148
onServerEvent: (cb) => subscribe(EVT.serverEvent, cb),
148149
onCreateProgress: (cb) => subscribe(EVT.createProgress, cb),
150+
onJavaInstallProgress: (cb) => subscribe(EVT.javaInstallProgress, cb),
149151
onToast: (cb) => subscribe(EVT.toast, cb)
150152
}
151153

‎src/renderer/src/components/ArgsEditor.tsx‎

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
3-
import { Check, Terminal, RefreshCw, AlertTriangle, CheckCircle2, XCircle, Wand2 } from 'lucide-react'
3+
import { Check, Terminal, RefreshCw, AlertTriangle, CheckCircle2, XCircle, Wand2, Download } from 'lucide-react'
44
import { useStore } from '../store'
55
import { checkJava, javaRequirement, javaVerdict } from '@shared/javaCompat'
6-
import { provisionPlan } from '@shared/javaProvision'
6+
import { provisionPlan, type JavaInstallPhase, type JavaInstallProgress } from '@shared/javaProvision'
77
import type { JavaArgsConfig, JavaInfo, JavaInstall, JavaPreset, ServerConfig } from '@shared/types'
88

9+
const PHASE_KEY: Record<JavaInstallPhase, string> = {
10+
resolve: 'args.javaPhaseResolve',
11+
download: 'args.javaPhaseDownload',
12+
extract: 'args.javaPhaseExtract',
13+
done: 'args.javaPhaseDone'
14+
}
15+
916
const PRESETS: JavaPreset[] = ['basic', 'aikars', 'aikars-large', 'proxy', 'custom']
1017

1118
export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
@@ -18,6 +25,8 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
1825
const [scanning, setScanning] = useState(false)
1926
/** What "auto" resolves to — only the main process knows (JAVA_HOME/PATH). */
2027
const [autoJava, setAutoJava] = useState<JavaInfo | null>(null)
28+
/** Non-null while a JRE download/extract is in flight. */
29+
const [installing, setInstalling] = useState<JavaInstallProgress | null>(null)
2130

2231
// Re-sync when switching servers.
2332
useEffect(() => setJava(server.java), [server.id, server.java])
@@ -36,6 +45,12 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
3645
void loadInstalls()
3746
}, [loadInstalls])
3847

48+
// Live progress from the main process while a JRE downloads/extracts.
49+
useEffect(
50+
() => window.msms.onJavaInstallProgress((p) => setInstalling(p.phase === 'done' ? null : p)),
51+
[]
52+
)
53+
3954
// Ask the main process what "auto" (or a hand-typed path) actually resolves
4055
// to, so the default configuration - which nobody picks from the dropdown -
4156
// still gets a verdict. Debounced against typing.
@@ -125,6 +140,29 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
125140
toast('success', 'common.saved')
126141
}
127142

143+
/** Download a compatible JRE, then pin it as this server's Java. */
144+
const runInstall = async (major: number): Promise<void> => {
145+
setInstalling({ major, phase: 'resolve' })
146+
try {
147+
const info = await window.msms.installJava(major)
148+
await loadInstalls(true)
149+
// Persist immediately onto the *saved* config — installing is a heavyweight
150+
// action and the toast says "now selected", so it must survive without a
151+
// separate Save. Spread server.java (not local `java`) so an unrelated
152+
// unsaved form edit isn't silently committed alongside it.
153+
await updateServer(server.id, { java: { ...server.java, javaPath: info.path } })
154+
set('javaPath', info.path)
155+
toast('success', 'args.javaInstalled', { major: info.major })
156+
} catch (e) {
157+
// "unsupported-platform"/"unsupported-package" isn't a transient failure —
158+
// this OS/arch has no .zip build we auto-install, so say that, not "retry".
159+
const msg = String((e as Error)?.message ?? e)
160+
toast('error', /unsupported/.test(msg) ? 'args.javaInstallUnsupported' : 'args.javaInstallFailed')
161+
} finally {
162+
setInstalling(null)
163+
}
164+
}
165+
128166
return (
129167
<div className="panel" style={{ maxWidth: '100%' }}>
130168
<div className="section-title" style={{ marginTop: 0 }}>
@@ -250,19 +288,23 @@ export function ArgsEditor({ server }: { server: ServerConfig }): JSX.Element {
250288
</button>
251289
</div>
252290
)}
253-
{/*
254-
Slice 1 only flags a missing compatible Java when nothing else is
255-
already warning: when `compat` shows the "wrong Java" line, a second
256-
red line saying "and no right one is installed" is just noise. When
257-
no Java resolves at all `compat` is silent, so this is the only
258-
signal. Slice 2 replaces this with an actionable "Install" button.
259-
*/}
260-
{provision?.kind === 'install' && !compat && (
261-
<div className="java-compat bad">
262-
<AlertTriangle size={12} />{' '}
263-
{t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })}
264-
</div>
265-
)}
291+
{provision?.kind === 'install' &&
292+
(installing ? (
293+
<div className="java-provision">
294+
<RefreshCw size={13} className="spin" />
295+
<span className="mono">
296+
{t(PHASE_KEY[installing.phase])}
297+
{installing.percent != null ? ` ${installing.percent}%` : ''}
298+
</span>
299+
</div>
300+
) : (
301+
<div className="java-provision">
302+
<span>{t('args.javaNeedInstall', { major: provision.major, mc: server.mcVersion })}</span>
303+
<button className="btn sm" onClick={() => void runInstall(provision.major)}>
304+
<Download size={13} /> {t('args.javaInstall', { major: provision.major })}
305+
</button>
306+
</div>
307+
))}
266308
</div>
267309
<label className="switch" style={{ marginTop: 24 }}>
268310
<input

‎src/renderer/src/locales/en.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,15 @@ export default {
276276
javaRisky: 'Minecraft {{mc}} was built for Java {{min}}. Java {{java}} often breaks server software of that era; Java {{max}} or lower is safer.',
277277
javaUse: 'Use Java {{major}}',
278278
javaSwitchHint: 'A compatible Java {{major}} is already installed — one click to use it.',
279-
javaNeedInstall: 'No compatible Java {{major}} is installed for Minecraft {{mc}}.'
279+
javaNeedInstall: 'No compatible Java {{major}} is installed for Minecraft {{mc}}.',
280+
javaInstall: 'Install Java {{major}}',
281+
javaInstalled: 'Java {{major}} installed — now selected for this server.',
282+
javaInstallFailed: "Couldn't install Java. Check your connection and try again.",
283+
javaInstallUnsupported: "Auto-install isn't available for your operating system yet — set a Java path by hand.",
284+
javaPhaseResolve: 'Finding the right build…',
285+
javaPhaseDownload: 'Downloading…',
286+
javaPhaseExtract: 'Extracting…',
287+
javaPhaseDone: 'Done'
280288
},
281289
create: {
282290
title: 'Create a new server',

‎src/renderer/src/locales/tr.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,15 @@ const tr: typeof en = {
278278
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.',
279279
javaUse: 'Java {{major}} kullan',
280280
javaSwitchHint: 'Uyumlu bir Java {{major}} zaten kurulu — tek tıkla kullan.',
281-
javaNeedInstall: 'Minecraft {{mc}} için uyumlu Java {{major}} kurulu değil.'
281+
javaNeedInstall: 'Minecraft {{mc}} için uyumlu Java {{major}} kurulu değil.',
282+
javaInstall: 'Java {{major}} kur',
283+
javaInstalled: 'Java {{major}} kuruldu — bu sunucu için seçildi.',
284+
javaInstallFailed: 'Java kurulamadı. Bağlantını kontrol edip tekrar dene.',
285+
javaInstallUnsupported: 'Otomatik kurulum işletim sistemin için henüz yok — Java yolunu elle ayarla.',
286+
javaPhaseResolve: 'Uygun yapı bulunuyor…',
287+
javaPhaseDownload: 'İndiriliyor…',
288+
javaPhaseExtract: 'Çıkarılıyor…',
289+
javaPhaseDone: 'Bitti'
282290
},
283291
create: {
284292
title: 'Yeni sunucu oluştur',

0 commit comments

Comments
 (0)