- 음악퀴즈 파일제거 도구 신규 추가: 동의 → 휴지통/완전삭제 선택 → 커스텀 폴더 (현재값 + 기본 .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>
469 lines
18 KiB
TypeScript
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()
|
|
})
|