diff --git a/electron-builder-pf.yml b/electron-builder-pf.yml new file mode 100644 index 0000000..e7856c2 --- /dev/null +++ b/electron-builder-pf.yml @@ -0,0 +1,33 @@ +appId: kr.tkrmagid.musicquiz.portforward +productName: 마인크래프트 간편 포트포워딩 +# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이 +# 포트포워딩 도구를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다. +extraMetadata: + main: dist/installer-pf/main.js +directories: + output: release + buildResources: build +files: + - dist/installer-pf/** + - dist/shared/** + - installer-pf/** + # pf 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함 + # (``). 그 한 파일만 명시적으로 포함. + - installer/styles.css + - build/icon.* + - package.json + # 이 도구는 sharp(이미지 처리)를 쓰지 않으므로 통째로 제외해 exe 크기를 줄인다. + - "!node_modules/sharp/**" + - "!node_modules/@img/**" +# i18n 사전(locales/installer-pf/ko-kr.json)을 런타임에서 읽도록 함께 배포. +extraResources: + - from: locales + to: locales + filter: + - "**/*" +win: + target: portable + artifactName: 마인크래프트간편포트포워딩-${version}-Portable.${ext} + icon: build/icon.ico +portable: + artifactName: 마인크래프트간편포트포워딩-${version}-Portable.${ext} diff --git a/installer-pf/index.html b/installer-pf/index.html new file mode 100644 index 0000000..80baed7 --- /dev/null +++ b/installer-pf/index.html @@ -0,0 +1,22 @@ + + + + + 마인크래프트 간편 포트포워딩 + + + +
+

마인크래프트 간편 포트포워딩

