Refactor launcher profiles and port automation
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled

This commit is contained in:
2026-05-05 21:52:17 +09:00
parent e266387784
commit 9786cfe031
22 changed files with 1558 additions and 798 deletions

View File

@@ -0,0 +1,299 @@
const childProcess = require('child_process')
const natUpnp = require('nat-upnp')
const ConfigManager = require('./configmanager')
const RULE_PREFIX = 'MRS Launcher Auto Port'
const UPnP_DESCRIPTION_PREFIX = 'MRS Launcher'
const UPnP_TTL_SECONDS = 3600
const states = new Map()
function buildState(profileId, port){
return {
profileId,
port,
statusCode: 'checking',
summary: '포트 확인 중',
message: '포트 개방 가능 여부를 확인하고 있습니다.',
tone: 'info',
firewallCreated: false,
ruleName: null,
upnpCreated: false,
checkedAt: Date.now()
}
}
function toPublicState(state){
return {
port: state.port,
statusCode: state.statusCode,
summary: state.summary,
message: state.message,
tone: state.tone,
checkedAt: state.checkedAt
}
}
function createClient(){
return natUpnp.createClient()
}
function clientGetMappings(client){
return new Promise((resolve, reject) => {
client.getMappings((error, results) => {
if(error){
reject(error)
return
}
resolve(Array.isArray(results) ? results : [])
})
})
}
function clientPortMapping(client, options){
return new Promise((resolve, reject) => {
client.portMapping(options, (error) => {
if(error){
reject(error)
return
}
resolve()
})
})
}
function clientPortUnmapping(client, options){
return new Promise((resolve, reject) => {
client.portUnmapping(options, (error) => {
if(error){
reject(error)
return
}
resolve()
})
})
}
function extractPort(mapping){
if(mapping == null){
return null
}
if(typeof mapping.public === 'number'){
return mapping.public
}
if(typeof mapping.publicPort === 'number'){
return mapping.publicPort
}
if(typeof mapping.port === 'number'){
return mapping.port
}
if(mapping.public != null && typeof mapping.public === 'object' && typeof mapping.public.port === 'number'){
return mapping.public.port
}
return null
}
function extractProtocol(mapping){
if(mapping == null){
return ''
}
const protocol = mapping.protocol ?? mapping.type ?? mapping.public?.protocol ?? ''
return String(protocol).toUpperCase()
}
async function findExistingTcpMapping(port){
const client = createClient()
const mappings = await clientGetMappings(client)
return mappings.find((mapping) => extractPort(mapping) === port && (extractProtocol(mapping) === '' || extractProtocol(mapping) === 'TCP')) ?? null
}
function getFirewallRuleName(profileId, port){
return `${RULE_PREFIX} ${profileId} ${port}`
}
function ensureWindowsFirewallRule(profileId, port){
if(process.platform !== 'win32'){
return {
created: false,
ruleName: null
}
}
const ruleName = getFirewallRuleName(profileId, port)
const result = childProcess.spawnSync('netsh', [
'advfirewall',
'firewall',
'add',
'rule',
`name=${ruleName}`,
'dir=in',
'action=allow',
'protocol=TCP',
`localport=${port}`
], {
encoding: 'utf8'
})
return {
created: result.status === 0,
ruleName
}
}
function deleteWindowsFirewallRule(ruleName){
if(process.platform !== 'win32' || !ruleName){
return
}
childProcess.spawnSync('netsh', [
'advfirewall',
'firewall',
'delete',
'rule',
`name=${ruleName}`
], {
encoding: 'utf8'
})
}
function buildPortFailureMessage(error){
const baseMessage = '자동 포트 개방 실패. 직접 포트포워딩 해주세요.'
if(error == null){
return baseMessage
}
const errorMessage = typeof error === 'string'
? error
: (error.message ?? String(error))
return `${baseMessage} ${errorMessage}`
}
exports.ensurePortAvailability = async function(profile){
if(profile?.serverEnabled !== true){
return {
port: null,
statusCode: 'not-needed',
summary: '포트 개방 필요 없음',
message: '이 프로필은 서버를 사용하지 않습니다.',
tone: 'info',
checkedAt: Date.now()
}
}
const manualAddress = ConfigManager.getLibraryServerAddressOverride(profile.id)
if(manualAddress != null && manualAddress.length > 0){
await exports.releaseProfilePort(profile.id)
return {
port: profile.serverPort ?? 25565,
statusCode: 'manual-address',
summary: '접속 주소 사용 중',
message: `직접 입력한 접속 주소가 설정되어 있어 자동 포트 개방을 건너뜁니다. (${manualAddress})`,
tone: 'info',
checkedAt: Date.now()
}
}
const port = profile.serverPort ?? 25565
const currentState = states.get(profile.id)
if(currentState != null && currentState.port === port && currentState.statusCode !== 'failed'){
currentState.checkedAt = Date.now()
return toPublicState(currentState)
}
const state = buildState(profile.id, port)
states.set(profile.id, state)
const firewallState = ensureWindowsFirewallRule(profile.id, port)
state.firewallCreated = firewallState.created
state.ruleName = firewallState.ruleName
try {
const existingMapping = await findExistingTcpMapping(port)
if(existingMapping != null){
state.statusCode = 'already-open'
state.summary = '이미 포트가 열려있습니다.'
state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.`
state.tone = 'success'
state.checkedAt = Date.now()
return toPublicState(state)
}
const client = createClient()
await clientPortMapping(client, {
public: port,
private: port,
protocol: 'TCP',
ttl: UPnP_TTL_SECONDS,
description: `${UPnP_DESCRIPTION_PREFIX} ${profile.id}`
})
state.upnpCreated = true
state.statusCode = 'opened'
state.summary = '자동 포트 개방 성공'
state.message = `UPnP로 TCP ${port} 포트를 열었습니다.${state.firewallCreated ? ' 윈도우 방화벽 허용도 같이 적용했습니다.' : ''}`
state.tone = 'success'
} catch (error) {
const message = typeof error?.message === 'string' ? error.message : String(error)
if(/ConflictInMappingEntry|Already/i.test(message)){
state.statusCode = 'already-open'
state.summary = '이미 포트가 열려있습니다.'
state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.`
state.tone = 'success'
} else {
state.statusCode = 'failed'
state.summary = '자동 포트 개방 실패'
state.message = buildPortFailureMessage(error)
state.tone = 'error'
}
}
state.checkedAt = Date.now()
return toPublicState(state)
}
exports.getPortAvailabilityState = function(profileId){
const state = states.get(profileId)
return state != null ? toPublicState(state) : null
}
exports.releaseProfilePort = async function(profileId){
const state = states.get(profileId)
if(state == null){
return
}
if(state.upnpCreated){
try {
const client = createClient()
await clientPortUnmapping(client, {
public: state.port,
protocol: 'TCP'
})
} catch (error) {
void error
}
}
if(state.firewallCreated && state.ruleName){
deleteWindowsFirewallRule(state.ruleName)
}
states.delete(profileId)
}
exports.cleanupAll = async function(){
await Promise.all(Array.from(states.keys()).map((profileId) => exports.releaseProfilePort(profileId)))
}
process.once('exit', () => {
for(const state of states.values()){
if(state.firewallCreated && state.ruleName){
deleteWindowsFirewallRule(state.ruleName)
}
}
})