feat(installer): v0.4.0 — 파일제거기, 커스텀 폴더명, 포트포워딩 개편, 이름/개발자용 표기
- 음악퀴즈 파일제거 도구 신규 추가: 동의 → 휴지통/완전삭제 선택 → 커스텀 폴더 (현재값 + 기본 .mc_custom) 전체, 데스크톱 바로가기, gameDir 가 해당 폴더인 마인크래프트 런처 프로필 정리. - MC_CUSTOM_DIR .env 로 커스텀 게임 폴더 이름 유동화(.mc_custom 기본). 렌더러 사전에도 실제 폴더명 반영. - 간편포트포워딩: 실행 중 UPnP 매핑 유지, 창 닫힘/종료 시 자동 제거(activePort 추적). - 개발자용 빌드는 창/헤더 제목 앞에 (개발자용) 표기, exe 이름에도 반영. - exe 이름 변경: 음악퀴즈 간편설치기 / 음악퀴즈 리소스팩설치기 / 간편포트포워딩. - README 및 .env 템플릿 갱신, 버전 0.3.23 → 0.4.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
224
src/installer-uninstall/main.ts
Normal file
224
src/installer-uninstall/main.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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<string>()
|
||||
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<UninstallPreview> => {
|
||||
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<string, { name?: string; gameDir?: string }>
|
||||
}
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
const file = launcherProfilesPath()
|
||||
if (!fs.existsSync(file)) return []
|
||||
let json: { profiles?: Record<string, { name?: string; gameDir?: string }> }
|
||||
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<UninstallResult> => {
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user