installer: 3-2 JDK 자동 설치(Temurin 21) 버튼 추가, 취소 가능

JDK 가 없을 때 사용자가 "자동 설치" 를 눌러 Adoptium Temurin 21 LTS
(Windows x64 zip) 를 받아 %APPDATA% 의 jdk/temurin-21 으로 풀어 사용하도록
한다. 다운로드는 streaming + AbortController 로 묶어, 설치 진행 중 같은
버튼이 "설치 취소" 로 바뀌며 누르면 다운로드를 즉시 중단하고 부분 파일을
정리한다. jdk:detect 후보에 자동 설치 경로도 추가해 다음 실행 시 자동 탐색됨.
This commit is contained in:
2026-05-13 01:57:24 +09:00
parent c621185abc
commit 99ed5076c1
3 changed files with 221 additions and 7 deletions

View File

@@ -211,15 +211,44 @@ function renderSubStep31(host, back, done) {
function renderSubStep32(host, back, done) { function renderSubStep32(host, back, done) {
host.innerHTML = host.innerHTML =
'<h3>3-2. JDK 확인</h3>' + '<h3>3-2. JDK 확인</h3>' +
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' + '<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.</p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' + '<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' + '<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' + '<button class="secondaryBtn" id="auto">자동 탐색</button>' +
'<button class="secondaryBtn" id="install">자동 설치</button></div>' +
'<div class="formMessage" id="msg"></div>' + '<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>' '<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
var input = host.querySelector('#jdkPath') var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg') 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() var detect = await installerApi.detectJdk()
if (detect.found) { if (detect.found) {
input.value = detect.path input.value = detect.path
@@ -227,16 +256,51 @@ function renderSubStep32(host, back, done) {
msg.classList.remove('error') msg.classList.remove('error')
msg.classList.add('success') msg.classList.add('success')
} else { } else {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.' msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.'
msg.classList.remove('success')
msg.classList.add('error') msg.classList.add('error')
} }
}) })
host.querySelector('#pickJdk').addEventListener('click', async function () { pickBtn.addEventListener('click', async function () {
if (installing) return
var picked = await installerApi.pickFolder() var picked = await installerApi.pickFolder()
if (picked) input.value = picked if (picked) input.value = picked
}) })
host.querySelector('#back').addEventListener('click', back) installBtn.addEventListener('click', async function () {
host.querySelector('#next').addEventListener('click', 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()) { if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.' msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.classList.add('error') msg.classList.add('error')
@@ -251,6 +315,8 @@ function renderSubStep32(host, back, done) {
input.value = detect.path input.value = detect.path
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
msg.classList.add('success') msg.classList.add('success')
} else if (!detect.found) {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.'
} }
})() })()
} }

View File

@@ -174,6 +174,8 @@ ipcMain.handle('jdk:detect', async () => {
const candidates: string[] = [] const candidates: string[] = []
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME) if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
if (process.env.JDK_HOME) candidates.push(process.env.JDK_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') candidates.push('C:\\Program Files\\Java')
for (const candidate of candidates) { for (const candidate of candidates) {
@@ -204,6 +206,150 @@ ipcMain.handle('jdk:detect', async () => {
return { found: false, path: '' } 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<void> {
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/<subDir>/<file> 로 해석. * 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file/<subDir>/<file> 로 해석.
*/ */

View File

@@ -15,6 +15,8 @@ const api = {
// 3-2 // 3-2
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'), 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 // 3-3
startServerInstall: (payload: ServerInstallPayload): Promise<void> => startServerInstall: (payload: ServerInstallPayload): Promise<void> =>