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 =>