Files
minecraft_launcher/src/installer-pf/main.ts
claude-bot 996a7cc03e 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>
2026-07-15 23:21:46 +09:00

469 lines
18 KiB
TypeScript

import { app, BrowserWindow, ipcMain } from 'electron'
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { URL } from 'node:url'
import natUpnp from 'nat-upnp'
import { loadComponentI18n } from '../shared/i18n.js'
// 포트포워딩 전용 독립 도구. 메인 설치기의 UPnP 개방 + 외부 포트 점검 로직만
// 떼어내 단독 실행한다. (음악퀴즈 설치/리소스팩과 무관)
const i18n = loadComponentI18n('installer-pf')
const t = i18n.t
const localeDict = i18n.dict
let mainWindow: BrowserWindow | null = null
// 이 도구가 UPnP 로 직접 열어둔 포트. 앱이 살아 있는 동안 매핑을 유지하고,
// 창을 닫거나 종료할 때 이 포트의 매핑을 제거한다(사용자가 라우터에 직접 만든
// 영구 규칙으로 이미 열려 있던 preForwarded 포트는 우리 것이 아니므로 추적하지 않음).
let activePort: number | null = null
// before-quit 재진입 가드. 매핑 정리를 마친 뒤에만 실제 종료로 넘어가게 한다.
let cleanupDone = false
function createMainWindow(): void {
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({
width: 760,
height: 620,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.removeMenu()
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-pf', '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)
}
// nat-upnp 는 라우터가 UPnP 를 거부/미지원할 때 콜백 밖에서 비동기 소켓 오류를
// 낼 수 있다. 처리되지 않으면 Electron 메인 프로세스가 그대로 종료되어 창이 갑자기
// 닫힌다("오류나면서 끝남"). 전역 가드로 잡아 로그만 남기고 앱은 계속 살려 둔다.
process.on('uncaughtException', (err) => {
try { sendLog(t('log.internalError', { message: (err as Error)?.message || String(err) })) } catch {}
})
process.on('unhandledRejection', (reason) => {
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
})
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
/** 이 PC 의 LAN IPv4(사설 대역 우선). 수동 포워딩 대상 IP 안내에 쓴다. */
function detectLocalIpv4(): string {
const ifaces = os.networkInterfaces()
const candidates: string[] = []
for (const name of Object.keys(ifaces)) {
for (const info of ifaces[name] || []) {
if (info.family === 'IPv4' && !info.internal && !info.address.startsWith('169.254.')) {
candidates.push(info.address)
}
}
}
const priv = candidates.find((a) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a))
return priv || candidates[0] || ''
}
/** CGNAT(이중 NAT) 대역 100.64.0.0/10 인지. */
function isCgnat(ip: string): boolean {
const m = ip.match(/^100\.(\d+)\./)
if (!m) return false
const octet = Number(m[1])
return octet >= 64 && octet <= 127
}
/** 사설(RFC1918) IPv4 인지. 라우터 WAN 이 사설이면 앞단에 NAT 이 또 있다는 뜻(이중 NAT). */
function isPrivateIpv4(ip: string): boolean {
return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)
}
/**
* CGNAT/이중 NAT 여부 판별. HTTP 로 본 공인 egress IP 하나만으로는 배제할 수 없다
* (전형적 CGNAT 은 egress 는 ISP 공인 IP, 라우터 WAN 만 100.64/10 이라 egress 검사로는 놓침).
* 그래서 라우터 WAN(IGD 외부) IP 를 함께 보고 비교한다.
* - 'yes' : 공인 IP 가 CGNAT 대역이거나, 라우터 WAN 이 사설/CGNAT 이거나 공인 IP 와 다름.
* - 'no' : 라우터 WAN 이 HTTP 공인 IP 와 동일 → 단일 NAT 확정.
* - 'unknown' : 라우터 WAN 을 못 읽음(UPnP 미응답/거부) → 외부 IP 만으로는 확정/배제 불가.
*/
function classifyCgnat(publicIp: string, wanIp: string): 'yes' | 'no' | 'unknown' {
if (publicIp && isCgnat(publicIp)) return 'yes'
if (wanIp) {
if (isCgnat(wanIp) || isPrivateIpv4(wanIp)) return 'yes'
if (publicIp && wanIp !== publicIp) return 'yes'
if (publicIp && wanIp === publicIp) return 'no'
}
return 'unknown'
}
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: 15000 }, (response) => {
const code = response.statusCode ?? 0
if ((code === 301 || code === 302) && response.headers.location) {
response.resume()
fetchBuffer(new URL(response.headers.location, target).toString()).then(resolve, reject)
return
}
if (code >= 400) {
response.resume()
reject(new Error(`HTTP ${code}`))
return
}
const chunks: Buffer[] = []
response.on('data', (c: Buffer) => chunks.push(c))
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
})
}
async function detectExternalIpHttp(): Promise<string> {
const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com']
for (const url of endpoints) {
try {
const buffer = await fetchBuffer(url)
const ip = buffer.toString('utf8').trim()
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip
} catch {
// try next
}
}
return ''
}
function detectExternalIpUpnp(): Promise<string> {
return new Promise((resolve) => {
let settled = false
const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } }
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
sendLog(t('log.upnpClientFail', { message: (err as Error).message }))
finish('')
return
}
const timer = setTimeout(() => {
try { client && client.close() } catch {}
finish('')
}, 6000)
client.externalIp((err: Error | null, ip?: string) => {
clearTimeout(timer)
try { client && client.close() } catch {}
finish(err || !ip ? '' : ip)
})
})
}
function openPortViaUpnp(port: number): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const done = (err?: Error) => {
if (settled) return
settled = true
if (err) reject(err)
else resolve()
}
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
done(err as Error)
return
}
const timer = setTimeout(() => {
try { client && client.close() } catch {}
done(new Error(t('errors.upnpTimeout')))
}, 15000)
client.portMapping(
{ public: port, private: port, ttl: 0, description: 'MusicQuiz PortForward', protocol: 'tcp' },
(error: Error | null) => {
clearTimeout(timer)
try { client && client.close() } catch {}
done(error || undefined)
}
)
})
}
function removeUpnpMapping(port: number): Promise<void> {
return new Promise((resolve) => {
let settled = false
const fin = () => { if (!settled) { settled = true; resolve() } }
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
sendLog(t('log.upnpClientFail', { message: (err as Error).message }))
fin()
return
}
const timer = setTimeout(() => {
try { client && client.close() } catch {}
fin()
}, 8000)
client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => {
clearTimeout(timer)
try { client && client.close() } catch {}
if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message }))
else sendLog(t('log.upnpRemoveDone', { port }))
fin()
})
})
}
type IfconfigPortResult = { ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }
// ifconfig.co 는 간헐적으로 타임아웃/레이트리밋을 낸다. 1회 재시도로 일시적 실패를 줄인다.
async function fetchIfconfigCoPort(port: number): Promise<IfconfigPortResult> {
let last: IfconfigPortResult = { ok: false, error: 'no attempt' }
for (let attempt = 0; attempt < 2; attempt++) {
last = await fetchIfconfigCoPortOnce(port)
if (last.ok) return last
if (attempt === 0) await sleep(1500)
}
return last
}
function fetchIfconfigCoPortOnce(port: number): Promise<IfconfigPortResult> {
return new Promise((resolve) => {
const target = new URL(`https://ifconfig.co/port/${port}`)
const req = https.get(target, {
timeout: 15000,
headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-PortForward' }
}, (res) => {
if ((res.statusCode ?? 0) >= 400) {
res.resume()
resolve({ ok: false, error: `HTTP ${res.statusCode}` })
return
}
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8').trim()
try {
const json = JSON.parse(text)
const reachable = typeof json.reachable === 'boolean' ? json.reachable : null
const ip = typeof json.ip === 'string' ? json.ip : ''
resolve({ ok: true, reachable, ip })
} catch {
resolve({ ok: false, error: text.slice(0, 80) })
}
})
})
req.on('error', (err) => resolve({ ok: false, error: err.message }))
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout'))))
})
}
/**
* 외부에서 지정 포트가 닿는지 검사한다.
* 1) 임시 TCP 리스너를 0.0.0.0:port 에 띄운다(서버가 안 떠 있어도 검증 가능).
* 2) ifconfig.co 에게 외부 IP:port 로 접속을 시킨다.
* 3) 리스너에 인바운드가 오거나 ifconfig.co 가 reachable=true 면 성공.
* '닫힘(false)' 은 ifconfig.co 가 명시적으로 false 를 줄 때만. 외부 판정이 없으면 null(확인 불가).
*/
async function probePortFromOutside(
port: number,
hintIp: string
): Promise<{ reachable: boolean | null; detail: string; detectedIp: string }> {
let server: net.Server | null = null
let listenerBound = false
try {
server = net.createServer()
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => { server!.removeListener('error', onError); reject(err) }
server!.once('error', onError)
server!.listen(port, '0.0.0.0', () => {
server!.removeListener('error', onError)
listenerBound = true
resolve()
})
})
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EADDRINUSE') sendLog(t('log.portInUse', { port }))
else sendLog(t('log.listenerBindFail', { message: (err as Error).message }))
try { server && server.close() } catch {}
server = null
}
let gotInbound = false
const inboundPromise = new Promise<void>((resolve) => {
if (!server) { resolve(); return }
server.on('connection', (sock: net.Socket) => {
gotInbound = true
try { sock.destroy() } catch {}
resolve()
})
})
const externalProbe = fetchIfconfigCoPort(port).catch((err) => ({ ok: false as const, error: (err as Error).message }))
await Promise.race([inboundPromise, sleep(12000)])
const externalResult = await externalProbe
try { server && server.close() } catch {}
let reachable: boolean | null = null
const details: string[] = []
if (listenerBound) {
details.push(t('log.detailListenerHit', { value: gotInbound ? 'yes' : 'no' }))
if (gotInbound) reachable = true
} else {
details.push(t('log.detailListenerSkip'))
}
let detectedIp = ''
if ('ok' in externalResult && externalResult.ok) {
details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' }))
detectedIp = externalResult.ip || ''
if (externalResult.reachable === true) reachable = true
else if (reachable !== true && externalResult.reachable === false) reachable = false
} else if ('ok' in externalResult && !externalResult.ok) {
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
}
// 외부 점검 서비스가 명시적 false 를 준 경우에만 닫힘. 리스너 미도달만으로는 단정하지 않는다
// (ifconfig.co 실패 시 외부 시도 자체가 없었고, 리스너 수신 주체가 이 도구라 방화벽 영향도 받음).
return {
reachable,
detail: details.join(', ') || t('log.detailNone'),
detectedIp: detectedIp || hintIp || ''
}
}
interface PortForwardOutcome {
externalIp: string
localIp: string
wanIp: string
cgnat: 'yes' | 'no' | 'unknown'
port: number
reachable: boolean | null
preForwarded: boolean
detail: string
}
ipcMain.handle('pf:i18n:dict', () => localeDict)
ipcMain.handle('pf:open', async (_event, portInput: number): Promise<PortForwardOutcome> => {
const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565
sendLog(t('log.start', { port }))
const localIp = detectLocalIpv4()
if (localIp) sendLog(t('log.localIp', { ip: localIp }))
// 다른 포트를 이미 우리가 열어둔 상태에서 새 포트를 열면, 이전 포트 매핑을 먼저 닫아
// 매핑이 새는 것을 막는다.
if (activePort !== null && activePort !== port) {
await removeUpnpMapping(activePort)
activePort = null
}
// 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별.
sendLog(t('log.cleanup'))
await removeUpnpMapping(port)
let externalIp = await detectExternalIpHttp()
if (externalIp) sendLog(t('log.externalIpHttp', { ip: externalIp }))
else sendLog(t('log.externalIpHttpFail'))
// 라우터 WAN(IGD 외부) IP 를 UPnP 로 조회해 HTTP 공인 IP 와 비교 → CGNAT/이중 NAT 판별.
const wanIp = await detectExternalIpUpnp()
if (wanIp) sendLog(t('log.routerWan', { ip: wanIp }))
else sendLog(t('log.routerWanUnknown'))
if (!externalIp && wanIp) externalIp = wanIp
const cgnat = classifyCgnat(externalIp, wanIp)
if (cgnat === 'yes') sendLog(t('log.cgnatDetected', { public: externalIp || '?', wan: wanIp || '?' }))
else if (cgnat === 'unknown') sendLog(t('log.cgnatUnknown'))
const wrap = (reachable: boolean | null, preForwarded: boolean, detail: string): PortForwardOutcome =>
({ externalIp, localIp, wanIp, cgnat, port, reachable, preForwarded, detail })
// 1차 점검: 이미 외부에서 닿는지.
sendLog(t('log.probeStart'))
let probe = await probePortFromOutside(port, externalIp)
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
sendLog(t('log.probeResult', { verdict: verdictText(probe.reachable), detail: probe.detail }))
if (probe.reachable === true) {
sendLog(t('log.preForwarded'))
return wrap(true, true, probe.detail)
}
// UPnP 개방 시도.
sendLog(t('log.upnpTry', { port }))
try {
await openPortViaUpnp(port)
// 우리가 연 포트로 기록 → 앱 종료/창 닫힘 시 자동으로 매핑 제거.
activePort = port
sendLog(t('log.upnpReqOk'))
} catch (error) {
const msg = (error as Error).message
sendLog(t('log.upnpTryFail', { message: msg }))
// ECONNREFUSED/타임아웃 = 라우터가 UPnP 제어를 거부/미지원. 수동 포워딩 안내로 유도.
if (/ECONNREFUSED|ETIMEDOUT|timed out|시간 초과/i.test(msg)) sendLog(t('log.upnpUnavailable'))
return wrap(probe.reachable, false, probe.detail)
}
// NAT 반영 지연 고려 재점검.
for (let attempt = 1; attempt <= 3; attempt++) {
await sleep(1500)
sendLog(t('log.recheck', { attempt }))
probe = await probePortFromOutside(port, externalIp)
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
if (probe.reachable === true) {
sendLog(t('log.upnpDone', { port }))
return wrap(true, false, probe.detail)
}
}
sendLog(t('log.upnpUnconfirmed'))
return wrap(probe.reachable, false, probe.detail)
})
ipcMain.handle('pf:close', async (_event, portInput: number): Promise<void> => {
const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565
sendLog(t('log.closeTry', { port }))
await removeUpnpMapping(port)
if (activePort === port) activePort = null
})
ipcMain.handle('pf:quit', async () => {
app.quit()
})
function verdictText(reachable: boolean | null): string {
return reachable === true ? t('verdict.success') : reachable === false ? t('verdict.fail') : t('verdict.unknown')
}
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
// 창을 닫거나 종료할 때, 이 도구가 열어둔 UPnP 매핑을 제거한 뒤 실제 종료로 넘어간다.
// removeUpnpMapping 은 비동기라 before-quit 을 한 번 막고(cleanup) 끝나면 다시 quit 한다.
app.on('before-quit', (event) => {
if (cleanupDone || activePort === null) return
event.preventDefault()
const port = activePort
activePort = null
sendLog(t('log.closeTry', { port }))
void removeUpnpMapping(port).finally(() => {
cleanupDone = true
app.quit()
})
})
app.on('window-all-closed', () => {
app.quit()
})