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 @@ + + +
+ +' + escapeHtml(tt('main.intro')) + '
' + + '' + escapeHtml(msg) + '
' + + '' + escapeHtml(tt('main.detailLabel', { detail: outcome.detail || '' })) + '
' + + '' + 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