import { app, BrowserWindow, ipcMain, shell } from 'electron' import path from 'node:path' import fs from 'node:fs' import fsp from 'node:fs/promises' import { loadEnv } from '../shared/env.js' import { getAppDataDir, getMcCustomDir } from '../shared/paths.js' import { loadComponentI18n } from '../shared/i18n.js' // 음악퀴즈 파일제거 도구. 음악퀴즈 간편설치기 / 리소스팩설치기가 만든 데이터를 // 한 번에 정리한다. (설치기 exe 자체는 사용자가 임의 위치에 둔 포터블이라 // 위치를 알 수 없어 삭제 대상에서 제외) loadEnv() const i18n = loadComponentI18n('installer-uninstall') const t = i18n.t const localeDict = i18n.dict let mainWindow: BrowserWindow | null = null function createMainWindow(): void { const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png') mainWindow = new BrowserWindow({ width: 720, height: 620, icon: iconPath, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }) mainWindow.removeMenu() void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-uninstall', '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 launcherProfilesPath(): string { return path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json') } /** 데스크톱에 설치기가 만든 서버 실행 바로가기 경로. */ function serverShortcutPath(): string { return path.join(app.getPath('desktop'), 'MusicQuiz Server.lnk') } /** 기본 폴더 이름(.mc_custom) 경로. MC_CUSTOM_DIR 을 바꿔 빌드해도, 예전 기본 폴더가 남아 있을 수 있으므로 함께 지운다. */ function defaultCustomDir(): string { return path.join(getAppDataDir(), '.mc_custom') } /** 삭제 대상 커스텀 폴더 후보(현재 설정값 + 기본 .mc_custom)를 대소문자 무시로 중복 제거. */ function targetCustomDirs(): string[] { const seen = new Set() const out: string[] = [] for (const dir of [getMcCustomDir(), defaultCustomDir()]) { const key = path.resolve(dir).toLowerCase() if (!seen.has(key)) { seen.add(key) out.push(dir) } } return out } /** 두 경로가 같은 폴더거나, a 가 b 하위인지(대소문자 무시 — Windows 파일계). */ function isSameOrInside(child: string, parent: string): boolean { const c = path.resolve(child).replace(/[\\/]+$/, '').toLowerCase() const p = path.resolve(parent).replace(/[\\/]+$/, '').toLowerCase() return c === p || c.startsWith(p + path.sep.toLowerCase()) || c.startsWith(p + '/') } interface UninstallPreview { existingDirs: string[] allTargetDirs: string[] shortcutExists: boolean launcherProfiles: string[] } /** 삭제 전 미리보기: 실제로 존재하는 대상만 추려 렌더러에 보여준다. */ ipcMain.handle('uninstall:preview', async (): Promise => { const allTargetDirs = targetCustomDirs() const existingDirs = allTargetDirs.filter((d) => fs.existsSync(d)) const shortcutExists = fs.existsSync(serverShortcutPath()) const launcherProfiles = findMusicQuizProfiles(allTargetDirs) return { existingDirs, allTargetDirs, shortcutExists, launcherProfiles } }) /** launcher_profiles.json 에서 gameDir 가 커스텀 폴더(또는 그 하위)인 프로필 이름 목록. */ function findMusicQuizProfiles(customDirs: string[]): string[] { const file = launcherProfilesPath() if (!fs.existsSync(file)) return [] try { const json = JSON.parse(fs.readFileSync(file, 'utf8')) as { profiles?: Record } const profiles = json.profiles ?? {} const names: string[] = [] for (const [key, prof] of Object.entries(profiles)) { const gameDir = typeof prof?.gameDir === 'string' ? prof.gameDir : '' if (gameDir && customDirs.some((d) => isSameOrInside(gameDir, d))) { names.push(typeof prof?.name === 'string' && prof.name ? prof.name : key) } } return names } catch { return [] } } interface UninstallResult { removed: string[] profilesRemoved: string[] errors: string[] } /** 하나의 파일/폴더를 mode 에 따라 휴지통 이동 또는 완전 삭제. */ async function removeOne(target: string, mode: 'trash' | 'permanent'): Promise { if (mode === 'trash') { await shell.trashItem(target) } else { await fsp.rm(target, { recursive: true, force: true }) } } /** launcher_profiles.json 에서 음악퀴즈 프로필만 제거하고 나머지는 보존. */ async function cleanLauncherProfiles(customDirs: string[]): Promise { const file = launcherProfilesPath() if (!fs.existsSync(file)) return [] let json: { profiles?: Record } try { json = JSON.parse(await fsp.readFile(file, 'utf8')) } catch { sendLog(t('log.launcherParseFail', { path: file })) return [] } const profiles = json.profiles ?? {} const removed: string[] = [] for (const [key, prof] of Object.entries(profiles)) { const gameDir = typeof prof?.gameDir === 'string' ? prof.gameDir : '' if (gameDir && customDirs.some((d) => isSameOrInside(gameDir, d))) { removed.push(typeof prof?.name === 'string' && prof.name ? prof.name : key) delete profiles[key] } } if (removed.length > 0) { json.profiles = profiles await fsp.writeFile(file, `${JSON.stringify(json, null, 2)}\n`, 'utf8') } return removed } ipcMain.handle('uninstall:run', async (_event, modeInput: unknown): Promise => { const mode: 'trash' | 'permanent' = modeInput === 'permanent' ? 'permanent' : 'trash' const customDirs = targetCustomDirs() const removed: string[] = [] const errors: string[] = [] sendLog(t('log.start', { mode: t(mode === 'trash' ? 'mode.trash' : 'mode.permanent') })) // 1) 커스텀 게임/캐시 폴더 통째로(현재 설정값 + 기본 .mc_custom). for (const customDir of customDirs) { if (fs.existsSync(customDir)) { try { await removeOne(customDir, mode) removed.push(customDir) sendLog(t('log.removedDir', { path: customDir })) } catch (err) { const msg = (err as Error).message errors.push(`${customDir}: ${msg}`) sendLog(t('log.removeFail', { path: customDir, message: msg })) } } else { sendLog(t('log.customDirMissing', { path: customDir })) } } // 2) 데스크톱 서버 실행 바로가기. const shortcut = serverShortcutPath() if (fs.existsSync(shortcut)) { try { await removeOne(shortcut, mode) removed.push(shortcut) sendLog(t('log.removedShortcut', { path: shortcut })) } catch (err) { const msg = (err as Error).message errors.push(`${shortcut}: ${msg}`) sendLog(t('log.removeFail', { path: shortcut, message: msg })) } } // 3) 마인크래프트 런처 프로필에서 음악퀴즈 설정 제거. let profilesRemoved: string[] = [] try { profilesRemoved = await cleanLauncherProfiles(customDirs) for (const name of profilesRemoved) sendLog(t('log.removedProfile', { name })) } catch (err) { const msg = (err as Error).message errors.push(`launcher_profiles.json: ${msg}`) sendLog(t('log.launcherWriteFail', { message: msg })) } sendLog(t('log.done', { count: removed.length + profilesRemoved.length })) return { removed, profilesRemoved, errors } }) ipcMain.handle('uninstall:i18n:dict', () => localeDict) ipcMain.handle('uninstall:quit', () => app.quit()) app.whenReady().then(() => { createMainWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow() }) }) app.on('window-all-closed', () => { app.quit() })