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

@@ -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<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> 로 해석.
*/