Scaffold resource pack installer entry
Add a second Electron entry under src/installer-rp/ + installer-rp/ launched by `npm run installer:rp`. It is structurally separate from the music quiz installer (own tsconfig, own preload, own renderer), shares the existing styles via a relative link, and exposes a three-step UI: pick a 음악퀴즈, run the install, then a 완료 page that can open the resourcepacks folder or quit. The install IPC handler currently scaffolds the flow per docs/add.md (yt-dlp prep → music download → image normalize → zip build → place at %appdata%/.minecraft/resourcepacks/) but the actual work is still TODO. Cancel/cleanup of %appdata%/.mc_custom/.temp/ is wired up so that future iterations can plug each step in without rewiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
27
installer-rp/index.html
Normal file
27
installer-rp/index.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>마인크래프트 음악퀴즈 리소스팩 간편설치기</title>
|
||||||
|
<link rel="stylesheet" href="../installer/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="appHeader">
|
||||||
|
<h1>마인크래프트 음악퀴즈 리소스팩 간편설치기</h1>
|
||||||
|
<ol class="stepIndicator" id="stepIndicator">
|
||||||
|
<li data-step="1">1. 음악퀴즈</li>
|
||||||
|
<li data-step="2">2. 설치</li>
|
||||||
|
<li data-step="3">3. 완료</li>
|
||||||
|
</ol>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="pageHost"></main>
|
||||||
|
|
||||||
|
<aside class="logViewer" id="logViewer" hidden>
|
||||||
|
<header><h2>설치 로그</h2><button type="button" id="logToggle">접기</button></header>
|
||||||
|
<pre id="logBody"></pre>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
184
installer-rp/renderer.js
Normal file
184
installer-rp/renderer.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const api = window.rpInstaller
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
packs: [],
|
||||||
|
selectedKey: null,
|
||||||
|
installing: false,
|
||||||
|
installed: false,
|
||||||
|
resourcepackPath: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHost = document.getElementById('pageHost')
|
||||||
|
const stepIndicator = document.getElementById('stepIndicator')
|
||||||
|
const logViewer = document.getElementById('logViewer')
|
||||||
|
const logBody = document.getElementById('logBody')
|
||||||
|
const logToggle = document.getElementById('logToggle')
|
||||||
|
|
||||||
|
logToggle.addEventListener('click', function () {
|
||||||
|
logViewer.classList.toggle('collapsed')
|
||||||
|
if (logViewer.classList.contains('collapsed')) {
|
||||||
|
logViewer.style.height = '36px'
|
||||||
|
logToggle.textContent = '펼치기'
|
||||||
|
} else {
|
||||||
|
logViewer.style.height = ''
|
||||||
|
logToggle.textContent = '접기'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.onLog(function (line) {
|
||||||
|
logViewer.hidden = false
|
||||||
|
logBody.textContent += line + '\n'
|
||||||
|
logBody.scrollTop = logBody.scrollHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
function setActiveStep(step) {
|
||||||
|
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||||
|
var index = Number(item.getAttribute('data-step'))
|
||||||
|
item.classList.remove('active', 'done')
|
||||||
|
if (index < step) item.classList.add('done')
|
||||||
|
if (index === step) item.classList.add('active')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPage() { pageHost.innerHTML = '' }
|
||||||
|
|
||||||
|
// ── 1단계: 음악퀴즈 선택 ────────────────────────────
|
||||||
|
function renderStep1() {
|
||||||
|
setActiveStep(1)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>1단계. 음악퀴즈 선택</h2>' +
|
||||||
|
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
|
||||||
|
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
var listEl = section.querySelector('#packList')
|
||||||
|
var nextBtn = section.querySelector('#next')
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
listEl.innerHTML = ''
|
||||||
|
if (state.packs.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.packs.forEach(function (pack) {
|
||||||
|
var card = document.createElement('button')
|
||||||
|
card.type = 'button'
|
||||||
|
card.className = 'choiceCard'
|
||||||
|
if (state.selectedKey === pack.key) card.classList.add('active')
|
||||||
|
card.innerHTML =
|
||||||
|
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
||||||
|
'<small>음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장</small>'
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
state.selectedKey = pack.key
|
||||||
|
nextBtn.disabled = false
|
||||||
|
renderList()
|
||||||
|
})
|
||||||
|
listEl.appendChild(card)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextBtn.addEventListener('click', function () {
|
||||||
|
if (!state.selectedKey) return
|
||||||
|
api.selectPack(state.selectedKey).then(function () {
|
||||||
|
renderStep2()
|
||||||
|
}).catch(function (err) {
|
||||||
|
alert(err.message || '선택 실패')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
api.loadPacks().then(function (packs) {
|
||||||
|
state.packs = packs || []
|
||||||
|
renderList()
|
||||||
|
}).catch(function (err) {
|
||||||
|
listEl.innerHTML = '<p class="formMessage error">목록 로드 실패: ' + escapeHtml(err.message || '') + '</p>'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2단계: 설치 진행 ────────────────────────────────
|
||||||
|
function renderStep2() {
|
||||||
|
setActiveStep(2)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>2단계. 리소스팩 설치</h2>' +
|
||||||
|
'<p class="formMessage">아래 "다음"을 누르면 음악·사진을 받아 리소스팩을 만들고 ' +
|
||||||
|
'<code>%appdata%/.minecraft/resourcepacks/</code> 에 넣습니다.</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
' <button class="secondaryBtn" id="prev">이전</button>' +
|
||||||
|
' <button class="primaryBtn" id="start">다음</button>' +
|
||||||
|
' <button class="secondaryBtn" id="cancel" hidden>취소</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
var prevBtn = section.querySelector('#prev')
|
||||||
|
var startBtn = section.querySelector('#start')
|
||||||
|
var cancelBtn = section.querySelector('#cancel')
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', function () {
|
||||||
|
if (state.installing) return
|
||||||
|
renderStep1()
|
||||||
|
})
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', function () {
|
||||||
|
if (state.installing) return
|
||||||
|
state.installing = true
|
||||||
|
startBtn.disabled = true
|
||||||
|
prevBtn.disabled = true
|
||||||
|
cancelBtn.hidden = false
|
||||||
|
logViewer.hidden = false
|
||||||
|
|
||||||
|
api.startInstall().then(function (result) {
|
||||||
|
state.installing = false
|
||||||
|
state.installed = true
|
||||||
|
state.resourcepackPath = (result && result.resourcepackPath) || ''
|
||||||
|
renderStep3()
|
||||||
|
}).catch(function (err) {
|
||||||
|
state.installing = false
|
||||||
|
startBtn.disabled = false
|
||||||
|
prevBtn.disabled = false
|
||||||
|
cancelBtn.hidden = true
|
||||||
|
alert('설치 실패: ' + (err.message || err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
api.cancelInstall()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3단계: 완료 ────────────────────────────────────
|
||||||
|
function renderStep3() {
|
||||||
|
setActiveStep(3)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>3단계. 완료</h2>' +
|
||||||
|
'<p class="formMessage">리소스팩 설치를 완료했습니다.</p>' +
|
||||||
|
(state.resourcepackPath
|
||||||
|
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
||||||
|
: '') +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
' <button class="secondaryBtn" id="openFolder">리소스팩 폴더 열기</button>' +
|
||||||
|
' <button class="primaryBtn" id="finish">확인</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||||
|
api.openResourcepackFolder()
|
||||||
|
})
|
||||||
|
section.querySelector('#finish').addEventListener('click', function () {
|
||||||
|
api.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStep1()
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||||
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||||
"installer": "tsc -p tsconfig.installer.json && electron .",
|
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||||
|
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
|
||||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
219
src/installer-rp/main.ts
Normal file
219
src/installer-rp/main.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import { URL } from 'node:url'
|
||||||
|
import type { Manifest, PackList } from '../shared/types.js'
|
||||||
|
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
|
||||||
|
import type { RpFetchedPack } from './types.js'
|
||||||
|
|
||||||
|
interface RpInstallerState {
|
||||||
|
manifestUrl: string
|
||||||
|
baseUrl: string
|
||||||
|
packs: Map<string, RpFetchedPack>
|
||||||
|
selectedKey: string | null
|
||||||
|
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
|
||||||
|
cancelRequested: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||||
|
|
||||||
|
const state: RpInstallerState = {
|
||||||
|
manifestUrl: DEFAULT_MANIFEST_URL,
|
||||||
|
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
|
||||||
|
packs: new Map(),
|
||||||
|
selectedKey: null,
|
||||||
|
cancelRequested: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
function deriveBaseUrl(manifestUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(manifestUrl)
|
||||||
|
return `${parsed.protocol}//${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMainWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 900,
|
||||||
|
height: 680,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mainWindow.removeMenu()
|
||||||
|
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-rp', 'index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendLog(line: string): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
|
||||||
|
mainWindow.webContents.send('log', stamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBuffer(url: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const target = new URL(url)
|
||||||
|
const transport = target.protocol === 'https:' ? https : http
|
||||||
|
const request = transport.get(target, { timeout: 30000 }, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
const redirect = response.headers.location
|
||||||
|
if (redirect) {
|
||||||
|
response.resume()
|
||||||
|
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((response.statusCode ?? 0) >= 400) {
|
||||||
|
response.resume()
|
||||||
|
reject(new Error(`HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const buffer = await fetchBuffer(url)
|
||||||
|
return JSON.parse(buffer.toString('utf8')) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IPC: 1단계 manifest 로드 ─────────────────────────
|
||||||
|
ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promise<RpFetchedPack[]> => {
|
||||||
|
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
|
||||||
|
state.manifestUrl = manifestUrlInput
|
||||||
|
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||||
|
}
|
||||||
|
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
||||||
|
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||||
|
const results: RpFetchedPack[] = []
|
||||||
|
for (const entry of manifest.packs ?? []) {
|
||||||
|
if (typeof entry?.file !== 'string') continue
|
||||||
|
const listUrl = `${state.baseUrl}/file/list/${encodeURIComponent(entry.file)}.json`
|
||||||
|
try {
|
||||||
|
const raw = await fetchJson<Partial<PackList>>(listUrl)
|
||||||
|
const list: PackList = {
|
||||||
|
musicPlaylistUrl: typeof raw.musicPlaylistUrl === 'string' ? raw.musicPlaylistUrl : '',
|
||||||
|
imagePlaylistUrl: typeof raw.imagePlaylistUrl === 'string' ? raw.imagePlaylistUrl : '',
|
||||||
|
music: Array.isArray(raw.music) ? raw.music : [],
|
||||||
|
images: Array.isArray(raw.images) ? raw.images : []
|
||||||
|
}
|
||||||
|
results.push({ key: entry.file, name: entry.name || entry.file, list })
|
||||||
|
} catch (error) {
|
||||||
|
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.packs.clear()
|
||||||
|
for (const item of results) state.packs.set(item.key, item)
|
||||||
|
sendLog(`로드된 음악퀴즈: ${results.length}개`)
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||||
|
if (!state.packs.has(packKey)) {
|
||||||
|
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
}
|
||||||
|
state.selectedKey = packKey
|
||||||
|
sendLog(`선택: ${packKey}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||||
|
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||||
|
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
|
||||||
|
const pack = state.packs.get(state.selectedKey)
|
||||||
|
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
state.cancelRequested = false
|
||||||
|
|
||||||
|
const tempRoot = path.join(getMcCustomDir(), '.temp')
|
||||||
|
await fsp.mkdir(tempRoot, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2-1. yt-dlp 준비
|
||||||
|
sendLog('yt-dlp 준비 중… (TODO)')
|
||||||
|
// TODO: ensureYtDlp() — shared 모듈로 분리 예정.
|
||||||
|
throwIfCancelled()
|
||||||
|
|
||||||
|
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
|
||||||
|
sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡) … (TODO)`)
|
||||||
|
for (let i = 0; i < pack.list.music.length; i++) {
|
||||||
|
throwIfCancelled()
|
||||||
|
sendLog(`${i + 1}번 노래 다운로드 중… (TODO)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||||
|
sendLog(`사진 다운로드 시작 (${pack.list.images.length}장) … (TODO)`)
|
||||||
|
for (let i = 0; i < pack.list.images.length; i++) {
|
||||||
|
throwIfCancelled()
|
||||||
|
sendLog(`${i + 1}번 사진 다운로드 중… (TODO)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-4. 리소스팩 zip 빌드
|
||||||
|
sendLog('리소스팩 zip 빌드 중… (TODO)')
|
||||||
|
throwIfCancelled()
|
||||||
|
|
||||||
|
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치
|
||||||
|
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||||
|
const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
|
||||||
|
await fsp.mkdir(resourcepackDir, { recursive: true })
|
||||||
|
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||||
|
// TODO: 실제 zip 파일을 위치시킴. 지금은 빈 placeholder.
|
||||||
|
await fsp.writeFile(resourcepackPath, '', { flag: 'a' })
|
||||||
|
|
||||||
|
sendLog(`설치 완료: ${resourcepackPath}`)
|
||||||
|
return { resourcepackPath }
|
||||||
|
} finally {
|
||||||
|
// 임시 파일 정리
|
||||||
|
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('rp:install:cancel', async () => {
|
||||||
|
state.cancelRequested = true
|
||||||
|
sendLog('취소 요청됨. 진행 중 작업을 중단합니다…')
|
||||||
|
})
|
||||||
|
|
||||||
|
function throwIfCancelled(): void {
|
||||||
|
if (state.cancelRequested) {
|
||||||
|
throw new Error('사용자가 설치를 취소했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IPC: 3단계 완료 ──────────────────────────────────
|
||||||
|
ipcMain.handle('rp:finish:openFolder', async () => {
|
||||||
|
const dir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
await shell.openPath(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('rp:quit', async () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 앱 라이프사이클 ───────────────────────────────
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createMainWindow()
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// 강제 종료 시에도 임시 파일은 정리.
|
||||||
|
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
39
src/installer-rp/preload.ts
Normal file
39
src/installer-rp/preload.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import type { RpFetchedPack } from './types.js'
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
|
||||||
|
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
|
||||||
|
ipcRenderer.invoke('rp:packs:load', manifestUrl),
|
||||||
|
/** 음악퀴즈 키를 선택. */
|
||||||
|
selectPack: (packKey: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('rp:packs:select', packKey),
|
||||||
|
|
||||||
|
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
||||||
|
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
||||||
|
ipcRenderer.invoke('rp:install:start'),
|
||||||
|
/** 진행 중인 설치 취소. 임시 파일 정리 후 종료. */
|
||||||
|
cancelInstall: (): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('rp:install:cancel'),
|
||||||
|
|
||||||
|
/** %appdata%/.minecraft/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
|
||||||
|
openResourcepackFolder: (): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('rp:finish:openFolder'),
|
||||||
|
/** 프로그램 종료. */
|
||||||
|
quit: (): Promise<void> => ipcRenderer.invoke('rp:quit'),
|
||||||
|
|
||||||
|
/** 로그 스트림 구독. */
|
||||||
|
onLog: (handler: (line: string) => void): (() => void) => {
|
||||||
|
const listener = (_event: unknown, line: string) => handler(line)
|
||||||
|
ipcRenderer.on('log', listener)
|
||||||
|
return () => ipcRenderer.removeListener('log', listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('rpInstaller', api)
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
rpInstaller: typeof api
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/installer-rp/types.ts
Normal file
15
src/installer-rp/types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { PackList } from '../shared/types.js'
|
||||||
|
|
||||||
|
export interface RpFetchedPack {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
/** /file/list/<key>.json 의 음악·사진 목록. */
|
||||||
|
list: PackList
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpInstallProgress {
|
||||||
|
step: 'yt-dlp' | 'music' | 'image' | 'package' | 'place'
|
||||||
|
index?: number
|
||||||
|
total?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
4
tsconfig.installer-rp.json
Normal file
4
tsconfig.installer-rp.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src/installer-rp/**/*.ts", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user