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:
@@ -211,15 +211,44 @@ function renderSubStep31(host, back, done) {
|
||||
function renderSubStep32(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<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>' +
|
||||
'<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="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
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 를 받아 설치합니다.'
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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> 로 해석.
|
||||
*/
|
||||
|
||||
@@ -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<void> =>
|
||||
|
||||
Reference in New Issue
Block a user