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:
@@ -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> 로 해석.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user