포트포워딩 도구가 실패/확인불가일 때 다음 조치를 바로 안내하도록 보강.
- 이 PC LAN IPv4 자동 감지 → "외부 TCP {port} → {localIp}:{port} 수동 포워딩 +
Windows 방화벽 인바운드 허용" 안내.
- 외부 IP 가 CGNAT(100.64/10) 대역이면 경고(공유기 포워딩만으론 불가).
- UPnP 가 ECONNREFUSED/타임아웃이면 "라우터 UPnP 거부/미지원" 로그 힌트.
실제 사용자 로그(라우터 UPnP ECONNREFUSED, 공인 IP, ifconfig false)에 기반한
개선. v0.3.16.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
170 lines
5.9 KiB
JavaScript
170 lines
5.9 KiB
JavaScript
'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 =
|
|
'<section class="page">' +
|
|
' <h2>' + escapeHtml(tt('main.heading')) + '</h2>' +
|
|
' <p class="formMessage">' + escapeHtml(tt('main.intro')) + '</p>' +
|
|
' <div class="fieldset">' +
|
|
' <label for="port">' + escapeHtml(tt('main.portLabel')) + '</label>' +
|
|
' <input type="text" id="port" inputmode="numeric" value="25565" />' +
|
|
' </div>' +
|
|
' <div class="actionRow">' +
|
|
' <button class="primaryBtn" id="openBtn">' + escapeHtml(tt('main.openBtn')) + '</button>' +
|
|
' <button class="secondaryBtn" id="closeBtn">' + escapeHtml(tt('main.closeBtn')) + '</button>' +
|
|
' <button class="secondaryBtn" id="quitBtn">' + escapeHtml(tt('main.quitBtn')) + '</button>' +
|
|
' </div>' +
|
|
' <div id="result"></div>' +
|
|
'</section>'
|
|
|
|
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')
|
|
}
|
|
|
|
// 실패/확인불가일 때 다음 조치 안내: CGNAT 경고 + 수동 포워딩 방법.
|
|
var extra = ''
|
|
if (outcome.reachable !== true) {
|
|
if (outcome.cgnat) {
|
|
extra += '<p class="formMessage error" style="margin-top:8px;">' +
|
|
escapeHtml(tt('main.cgnatWarn', { ip: outcome.externalIp || '?' })) + '</p>'
|
|
}
|
|
var manual = outcome.localIp
|
|
? tt('main.manualForward', { port: outcome.port, localIp: outcome.localIp })
|
|
: tt('main.manualForwardNoIp', { port: outcome.port })
|
|
extra += '<p class="formMessage" style="margin-top:8px;">' + escapeHtml(manual) + '</p>'
|
|
}
|
|
|
|
resultEl.innerHTML =
|
|
'<div class="progressCard ' + (cls === 'ok' ? 'done' : cls === 'fail' ? 'error' : 'running') + '" style="margin-top:14px;">' +
|
|
' <div class="cardTop"><span class="statusBadge ' + cls + '">' + escapeHtml(badge) + '</span> ' +
|
|
' <span class="label">' + escapeHtml(addr) + '</span></div>' +
|
|
' <p class="formMessage" style="margin-top:8px;">' + escapeHtml(msg) + '</p>' +
|
|
extra +
|
|
' <p class="formMessage"><small>' + escapeHtml(tt('main.detailLabel', { detail: outcome.detail || '' })) + '</small></p>' +
|
|
'</div>'
|
|
}
|
|
|
|
openBtn.addEventListener('click', function () {
|
|
var port = parsePort()
|
|
portEl.value = String(port)
|
|
setBusy(true)
|
|
resultEl.innerHTML = '<p class="formMessage" style="margin-top:14px;">' + escapeHtml(tt('main.working')) + '</p>'
|
|
api.openPort(port).then(function (outcome) {
|
|
showResult(outcome)
|
|
}).catch(function (err) {
|
|
resultEl.innerHTML = '<p class="formMessage error" style="margin-top:14px;">' +
|
|
escapeHtml(tt('main.error', { message: (err && err.message) || String(err) })) + '</p>'
|
|
}).then(function () {
|
|
setBusy(false)
|
|
})
|
|
})
|
|
|
|
closeBtn.addEventListener('click', function () {
|
|
var port = parsePort()
|
|
portEl.value = String(port)
|
|
setBusy(true)
|
|
api.closePort(port).then(function () {
|
|
resultEl.innerHTML = '<p class="formMessage" style="margin-top:14px;">' +
|
|
escapeHtml(tt('main.closed', { port: port })) + '</p>'
|
|
}).then(function () {
|
|
setBusy(false)
|
|
})
|
|
})
|
|
|
|
quitBtn.addEventListener('click', function () { api.quit() })
|
|
}
|
|
|
|
;(async function () {
|
|
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
|
applyStaticI18n()
|
|
renderMain()
|
|
})()
|