Build installer and management site from spec
This commit is contained in:
597
src/installer/main.ts
Normal file
597
src/installer/main.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
import { BrowserWindow, app, dialog, ipcMain, shell } from 'electron'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import AdmZip from 'adm-zip'
|
||||
import upnp from 'nat-upnp'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { createApp } from '../server/app'
|
||||
import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
|
||||
import { PackDefinition, RootManifest } from '../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MANIFEST_URL = process.env.INSTALLER_MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||
const DEFAULT_SITE_URL = process.env.MANAGEMENT_SITE_URL ?? 'http://127.0.0.1:3000'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentInstall: InstallSessionState | null = null
|
||||
let configEditorServer: ReturnType<express.Express['listen']> | null = null
|
||||
let pendingEulaResolver: (() => void) | null = null
|
||||
|
||||
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
|
||||
mainWindow?.webContents.send('installer:log', {
|
||||
message,
|
||||
tone,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
function hasHangul(input: string): boolean {
|
||||
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input)
|
||||
}
|
||||
|
||||
function ensureWindow() {
|
||||
if (mainWindow != null) {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
const appRoot = app.getAppPath()
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
webPreferences: {
|
||||
preload: path.join(appRoot, 'dist', 'installer', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.loadFile(path.join(appRoot, 'installer', 'index.html'))
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
async function startManagementSite(): Promise<void> {
|
||||
const appInstance = await createApp()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = appInstance.listen(3000, '127.0.0.1', () => resolve())
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseDirectory(): Promise<string | null> {
|
||||
const targetWindow = ensureWindow()
|
||||
const result = await dialog.showOpenDialog(targetWindow, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return result.filePaths[0]
|
||||
}
|
||||
|
||||
async function detectJdkCandidates(): Promise<string[]> {
|
||||
const candidates = new Set<string>()
|
||||
const envCandidates = [process.env.JAVA_HOME, process.env.JDK_HOME]
|
||||
|
||||
for (const candidate of envCandidates) {
|
||||
if (candidate != null && candidate.trim().length > 0) {
|
||||
candidates.add(candidate.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const javaRoot = 'C:\\Program Files\\Java'
|
||||
if (fs.existsSync(javaRoot)) {
|
||||
const entries = await fsp.readdir(javaRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
candidates.add(path.join(javaRoot, entry.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates]
|
||||
}
|
||||
|
||||
function resolveJavaExecutable(jdkPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? path.join(jdkPath, 'bin', 'java.exe')
|
||||
: path.join(jdkPath, 'bin', 'java')
|
||||
}
|
||||
|
||||
async function detectJdk(): Promise<{ detected: string | null; candidates: string[] }> {
|
||||
const candidates = await detectJdkCandidates()
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(resolveJavaExecutable(candidate))) {
|
||||
return {
|
||||
detected: candidate,
|
||||
candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detected: null,
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUrl: string; packDefinition: PackDefinition; packName: string }> {
|
||||
const manifestResponse = await fetch(payload.manifestUrl)
|
||||
if (!manifestResponse.ok) {
|
||||
throw new Error(`manifest.json 요청 실패: ${manifestResponse.status}`)
|
||||
}
|
||||
|
||||
const rootManifest = await manifestResponse.json() as RootManifest
|
||||
const packEntry = rootManifest.packs.find((entry) => entry.file === payload.pack.file)
|
||||
if (packEntry == null) {
|
||||
throw new Error('선택한 서버팩을 manifest.json에서 찾지 못했습니다.')
|
||||
}
|
||||
|
||||
const manifestUrl = new URL(payload.manifestUrl)
|
||||
const packUrl = new URL(`/manifest/${payload.pack.file}.json`, manifestUrl.origin)
|
||||
const packResponse = await fetch(packUrl)
|
||||
if (!packResponse.ok) {
|
||||
throw new Error(`서버팩 JSON 요청 실패: ${packResponse.status}`)
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: manifestUrl.origin,
|
||||
packDefinition: await packResponse.json() as PackDefinition,
|
||||
packName: packEntry.name
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
|
||||
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
|
||||
|
||||
if (systemRamMb >= pack.clientRecommendedRam) {
|
||||
return {
|
||||
selected: pack.clientRecommendedRam,
|
||||
warning: null
|
||||
}
|
||||
}
|
||||
|
||||
if (systemRamMb >= pack.clientMinRam) {
|
||||
return {
|
||||
selected: pack.clientMinRam,
|
||||
warning: '권장 램보다 시스템 램이 적어 최소 램으로 설치합니다.'
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.')
|
||||
}
|
||||
|
||||
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition): Promise<void> {
|
||||
const appData = process.env.APPDATA
|
||||
if (appData == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const launcherProfilesPath = path.join(appData, '.minecraft', 'launcher_profiles.json')
|
||||
const gameDir = path.join(installRoot, '.mc_custom')
|
||||
const selectedRam = resolveClientRamMb(pack).selected
|
||||
|
||||
let payload: Record<string, unknown> = {}
|
||||
if (fs.existsSync(launcherProfilesPath)) {
|
||||
try {
|
||||
payload = JSON.parse(await fsp.readFile(launcherProfilesPath, 'utf8')) as Record<string, unknown>
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = typeof payload.profiles === 'object' && payload.profiles != null
|
||||
? payload.profiles as Record<string, unknown>
|
||||
: {}
|
||||
|
||||
profiles[packName] = {
|
||||
created: new Date().toISOString(),
|
||||
gameDir,
|
||||
icon: 'Grass',
|
||||
javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`,
|
||||
lastVersionId: pack.mcVersion,
|
||||
name: packName,
|
||||
type: 'custom'
|
||||
}
|
||||
|
||||
payload.profiles = profiles
|
||||
await fsp.mkdir(path.dirname(launcherProfilesPath), { recursive: true })
|
||||
await fsp.writeFile(launcherProfilesPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, installRoot: string): Promise<string> {
|
||||
const customRoot = path.join(installRoot, '.mc_custom')
|
||||
await fsp.mkdir(customRoot, { recursive: true })
|
||||
|
||||
if (pack.files != null && pack.files.length > 0) {
|
||||
for (const filePath of pack.files) {
|
||||
const targetUrl = new URL(`/file/${filePath}`, baseUrl).toString()
|
||||
const targetPath = path.join(customRoot, filePath)
|
||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
sendLog(`다운로드: ${filePath}`)
|
||||
const response = await fetch(targetUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`파일 다운로드 실패: ${filePath}`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await fsp.writeFile(targetPath, Buffer.from(arrayBuffer))
|
||||
}
|
||||
return customRoot
|
||||
}
|
||||
|
||||
const archiveUrl = new URL(`/file/${pack.packPath}`, baseUrl).toString()
|
||||
sendLog(`다운로드: ${archiveUrl}`)
|
||||
const response = await fetch(archiveUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`서버팩 다운로드 실패: ${response.status}`)
|
||||
}
|
||||
|
||||
const archiveBuffer = Buffer.from(await response.arrayBuffer())
|
||||
const archivePath = path.join(customRoot, 'server-pack.zip')
|
||||
await fsp.writeFile(archivePath, archiveBuffer)
|
||||
sendLog('압축 해제 시작')
|
||||
const zip = new AdmZip(archivePath)
|
||||
zip.extractAllTo(customRoot, true)
|
||||
await fsp.unlink(archivePath)
|
||||
return customRoot
|
||||
}
|
||||
|
||||
async function waitForEulaAcceptance(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
pendingEulaResolver = resolve
|
||||
sendLog('Minecraft EULA 동의가 필요합니다.', 'warn')
|
||||
mainWindow?.webContents.send('installer:log', { action: 'eula-required' })
|
||||
})
|
||||
}
|
||||
|
||||
async function findServerJar(root: string): Promise<string | null> {
|
||||
const entries = await fsp.readdir(root, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (entry.isFile() && entry.name.endsWith('.jar')) {
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function startInstall(payload: InstallPayload): Promise<{ nextStep: number; warning: string | null }> {
|
||||
if (hasHangul(payload.installPath)) {
|
||||
throw new Error('설치 경로에 한글이 포함되어 있습니다.')
|
||||
}
|
||||
if (!fs.existsSync(resolveJavaExecutable(payload.jdkPath))) {
|
||||
throw new Error('유효한 JDK 경로를 지정해야 합니다.')
|
||||
}
|
||||
|
||||
const packMeta = await fetchPackManifest({
|
||||
manifestUrl: payload.manifestUrl,
|
||||
pack: {
|
||||
file: payload.packFile,
|
||||
name: payload.packFile
|
||||
}
|
||||
})
|
||||
|
||||
const ramDecision = resolveClientRamMb(packMeta.packDefinition)
|
||||
sendLog('서버팩 정보 확인 완료')
|
||||
if (ramDecision.warning != null) {
|
||||
sendLog(ramDecision.warning, 'warn')
|
||||
}
|
||||
|
||||
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
|
||||
const eulaPath = path.join(extractedRoot, 'eula.txt')
|
||||
if (fs.existsSync(eulaPath)) {
|
||||
await fsp.unlink(eulaPath)
|
||||
}
|
||||
|
||||
currentInstall = {
|
||||
manifestUrl: payload.manifestUrl,
|
||||
packFile: payload.packFile,
|
||||
installPath: payload.installPath,
|
||||
jdkPath: payload.jdkPath,
|
||||
packDefinition: packMeta.packDefinition,
|
||||
packName: packMeta.packName,
|
||||
extractedRoot
|
||||
}
|
||||
|
||||
await waitForEulaAcceptance()
|
||||
await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8')
|
||||
sendLog('EULA 동의 반영 완료', 'success')
|
||||
await writeLauncherProfile(packMeta.packName, payload.installPath, packMeta.packDefinition)
|
||||
sendLog('Minecraft 런처 프로필 추가 완료', 'success')
|
||||
|
||||
return {
|
||||
nextStep: 5,
|
||||
warning: ramDecision.warning
|
||||
}
|
||||
}
|
||||
|
||||
async function stopConfigEditor(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (configEditorServer == null) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
configEditorServer.close(() => {
|
||||
configEditorServer = null
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseProperties(raw: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (line.trim().length === 0 || line.trim().startsWith('#') || !line.includes('=')) {
|
||||
continue
|
||||
}
|
||||
const [key, ...valueParts] = line.split('=')
|
||||
result[key.trim()] = valueParts.join('=').trim()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function stringifyProperties(values: Record<string, string>): string {
|
||||
return Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function openConfigEditor(): Promise<string> {
|
||||
if (currentInstall == null) {
|
||||
throw new Error('설치된 서버팩 정보가 없습니다.')
|
||||
}
|
||||
|
||||
await stopConfigEditor()
|
||||
const editorApp = express()
|
||||
editorApp.use(express.urlencoded({ extended: true }))
|
||||
|
||||
editorApp.get('/', async (_req, res) => {
|
||||
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
||||
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
||||
const serverPropertiesRaw = fs.existsSync(serverPropertiesPath)
|
||||
? await fsp.readFile(serverPropertiesPath, 'utf8')
|
||||
: ''
|
||||
const parsed = parseProperties(serverPropertiesRaw)
|
||||
const bukkitRaw = fs.existsSync(bukkitPath)
|
||||
? await fsp.readFile(bukkitPath, 'utf8')
|
||||
: ''
|
||||
|
||||
res.send(`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>서버 설정 편집기</title>
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;background:#101412;color:#f5f5f5;margin:0;padding:24px;}
|
||||
.wrap{max-width:960px;margin:0 auto;}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||
label{display:block;font-weight:700;margin-bottom:6px;}
|
||||
input,textarea{width:100%;padding:10px;border-radius:10px;border:1px solid #2c3a34;background:#171d1a;color:#fff;}
|
||||
textarea{min-height:220px;}
|
||||
button{padding:12px 18px;border:none;border-radius:999px;background:#f0bf57;color:#111;font-weight:700;cursor:pointer;}
|
||||
.card{background:#171d1a;padding:18px;border-radius:18px;margin-bottom:18px;}
|
||||
.desc{color:#b9c0bc;font-size:14px;margin-bottom:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>서버 설정 편집기</h1>
|
||||
<form method="post" action="/save">
|
||||
<div class="card">
|
||||
<h2>server.properties</h2>
|
||||
<div class="desc">메모장 대신 주요 항목을 설명과 함께 수정합니다.</div>
|
||||
<div class="grid">
|
||||
<div><label>MOTD</label><input name="motd" value="${parsed.motd ?? ''}" /></div>
|
||||
<div><label>서버 포트</label><input name="server-port" value="${parsed['server-port'] ?? '25565'}" /></div>
|
||||
<div><label>최대 인원수</label><input name="max-players" value="${parsed['max-players'] ?? '20'}" /></div>
|
||||
<div><label>화이트리스트</label><input name="white-list" value="${parsed['white-list'] ?? 'false'}" /></div>
|
||||
<div><label>PvP</label><input name="pvp" value="${parsed.pvp ?? 'true'}" /></div>
|
||||
<div><label>온라인 모드</label><input name="online-mode" value="${parsed['online-mode'] ?? 'true'}" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>bukkit.yml</h2>
|
||||
<div class="desc">기타 Bukkit 설정은 전체 파일을 직접 수정합니다.</div>
|
||||
<textarea name="bukkitRaw">${bukkitRaw.replace(/</g, '<')}</textarea>
|
||||
</div>
|
||||
<button type="submit">적용</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
editorApp.post('/save', async (req, res) => {
|
||||
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
|
||||
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
|
||||
const values = {
|
||||
motd: String(req.body.motd ?? ''),
|
||||
'server-port': String(req.body['server-port'] ?? '25565'),
|
||||
'max-players': String(req.body['max-players'] ?? '20'),
|
||||
'white-list': String(req.body['white-list'] ?? 'false'),
|
||||
pvp: String(req.body.pvp ?? 'true'),
|
||||
'online-mode': String(req.body['online-mode'] ?? 'true')
|
||||
}
|
||||
await fsp.writeFile(serverPropertiesPath, `${stringifyProperties(values)}\n`, 'utf8')
|
||||
await fsp.writeFile(bukkitPath, String(req.body.bukkitRaw ?? ''), 'utf8')
|
||||
res.redirect('/')
|
||||
})
|
||||
|
||||
const url = await new Promise<string>((resolve) => {
|
||||
configEditorServer = editorApp.listen(0, '127.0.0.1', () => {
|
||||
const address = configEditorServer?.address()
|
||||
const port = typeof address === 'object' && address != null ? address.port : 0
|
||||
resolve(`http://127.0.0.1:${port}`)
|
||||
})
|
||||
})
|
||||
|
||||
currentInstall.configEditorUrl = url
|
||||
await shell.openExternal(url)
|
||||
sendLog(`설정 편집기 실행: ${url}`, 'success')
|
||||
return url
|
||||
}
|
||||
|
||||
async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> {
|
||||
if (currentInstall == null) {
|
||||
throw new Error('설치된 서버 정보가 없습니다.')
|
||||
}
|
||||
|
||||
const port = 25565
|
||||
const client = upnp.createClient()
|
||||
|
||||
const externalIpResponse = await fetch('https://api.ipify.org?format=json')
|
||||
const externalIp = externalIpResponse.ok
|
||||
? (await externalIpResponse.json() as { ip?: string }).ip
|
||||
: undefined
|
||||
|
||||
const mapPort = () => new Promise<void>((resolve, reject) => {
|
||||
client.portMapping(
|
||||
{
|
||||
public: port,
|
||||
private: port,
|
||||
ttl: 3600
|
||||
},
|
||||
(error) => {
|
||||
if (error != null) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await mapPort()
|
||||
currentInstall.externalPort = port
|
||||
currentInstall.externalAddress = externalIp
|
||||
const message = externalIp != null
|
||||
? `UPnP 자동 개방 성공: ${externalIp}:${port}`
|
||||
: `UPnP 자동 개방 성공: 포트 ${port}`
|
||||
sendLog(message, 'success')
|
||||
return {
|
||||
status: 'success',
|
||||
message,
|
||||
externalAddress: externalIp
|
||||
}
|
||||
} catch {
|
||||
const message = '자동 포트 개방 실패. 직접 포트포워딩을 해주세요.'
|
||||
sendLog(message, 'error')
|
||||
return {
|
||||
status: 'manual',
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openInstalledFolder(): Promise<void> {
|
||||
if (currentInstall == null) {
|
||||
return
|
||||
}
|
||||
await shell.openPath(currentInstall.installPath)
|
||||
}
|
||||
|
||||
async function createDesktopShortcut(enabled: boolean): Promise<void> {
|
||||
if (!enabled || currentInstall == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktopDir = path.join(os.homedir(), 'Desktop')
|
||||
const shortcutPath = path.join(desktopDir, `${currentInstall.packName} 서버 실행.cmd`)
|
||||
const serverJar = await findServerJar(currentInstall.extractedRoot)
|
||||
if (serverJar == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const contents = [
|
||||
'@echo off',
|
||||
`cd /d "${currentInstall.extractedRoot}"`,
|
||||
`"${resolveJavaExecutable(currentInstall.jdkPath)}" -Xms${currentInstall.packDefinition.serverMinRam}M -Xmx${currentInstall.packDefinition.serverMaxRam}M -jar "${serverJar}" nogui`
|
||||
].join('\r\n')
|
||||
|
||||
await fsp.writeFile(shortcutPath, contents, 'utf8')
|
||||
}
|
||||
|
||||
async function runServer(enabled: boolean): Promise<void> {
|
||||
if (!enabled || currentInstall == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const serverJar = await findServerJar(currentInstall.extractedRoot)
|
||||
if (serverJar == null) {
|
||||
sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn')
|
||||
return
|
||||
}
|
||||
|
||||
const javaExec = resolveJavaExecutable(currentInstall.jdkPath)
|
||||
execFile(javaExec, [
|
||||
`-Xms${currentInstall.packDefinition.serverMinRam}M`,
|
||||
`-Xmx${currentInstall.packDefinition.serverMaxRam}M`,
|
||||
'-jar',
|
||||
serverJar,
|
||||
'nogui'
|
||||
], {
|
||||
cwd: currentInstall.extractedRoot
|
||||
})
|
||||
sendLog('서버 실행 시작', 'success')
|
||||
}
|
||||
|
||||
function bindIpcHandlers() {
|
||||
ipcMain.handle('installer:get-defaults', async () => ({
|
||||
manifestUrl: DEFAULT_MANIFEST_URL,
|
||||
managementSiteUrl: DEFAULT_SITE_URL
|
||||
}))
|
||||
|
||||
ipcMain.handle('installer:load-packs', async (_event, manifestUrl: string) => {
|
||||
const response = await fetch(manifestUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`manifest.json 요청 실패: ${response.status}`)
|
||||
}
|
||||
return response.json() as Promise<RootManifest>
|
||||
})
|
||||
|
||||
ipcMain.handle('installer:choose-directory', async () => chooseDirectory())
|
||||
ipcMain.handle('installer:detect-jdk', async () => detectJdk())
|
||||
ipcMain.handle('installer:choose-jdk', async () => chooseDirectory())
|
||||
ipcMain.handle('installer:start-install', async (_event, payload: InstallPayload) => startInstall(payload))
|
||||
ipcMain.handle('installer:accept-eula', async () => {
|
||||
pendingEulaResolver?.()
|
||||
pendingEulaResolver = null
|
||||
})
|
||||
ipcMain.handle('installer:open-config-editor', async () => openConfigEditor())
|
||||
ipcMain.handle('installer:configure-port', async () => configurePort())
|
||||
ipcMain.handle('installer:open-folder', async () => openInstalledFolder())
|
||||
ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled))
|
||||
ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled))
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await startManagementSite()
|
||||
bindIpcHandlers()
|
||||
ensureWindow()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', async () => {
|
||||
await stopConfigEditor()
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
17
src/installer/preload.ts
Normal file
17
src/installer/preload.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('installerApi', {
|
||||
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
|
||||
loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl),
|
||||
chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'),
|
||||
detectJdk: () => ipcRenderer.invoke('installer:detect-jdk'),
|
||||
chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'),
|
||||
startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload),
|
||||
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
|
||||
openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'),
|
||||
configurePort: () => ipcRenderer.invoke('installer:configure-port'),
|
||||
openFolder: () => ipcRenderer.invoke('installer:open-folder'),
|
||||
createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled),
|
||||
runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled),
|
||||
onLog: (handler: (entry: unknown) => void) => ipcRenderer.on('installer:log', (_event, entry) => handler(entry))
|
||||
})
|
||||
30
src/installer/types.ts
Normal file
30
src/installer/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PackDefinition, PackListEntry } from '../shared/types'
|
||||
|
||||
export interface InstallerDefaults {
|
||||
manifestUrl: string
|
||||
}
|
||||
|
||||
export interface SelectedPackPayload {
|
||||
manifestUrl: string
|
||||
pack: PackListEntry
|
||||
}
|
||||
|
||||
export interface InstallPayload {
|
||||
manifestUrl: string
|
||||
packFile: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
}
|
||||
|
||||
export interface InstallSessionState {
|
||||
manifestUrl: string
|
||||
packFile: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
packDefinition: PackDefinition
|
||||
packName: string
|
||||
extractedRoot: string
|
||||
externalAddress?: string
|
||||
externalPort?: number
|
||||
configEditorUrl?: string
|
||||
}
|
||||
Reference in New Issue
Block a user