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:
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()
|
||||
})
|
||||
Reference in New Issue
Block a user