+
+ +
+ + + + + + diff --git a/installer-pf/renderer.js b/installer-pf/renderer.js new file mode 100644 index 0000000..1020cda --- /dev/null +++ b/installer-pf/renderer.js @@ -0,0 +1,154 @@ +'use strict' + +const api = window.pfTool + +let I18N = {} + +function tt(key, params) { + var parts = String(key).split('.') + var cur = I18N + for (var i = 0; i < parts.length; i++) { + if (cur && typeof cur === 'object' && parts[i] in cur) { + cur = cur[parts[i]] + } else { + return key + } + } + if (typeof cur !== 'string') return key + if (!params) return cur + return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) { + return name in params ? String(params[name]) : '{{' + name + '}}' + }) +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''' + }) +} + +const pageHost = document.getElementById('pageHost') +const logViewer = document.getElementById('logViewer') +const logBody = document.getElementById('logBody') +const logToggle = document.getElementById('logToggle') + +logToggle.addEventListener('click', function () { + logViewer.classList.toggle('collapsed') + if (logViewer.classList.contains('collapsed')) { + logViewer.style.height = '36px' + logToggle.textContent = tt('logViewer.expand') + } else { + logViewer.style.height = '' + logToggle.textContent = tt('logViewer.collapse') + } +}) + +api.onLog(function (line) { + logViewer.hidden = false + logBody.textContent += line + '\n' + logBody.scrollTop = logBody.scrollHeight +}) + +function applyStaticI18n() { + document.title = tt('app.title') + var h1 = document.querySelector('.appHeader h1') + if (h1) h1.textContent = tt('app.title') + var logH2 = logViewer.querySelector('header h2') + if (logH2) logH2.textContent = tt('logViewer.heading') + logToggle.textContent = tt('logViewer.collapse') +} + +function renderMain() { + pageHost.innerHTML = + '
' + + '

' + escapeHtml(tt('main.heading')) + '

' + + '

' + escapeHtml(tt('main.intro')) + '

' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + var portEl = document.getElementById('port') + var openBtn = document.getElementById('openBtn') + var closeBtn = document.getElementById('closeBtn') + var quitBtn = document.getElementById('quitBtn') + var resultEl = document.getElementById('result') + + function parsePort() { + var n = parseInt(String(portEl.value).replace(/[^0-9]/g, ''), 10) + if (!isFinite(n) || n <= 0 || n >= 65536) return 25565 + return n + } + + function setBusy(busy) { + openBtn.disabled = busy + closeBtn.disabled = busy + portEl.disabled = busy + } + + function showResult(outcome) { + var addr = (outcome.externalIp || tt('main.ipUnknown')) + + (outcome.port === 25565 ? '' : (':' + outcome.port)) + var cls, badge, msg + if (outcome.reachable === true) { + cls = 'ok'; badge = tt('verdict.success') + msg = tt('main.successMsg', { addr: addr }) + + (outcome.preForwarded ? ' ' + tt('main.alreadyOpen') : '') + } else if (outcome.reachable === false) { + cls = 'fail'; badge = tt('verdict.fail') + msg = tt('main.failMsg') + } else { + cls = 'warn'; badge = tt('verdict.unknown') + msg = tt('main.unknownMsg') + } + resultEl.innerHTML = + '
' + + '
' + escapeHtml(badge) + ' ' + + ' ' + escapeHtml(addr) + '
' + + '

' + escapeHtml(msg) + '

' + + '

' + escapeHtml(tt('main.detailLabel', { detail: outcome.detail || '' })) + '

' + + '
' + } + + openBtn.addEventListener('click', function () { + var port = parsePort() + portEl.value = String(port) + setBusy(true) + resultEl.innerHTML = '

' + escapeHtml(tt('main.working')) + '

' + api.openPort(port).then(function (outcome) { + showResult(outcome) + }).catch(function (err) { + resultEl.innerHTML = '

' + + escapeHtml(tt('main.error', { message: (err && err.message) || String(err) })) + '

' + }).then(function () { + setBusy(false) + }) + }) + + closeBtn.addEventListener('click', function () { + var port = parsePort() + portEl.value = String(port) + setBusy(true) + api.closePort(port).then(function () { + resultEl.innerHTML = '

' + + escapeHtml(tt('main.closed', { port: port })) + '

' + }).then(function () { + setBusy(false) + }) + }) + + quitBtn.addEventListener('click', function () { api.quit() }) +} + +;(async function () { + try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} } + applyStaticI18n() + renderMain() +})() diff --git a/locales/installer-pf/ko-kr.json b/locales/installer-pf/ko-kr.json new file mode 100644 index 0000000..011f6a2 --- /dev/null +++ b/locales/installer-pf/ko-kr.json @@ -0,0 +1,64 @@ +{ + "app": { + "title": "마인크래프트 간편 포트포워딩" + }, + "logViewer": { + "heading": "로그", + "collapse": "접기", + "expand": "펼치기" + }, + "verdict": { + "success": "성공", + "fail": "실패", + "unknown": "확인 불가" + }, + "main": { + "heading": "포트 열기", + "intro": "UPnP 로 라우터에 포트를 열고, 외부에서 실제로 닿는지 확인합니다. 마인크래프트 자바 서버는 TCP 25565 를 사용합니다.", + "portLabel": "포트", + "openBtn": "포트 열기 / 확인", + "closeBtn": "포트 닫기", + "quitBtn": "종료", + "working": "포트를 여는 중입니다… (외부 점검까지 최대 1분 정도 걸릴 수 있어요)", + "ipUnknown": "외부IP-확인불가", + "successMsg": "외부에서 접속 가능합니다. 접속 주소: {{addr}}", + "alreadyOpen": "(이미 열려 있던 상태입니다)", + "failMsg": "외부에서 포트에 닿지 않습니다. 라우터 UPnP 설정, 이중 NAT(CGNAT), Windows 방화벽, 또는 포워딩 대상 IP 를 확인하세요.", + "unknownMsg": "외부 도달 여부를 확정하지 못했습니다(외부 점검 서비스 응답 없음). 포트 매핑은 등록되었을 수 있습니다. 잠시 후 다시 시도하거나, 실제 접속으로 확인하세요.", + "detailLabel": "점검 상세: {{detail}}", + "error": "오류: {{message}}", + "closed": "포트 {{port}} 매핑을 제거했습니다." + }, + "log": { + "start": "포트포워딩 시작: TCP {{port}}", + "cleanup": "이전 UPnP 매핑 정리 중…", + "externalIpHttp": "외부 IP(HTTP): {{ip}}", + "externalIpHttpFail": "외부 IP HTTP 조회 실패 → UPnP 게이트웨이로 폴백", + "externalIpUpnp": "외부 IP(UPnP): {{ip}}", + "externalIpFail": "외부 IP 확인 실패", + "probeStart": "외부 포트 점검 시작…", + "probeResult": "점검 결과: {{verdict}} ({{detail}})", + "preForwarded": "이미 외부에서 접속 가능한 상태입니다.", + "upnpTry": "UPnP 포트 개방 시도: TCP {{port}}", + "upnpReqOk": "UPnP 매핑 요청 성공.", + "upnpTryFail": "UPnP 개방 실패: {{message}}", + "recheck": "UPnP 반영 재점검 {{attempt}}/3…", + "upnpDone": "UPnP 개방 확인 완료: TCP {{port}}", + "upnpUnconfirmed": "UPnP 매핑은 등록됐지만 외부 도달은 확정되지 않았습니다.", + "closeTry": "UPnP 매핑 제거 시도: TCP {{port}}", + "upnpRemoveAttempt": "UPnP 매핑 제거 시도: {{message}}", + "upnpRemoveDone": "UPnP 매핑 제거 완료: TCP {{port}}", + "upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}", + "portInUse": "포트 {{port}}이(가) 이미 사용 중 → 임시 리스너 없이 외부 서비스 응답만으로 판정.", + "listenerBindFail": "임시 리스너 바인딩 실패: {{message}}", + "detailListenerHit": "임시 리스너 도달={{value}}", + "detailListenerSkip": "임시 리스너=skip(포트 사용중)", + "detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}", + "detailIfconfigFail": "ifconfig.co 실패={{error}}", + "detailNone": "결과 없음" + }, + "errors": { + "requestTimeout": "요청 시간 초과", + "upnpTimeout": "UPnP 요청 시간 초과" + } +} diff --git a/package.json b/package.json index 47a9c92..1b060ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.14", + "version": "0.3.15", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { @@ -9,10 +9,12 @@ "dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js", "installer": "tsc -p tsconfig.installer.json && electron .", "installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js", + "installer:pf": "tsc -p tsconfig.installer-pf.json && electron dist/installer-pf/main.js", "preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5", "build:launcher-icon": "node scripts/build-launcher-icon.cjs", "dist:win": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml", - "dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml" + "dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml", + "dist:win:pf": "tsc -p tsconfig.installer-pf.json && electron-builder --win --config electron-builder-pf.yml" }, "dependencies": { "@types/archiver": "^7.0.0", diff --git a/src/installer-pf/main.ts b/src/installer-pf/main.ts new file mode 100644 index 0000000..57b6030 --- /dev/null +++ b/src/installer-pf/main.ts @@ -0,0 +1,366 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import http from 'node:http' +import https from 'node:https' +import net from 'node:net' +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 + +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) +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +function fetchBuffer(url: string): Promise { + 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 { + 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 { + return new Promise((resolve) => { + let settled = false + const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } } + let client: ReturnType | 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('') + }, 8000) + client.externalIp((err: Error | null, ip?: string) => { + clearTimeout(timer) + try { client && client.close() } catch {} + finish(err || !ip ? '' : ip) + }) + }) +} + +function openPortViaUpnp(port: number): Promise { + 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 | 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 { + return new Promise((resolve) => { + let settled = false + const fin = () => { if (!settled) { settled = true; resolve() } } + let client: ReturnType | 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 { + 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 { + 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((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((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 + port: number + reachable: boolean | null + preForwarded: boolean + detail: string +} + +ipcMain.handle('pf:i18n:dict', () => localeDict) + +ipcMain.handle('pf:open', async (_event, portInput: number): Promise => { + const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565 + sendLog(t('log.start', { port })) + + // 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별. + sendLog(t('log.cleanup')) + await removeUpnpMapping(port) + + let externalIp = await detectExternalIpHttp() + if (externalIp) sendLog(t('log.externalIpHttp', { ip: externalIp })) + else { + sendLog(t('log.externalIpHttpFail')) + externalIp = await detectExternalIpUpnp() + if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp })) + else sendLog(t('log.externalIpFail')) + } + + // 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 { externalIp, port, reachable: true, preForwarded: true, detail: probe.detail } + } + + // UPnP 개방 시도. + sendLog(t('log.upnpTry', { port })) + try { + await openPortViaUpnp(port) + sendLog(t('log.upnpReqOk')) + } catch (error) { + sendLog(t('log.upnpTryFail', { message: (error as Error).message })) + return { externalIp, port, reachable: probe.reachable, preForwarded: false, detail: 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 { externalIp, port, reachable: true, preForwarded: false, detail: probe.detail } + } + } + sendLog(t('log.upnpUnconfirmed')) + return { externalIp, port, reachable: probe.reachable, preForwarded: false, detail: probe.detail } +}) + +ipcMain.handle('pf:close', async (_event, portInput: number): Promise => { + const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565 + sendLog(t('log.closeTry', { port })) + await removeUpnpMapping(port) +}) + +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() + }) +}) + +app.on('window-all-closed', () => { + app.quit() +}) diff --git a/src/installer-pf/preload.ts b/src/installer-pf/preload.ts new file mode 100644 index 0000000..3d015e1 --- /dev/null +++ b/src/installer-pf/preload.ts @@ -0,0 +1,38 @@ +import { contextBridge, ipcRenderer } from 'electron' + +interface PortForwardOutcome { + externalIp: string + port: number + reachable: boolean | null + preForwarded: boolean + detail: string +} + +const api = { + /** i18n 사전을 렌더러에 전달. */ + loadLocale: (): Promise> => ipcRenderer.invoke('pf:i18n:dict'), + + /** 지정 포트를 UPnP 로 개방 시도하고 외부에서 닿는지 점검. */ + openPort: (port: number): Promise => ipcRenderer.invoke('pf:open', port), + + /** UPnP 포트 매핑 제거. */ + closePort: (port: number): Promise => ipcRenderer.invoke('pf:close', port), + + /** 프로그램 종료. */ + quit: (): Promise => ipcRenderer.invoke('pf:quit'), + + /** 로그 스트림 구독. */ + onLog: (handler: (line: string) => void): (() => void) => { + const listener = (_event: unknown, line: string) => handler(line) + ipcRenderer.on('log', listener) + return () => ipcRenderer.removeListener('log', listener) + } +} + +contextBridge.exposeInMainWorld('pfTool', api) + +declare global { + interface Window { + pfTool: typeof api + } +} diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index 0d7d6d4..132aed7 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -73,7 +73,7 @@ export function createI18n(filePath: string): I18n { * 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales//ko-kr.json` * 2. `<프로젝트 루트>/locales//ko-kr.json` */ -export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n { +export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp' | 'installer-pf'): I18n { // 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위. const projectRoot = path.resolve(__dirname, '..', '..') diff --git a/tsconfig.installer-pf.json b/tsconfig.installer-pf.json new file mode 100644 index 0000000..b4be1b7 --- /dev/null +++ b/tsconfig.installer-pf.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/installer-pf/**/*.ts", "src/shared/**/*.ts"] +}