diff --git a/installer/renderer.js b/installer/renderer.js
index aa243e9..dc2f786 100644
--- a/installer/renderer.js
+++ b/installer/renderer.js
@@ -211,15 +211,44 @@ function renderSubStep31(host, back, done) {
function renderSubStep32(host, back, done) {
host.innerHTML =
'
3-2. JDK 확인
' +
- 'JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.
' +
+ 'JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.
' +
'' +
'' +
- '
' +
+ '' +
+ '' +
'' +
''
var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg')
- host.querySelector('#auto').addEventListener('click', async function () {
+ var installBtn = host.querySelector('#install')
+ var autoBtn = host.querySelector('#auto')
+ var pickBtn = host.querySelector('#pickJdk')
+ var nextBtn = host.querySelector('#next')
+ var installing = false
+
+ function setInstallingUi(on) {
+ installing = on
+ if (on) {
+ installBtn.textContent = '설치 취소'
+ installBtn.classList.remove('secondaryBtn')
+ installBtn.classList.add('dangerBtn')
+ autoBtn.disabled = true
+ pickBtn.disabled = true
+ nextBtn.disabled = true
+ input.disabled = true
+ } else {
+ installBtn.textContent = '자동 설치'
+ installBtn.classList.remove('dangerBtn')
+ installBtn.classList.add('secondaryBtn')
+ autoBtn.disabled = false
+ pickBtn.disabled = false
+ nextBtn.disabled = false
+ input.disabled = false
+ }
+ }
+
+ autoBtn.addEventListener('click', async function () {
+ if (installing) return
var detect = await installerApi.detectJdk()
if (detect.found) {
input.value = detect.path
@@ -227,16 +256,51 @@ function renderSubStep32(host, back, done) {
msg.classList.remove('error')
msg.classList.add('success')
} else {
- msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.'
+ msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.'
+ msg.classList.remove('success')
msg.classList.add('error')
}
})
- host.querySelector('#pickJdk').addEventListener('click', async function () {
+ pickBtn.addEventListener('click', async function () {
+ if (installing) return
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
- host.querySelector('#back').addEventListener('click', back)
- host.querySelector('#next').addEventListener('click', function () {
+ installBtn.addEventListener('click', async function () {
+ if (installing) {
+ // 진행 중이면 취소.
+ msg.textContent = 'JDK 설치 취소 요청 중...'
+ msg.classList.remove('success', 'error')
+ await installerApi.cancelJdkInstall()
+ return
+ }
+ setInstallingUi(true)
+ msg.classList.remove('success', 'error')
+ msg.textContent = 'Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)'
+ try {
+ var result = await installerApi.installJdk()
+ if (result.ok && result.path) {
+ input.value = result.path
+ state.serverInstall.jdk = result.path
+ msg.textContent = 'JDK 자동 설치 완료: ' + result.path
+ msg.classList.add('success')
+ } else {
+ msg.textContent = 'JDK 설치 ' + (result.message === '취소됨' ? '취소됨' : '실패: ' + (result.message || '알 수 없는 오류'))
+ msg.classList.add('error')
+ }
+ } catch (err) {
+ msg.textContent = 'JDK 설치 오류: ' + (err && err.message ? err.message : err)
+ msg.classList.add('error')
+ } finally {
+ setInstallingUi(false)
+ }
+ })
+ host.querySelector('#back').addEventListener('click', function () {
+ if (installing) return
+ back()
+ })
+ nextBtn.addEventListener('click', function () {
+ if (installing) return
if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.classList.add('error')
@@ -251,6 +315,8 @@ function renderSubStep32(host, back, done) {
input.value = detect.path
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
msg.classList.add('success')
+ } else if (!detect.found) {
+ msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.'
}
})()
}
diff --git a/src/installer/main.ts b/src/installer/main.ts
index a603695..72877a3 100644
--- a/src/installer/main.ts
+++ b/src/installer/main.ts
@@ -174,6 +174,8 @@ ipcMain.handle('jdk:detect', async () => {
const candidates: string[] = []
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
+ // 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함.
+ candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21'))
candidates.push('C:\\Program Files\\Java')
for (const candidate of candidates) {
@@ -204,6 +206,150 @@ ipcMain.handle('jdk:detect', async () => {
return { found: false, path: '' }
})
+// ── JDK 자동 설치(Temurin 21, 취소 가능) ──────────────────────────────
+interface JdkInstallState {
+ controller: AbortController | null
+ destDir: string | null
+ inProgress: boolean
+}
+const jdkInstall: JdkInstallState = { controller: null, destDir: null, inProgress: false }
+
+function downloadStream(
+ url: string,
+ target: string,
+ signal: AbortSignal,
+ onProgress?: (loaded: number, total: number) => void
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error('취소되었습니다.'))
+ return
+ }
+ const u = new URL(url)
+ const transport = u.protocol === 'https:' ? https : http
+ const fileStream = fs.createWriteStream(target)
+ let settled = false
+ const onAbort = () => {
+ try { req.destroy(new Error('취소되었습니다.')) } catch { /* noop */ }
+ try { fileStream.close() } catch { /* noop */ }
+ }
+ signal.addEventListener('abort', onAbort)
+ const req = transport.get(u, { timeout: 120000 }, (res) => {
+ const sc = res.statusCode ?? 0
+ if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
+ const redirect = res.headers.location
+ if (redirect) {
+ res.resume()
+ fileStream.close(() => {
+ signal.removeEventListener('abort', onAbort)
+ downloadStream(new URL(redirect, u).toString(), target, signal, onProgress).then(resolve, reject)
+ })
+ return
+ }
+ }
+ if (sc >= 400) {
+ res.resume()
+ fileStream.close(() => {
+ signal.removeEventListener('abort', onAbort)
+ if (!settled) { settled = true; reject(new Error(`HTTP ${sc}`)) }
+ })
+ return
+ }
+ const total = Number(res.headers['content-length'] ?? 0)
+ let loaded = 0
+ res.on('data', (chunk: Buffer) => {
+ loaded += chunk.length
+ if (onProgress) onProgress(loaded, total)
+ })
+ res.pipe(fileStream)
+ fileStream.on('finish', () => fileStream.close(() => {
+ signal.removeEventListener('abort', onAbort)
+ if (!settled) { settled = true; resolve() }
+ }))
+ res.on('error', (err) => {
+ signal.removeEventListener('abort', onAbort)
+ if (!settled) { settled = true; reject(err) }
+ })
+ })
+ req.on('error', (err) => {
+ signal.removeEventListener('abort', onAbort)
+ fileStream.close(() => {})
+ if (!settled) { settled = true; reject(err) }
+ })
+ req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
+ })
+}
+
+ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
+ if (jdkInstall.inProgress) {
+ return { ok: false, message: '이미 JDK 설치가 진행 중입니다.' }
+ }
+ jdkInstall.inProgress = true
+ const controller = new AbortController()
+ jdkInstall.controller = controller
+ const tmpRoot = path.join(getAppDataDir(), 'jdk-cache')
+ await fsp.mkdir(tmpRoot, { recursive: true })
+ const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`)
+ const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21')
+ jdkInstall.destDir = destDir
+ try {
+ // Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트.
+ const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk'
+ sendLog('JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...')
+ let lastPctReported = -1
+ await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
+ if (total > 0) {
+ const pct = Math.floor((loaded / total) * 100)
+ if (pct >= lastPctReported + 5) {
+ lastPctReported = pct
+ sendLog(`JDK 다운로드: ${pct}% (${Math.floor(loaded / 1024 / 1024)}MB / ${Math.floor(total / 1024 / 1024)}MB)`)
+ }
+ }
+ })
+ if (controller.signal.aborted) throw new Error('취소되었습니다.')
+
+ sendLog('JDK 압축 해제 중...')
+ await fsp.rm(destDir, { recursive: true, force: true })
+ await fsp.mkdir(destDir, { recursive: true })
+ await extractZip(tempZip, { dir: destDir })
+
+ // Adoptium ZIP 은 jdk-21.x.x+... 하위 폴더로 감싸져 있다. 그 폴더의 bin/java.exe 가 실제 자바.
+ let javaRoot = destDir
+ const inner = await fsp.readdir(destDir, { withFileTypes: true })
+ const innerJdk = inner.find((entry) => entry.isDirectory() && /^jdk-/i.test(entry.name))
+ if (innerJdk) javaRoot = path.join(destDir, innerJdk.name)
+ const javaExe = path.join(javaRoot, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
+ if (!fs.existsSync(javaExe)) {
+ throw new Error(`설치 후 java 실행 파일을 찾지 못했습니다: ${javaExe}`)
+ }
+
+ sendLog(`JDK 자동 설치 완료: ${javaRoot}`)
+ return { ok: true, path: javaRoot }
+ } catch (err) {
+ const msg = (err as Error).message || String(err)
+ if (controller.signal.aborted || /취소/.test(msg)) {
+ sendLog('JDK 설치가 취소되었습니다.')
+ try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ }
+ return { ok: false, message: '취소됨' }
+ }
+ sendLog(`JDK 설치 실패: ${msg}`)
+ return { ok: false, message: msg }
+ } finally {
+ try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ }
+ jdkInstall.inProgress = false
+ jdkInstall.controller = null
+ jdkInstall.destDir = null
+ }
+})
+
+ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => {
+ if (jdkInstall.controller) {
+ jdkInstall.controller.abort()
+ sendLog('JDK 설치 취소 요청을 보냈습니다.')
+ }
+ return { ok: true }
+})
+
/**
* 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file// 로 해석.
*/
diff --git a/src/installer/preload.ts b/src/installer/preload.ts
index acdc9a6..c77b503 100644
--- a/src/installer/preload.ts
+++ b/src/installer/preload.ts
@@ -15,6 +15,8 @@ const api = {
// 3-2
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
+ installJdk: (): Promise<{ ok: boolean; path?: string; message?: string }> => ipcRenderer.invoke('jdk:install'),
+ cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'),
// 3-3
startServerInstall: (payload: ServerInstallPayload): Promise =>