Refactor launcher profiles and port automation
This commit is contained in:
@@ -16,42 +16,97 @@ function normalizeNullableText(value){
|
||||
return nextValue.length > 0 ? nextValue : null
|
||||
}
|
||||
|
||||
function normalizeBoolean(value){
|
||||
return value === true
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback, minimum = 1){
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10)
|
||||
if(Number.isFinite(parsed) && parsed >= minimum){
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function deriveFeatureFlags(rawProfile){
|
||||
const legacyKind = normalizeText(rawProfile?.kind)
|
||||
|
||||
let modsEnabled = normalizeBoolean(rawProfile?.modsEnabled)
|
||||
let pluginsEnabled = normalizeBoolean(rawProfile?.pluginsEnabled)
|
||||
let serverEnabled = normalizeBoolean(rawProfile?.serverEnabled)
|
||||
|
||||
if(legacyKind === 'modpack'){
|
||||
modsEnabled = true
|
||||
} else if(legacyKind === 'server-pack'){
|
||||
pluginsEnabled = true
|
||||
serverEnabled = true
|
||||
}
|
||||
|
||||
if(pluginsEnabled){
|
||||
serverEnabled = true
|
||||
}
|
||||
|
||||
return {
|
||||
modsEnabled,
|
||||
pluginsEnabled,
|
||||
serverEnabled
|
||||
}
|
||||
}
|
||||
|
||||
function deriveLegacyKind(flags){
|
||||
if(flags.serverEnabled){
|
||||
return 'server-pack'
|
||||
}
|
||||
if(flags.modsEnabled){
|
||||
return 'modpack'
|
||||
}
|
||||
return 'map'
|
||||
}
|
||||
|
||||
function resolveLegacyServerJar(rawProfile){
|
||||
const directValue = normalizeNullableText(rawProfile?.serverJarUrl)
|
||||
if(directValue != null){
|
||||
return directValue
|
||||
}
|
||||
|
||||
const legacyBundle = normalizeNullableText(rawProfile?.serverBundleUrl)
|
||||
if(legacyBundle != null && legacyBundle.toLowerCase().endsWith('.jar')){
|
||||
return legacyBundle
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toStoredProfile(rawProfile){
|
||||
const kind = normalizeText(rawProfile.kind) || 'modpack'
|
||||
const flags = deriveFeatureFlags(rawProfile)
|
||||
const storedProfile = {
|
||||
id: normalizeText(rawProfile.id),
|
||||
name: normalizeText(rawProfile.name),
|
||||
kind,
|
||||
kind: deriveLegacyKind(flags),
|
||||
description: normalizeText(rawProfile.description),
|
||||
details: normalizeText(rawProfile.details),
|
||||
distributionUrl: normalizeNullableText(rawProfile.distributionUrl),
|
||||
defaultServerAddress: normalizeText(rawProfile.defaultServerAddress),
|
||||
allowCustomServerAddress: rawProfile.allowCustomServerAddress === true,
|
||||
modsEnabled: flags.modsEnabled,
|
||||
pluginsEnabled: flags.pluginsEnabled,
|
||||
serverEnabled: flags.serverEnabled,
|
||||
worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl),
|
||||
worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName),
|
||||
serverBundleUrl: normalizeNullableText(rawProfile.serverBundleUrl),
|
||||
serverJarUrl: resolveLegacyServerJar(rawProfile),
|
||||
serverDirectoryName: normalizeText(rawProfile.serverDirectoryName) || 'server',
|
||||
serverLaunchCommand: normalizeNullableText(rawProfile.serverLaunchCommand),
|
||||
serverWorkingDirectory: normalizeNullableText(rawProfile.serverWorkingDirectory),
|
||||
serverPort: Number.isFinite(Number(rawProfile.serverPort)) ? Number(rawProfile.serverPort) : 25565,
|
||||
tunnelCommand: normalizeNullableText(rawProfile.tunnelCommand),
|
||||
tunnelAddressRegex: normalizeNullableText(rawProfile.tunnelAddressRegex),
|
||||
serverMemoryMb: normalizePositiveInteger(rawProfile.serverMemoryMb, 4096, 512),
|
||||
serverMaxPlayers: normalizePositiveInteger(rawProfile.serverMaxPlayers, 20, 1),
|
||||
serverWhitelistEnabled: normalizeBoolean(rawProfile.serverWhitelistEnabled),
|
||||
artwork: normalizeText(rawProfile.artwork)
|
||||
}
|
||||
|
||||
if(kind !== 'map'){
|
||||
storedProfile.worldArchiveUrl = null
|
||||
storedProfile.worldDirectoryName = null
|
||||
}
|
||||
|
||||
if(kind !== 'server-pack'){
|
||||
storedProfile.serverBundleUrl = null
|
||||
if(!storedProfile.serverEnabled){
|
||||
storedProfile.serverJarUrl = null
|
||||
storedProfile.serverDirectoryName = 'server'
|
||||
storedProfile.serverLaunchCommand = null
|
||||
storedProfile.serverWorkingDirectory = null
|
||||
storedProfile.serverPort = 25565
|
||||
storedProfile.tunnelCommand = null
|
||||
storedProfile.tunnelAddressRegex = null
|
||||
storedProfile.serverMemoryMb = 4096
|
||||
storedProfile.serverMaxPlayers = 20
|
||||
storedProfile.serverWhitelistEnabled = false
|
||||
}
|
||||
|
||||
return storedProfile
|
||||
@@ -66,21 +121,19 @@ function normalizeProfile(rawProfile, sourceType = 'catalog'){
|
||||
launchIssues.push('distribution URL이 필요합니다.')
|
||||
}
|
||||
|
||||
if(storedProfile.kind === 'map'){
|
||||
if(storedProfile.worldArchiveUrl == null){
|
||||
launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.')
|
||||
}
|
||||
if(storedProfile.worldDirectoryName == null){
|
||||
launchIssues.push('월드 폴더 이름이 필요합니다.')
|
||||
}
|
||||
if(storedProfile.worldArchiveUrl == null){
|
||||
launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.')
|
||||
}
|
||||
if(storedProfile.worldDirectoryName == null){
|
||||
launchIssues.push('월드 폴더 이름이 필요합니다.')
|
||||
}
|
||||
|
||||
if(storedProfile.kind === 'server-pack' && storedProfile.serverBundleUrl == null){
|
||||
hostIssues.push('로컬 호스팅을 하려면 서버 번들 ZIP 또는 디렉터리 경로가 필요합니다.')
|
||||
if(storedProfile.serverEnabled && storedProfile.serverJarUrl == null){
|
||||
hostIssues.push('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
|
||||
}
|
||||
|
||||
const launchReady = launchIssues.length === 0
|
||||
const hostReady = storedProfile.kind === 'server-pack' ? hostIssues.length === 0 : false
|
||||
const hostReady = storedProfile.serverEnabled ? hostIssues.length === 0 : false
|
||||
|
||||
return {
|
||||
...storedProfile,
|
||||
@@ -147,7 +200,7 @@ exports.getInstalledProfiles = async function(){
|
||||
const catalog = await exports.loadCatalog()
|
||||
const installedProfiles = ConfigManager.getInstalledLibraryProfiles()
|
||||
|
||||
return installedProfiles.map((installedProfile) => {
|
||||
const mergedProfiles = installedProfiles.map((installedProfile) => {
|
||||
const latestProfile = catalog.profiles.find((profile) => profile.id === installedProfile.id)
|
||||
return latestProfile != null
|
||||
? {
|
||||
@@ -157,6 +210,11 @@ exports.getInstalledProfiles = async function(){
|
||||
}
|
||||
: installedProfile
|
||||
})
|
||||
|
||||
ConfigManager.setInstalledLibraryProfiles(mergedProfiles)
|
||||
ConfigManager.save()
|
||||
|
||||
return mergedProfiles
|
||||
}
|
||||
|
||||
exports.installProfile = async function(profileId){
|
||||
@@ -214,10 +272,6 @@ exports.resolveServerAddress = function(profile){
|
||||
return manualAddress
|
||||
}
|
||||
|
||||
if(typeof profile.defaultServerAddress === 'string' && profile.defaultServerAddress.length > 0){
|
||||
return profile.defaultServerAddress
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -226,6 +280,14 @@ exports.setServerAddressOverride = function(profileId, address){
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.shouldHostLocally = function(profile){
|
||||
if(profile == null || profile.serverEnabled !== true){
|
||||
return false
|
||||
}
|
||||
|
||||
return exports.resolveServerAddress(profile) == null
|
||||
}
|
||||
|
||||
exports.applyConfiguredProfile = function(){
|
||||
const selectedProfile = exports.getSelectedProfileSync()
|
||||
const distributionUrl = selectedProfile?.distributionUrl ?? DEFAULT_REMOTE_DISTRO_URL
|
||||
|
||||
299
app/assets/js/portmanager.js
Normal file
299
app/assets/js/portmanager.js
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,8 @@ const { Type } = require('helios-distribution-types')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const ConfigManager = require('./configmanager')
|
||||
const ServerRuntime = require('./serverruntime')
|
||||
|
||||
const logger = LoggerUtil.getLogger('ProcessBuilder')
|
||||
|
||||
@@ -366,11 +367,14 @@ class ProcessBuilder {
|
||||
|
||||
_processAutoConnectArg(args){
|
||||
const selectedProfileId = ConfigManager.getSelectedLibraryProfile()
|
||||
const selectedProfile = selectedProfileId != null
|
||||
? ConfigManager.getInstalledLibraryProfile(selectedProfileId)
|
||||
: null
|
||||
const quickPlayWorld = selectedProfileId != null
|
||||
? ConfigManager.getLibraryQuickPlayWorld(selectedProfileId)
|
||||
: null
|
||||
|
||||
if(quickPlayWorld){
|
||||
if(quickPlayWorld && selectedProfile?.serverEnabled !== true){
|
||||
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
|
||||
args.push('--quickPlaySingleplayer')
|
||||
args.push(quickPlayWorld)
|
||||
@@ -379,14 +383,25 @@ class ProcessBuilder {
|
||||
}
|
||||
|
||||
if(ConfigManager.getAutoConnect() && this.server.rawServer.autoconnect){
|
||||
const serverAddressOverride = selectedProfileId != null
|
||||
let serverAddressOverride = selectedProfileId != null
|
||||
? resolveServerAddressOverride(
|
||||
ConfigManager.getLibraryServerAddressOverride(selectedProfileId),
|
||||
this.server.port
|
||||
)
|
||||
: null
|
||||
|
||||
if(serverAddressOverride == null && selectedProfile?.serverEnabled === true){
|
||||
const hostState = ServerRuntime.getHostedProfileState(selectedProfileId)
|
||||
if(hostState.running){
|
||||
serverAddressOverride = {
|
||||
hostname: '127.0.0.1',
|
||||
port: selectedProfile.serverPort ?? this.server.port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = serverAddressOverride?.hostname ?? this.server.hostname
|
||||
const port = serverAddressOverride?.port ?? this.server.port
|
||||
const port = serverAddressOverride?.port ?? (selectedProfile?.serverEnabled === true ? (selectedProfile.serverPort ?? this.server.port) : this.server.port)
|
||||
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
|
||||
args.push('--quickPlayMultiplayer')
|
||||
args.push(`${hostname}:${port}`)
|
||||
|
||||
@@ -53,7 +53,7 @@ async function downloadSourceToFile(source, destination){
|
||||
const localSource = resolveLocalSource(source)
|
||||
const localStat = await fs.stat(localSource)
|
||||
if(localStat.isDirectory()){
|
||||
throw new Error(`Directory source is not supported for archive cache: ${localSource}`)
|
||||
throw new Error(`Directory source is not supported for file cache: ${localSource}`)
|
||||
}
|
||||
await fs.copy(localSource, destination, { overwrite: true })
|
||||
return destination
|
||||
@@ -83,21 +83,38 @@ async function extractZipToDirectory(zipPath, destination){
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCachedArchive(profileId, source, fileName){
|
||||
async function ensureCachedFile(profileId, source, fileName){
|
||||
const cachePath = getProfileCacheFile(profileId, fileName)
|
||||
await downloadSourceToFile(source, cachePath)
|
||||
return cachePath
|
||||
}
|
||||
|
||||
async function ensureServerWorldInstalled(profile, serverDirectory){
|
||||
if(!profile.worldArchiveUrl){
|
||||
return null
|
||||
}
|
||||
|
||||
const targetWorldDirectory = path.join(serverDirectory, 'world')
|
||||
if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){
|
||||
const cachePath = await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
await extractZipToDirectory(cachePath, targetWorldDirectory)
|
||||
} else {
|
||||
await fs.remove(targetWorldDirectory)
|
||||
await fs.copy(resolveLocalSource(profile.worldArchiveUrl), targetWorldDirectory, { overwrite: true })
|
||||
}
|
||||
|
||||
return targetWorldDirectory
|
||||
}
|
||||
|
||||
exports.getProfileBaseDirectory = getProfileBaseDirectory
|
||||
exports.getServerBundleDirectory = getServerBundleDirectory
|
||||
|
||||
exports.prefetchProfileAssets = async function(profile){
|
||||
if(profile.worldArchiveUrl){
|
||||
await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
}
|
||||
if(profile.serverBundleUrl){
|
||||
await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip')
|
||||
if(profile.serverJarUrl){
|
||||
await ensureCachedFile(profile.id, profile.serverJarUrl, 'server.jar')
|
||||
}
|
||||
|
||||
const currentState = ConfigManager.getLibraryProfileAssetState(profile.id)
|
||||
@@ -118,7 +135,7 @@ exports.ensureWorldInstalled = async function(profile, serverId){
|
||||
|
||||
await fs.ensureDir(savesDirectory)
|
||||
if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){
|
||||
const cachePath = await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
const cachePath = await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
await extractZipToDirectory(cachePath, targetWorldDirectory)
|
||||
} else {
|
||||
await fs.remove(targetWorldDirectory)
|
||||
@@ -136,33 +153,36 @@ exports.ensureWorldInstalled = async function(profile, serverId){
|
||||
return targetWorldDirectory
|
||||
}
|
||||
|
||||
exports.ensureServerBundleInstalled = async function(profile){
|
||||
if(!profile.serverBundleUrl){
|
||||
exports.ensureServerJarInstalled = async function(profile){
|
||||
if(!profile.serverJarUrl){
|
||||
return null
|
||||
}
|
||||
|
||||
const targetDirectory = getServerBundleDirectory(profile)
|
||||
if(isRemoteSource(profile.serverBundleUrl) || profile.serverBundleUrl.endsWith('.zip') || profile.serverBundleUrl.startsWith('file://')){
|
||||
const cachePath = await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip')
|
||||
await extractZipToDirectory(cachePath, targetDirectory)
|
||||
} else {
|
||||
await fs.remove(targetDirectory)
|
||||
await fs.copy(resolveLocalSource(profile.serverBundleUrl), targetDirectory, { overwrite: true })
|
||||
}
|
||||
const targetJarPath = path.join(targetDirectory, 'server.jar')
|
||||
await fs.ensureDir(targetDirectory)
|
||||
|
||||
const cachePath = await ensureCachedFile(profile.id, profile.serverJarUrl, 'server.jar')
|
||||
await fs.copy(cachePath, targetJarPath, { overwrite: true })
|
||||
await ensureServerWorldInstalled(profile, targetDirectory)
|
||||
|
||||
ConfigManager.setLibraryProfileAssetState(profile.id, {
|
||||
serverBundleInstalledAt: new Date().toISOString(),
|
||||
serverBundleDirectory: targetDirectory
|
||||
serverInstalledAt: new Date().toISOString(),
|
||||
serverDirectory: targetDirectory,
|
||||
serverJarPath: targetJarPath
|
||||
})
|
||||
ConfigManager.save()
|
||||
|
||||
return targetDirectory
|
||||
return {
|
||||
serverDirectory: targetDirectory,
|
||||
serverJarPath: targetJarPath
|
||||
}
|
||||
}
|
||||
|
||||
exports.ensureServerBundleInstalled = exports.ensureServerJarInstalled
|
||||
|
||||
exports.prepareProfileForLaunch = async function(profile, serverId){
|
||||
if(profile.kind === 'map'){
|
||||
return exports.ensureWorldInstalled(profile, serverId)
|
||||
}
|
||||
|
||||
return null
|
||||
return exports.ensureWorldInstalled(profile, serverId)
|
||||
}
|
||||
|
||||
exports.ensureServerWorldInstalled = ensureServerWorldInstalled
|
||||
|
||||
@@ -8,18 +8,6 @@ const installPageShell = document.querySelector('#installContainer .launcherPage
|
||||
|
||||
let expandedProfileId = null
|
||||
|
||||
function describeProfileKind(kind){
|
||||
switch(kind){
|
||||
case 'map':
|
||||
return '오리지널 맵'
|
||||
case 'server-pack':
|
||||
return '플러그인 맵 + 서버팩'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '모드팩'
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallBadge(text){
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'launcherBadge'
|
||||
@@ -27,6 +15,20 @@ function createInstallBadge(text){
|
||||
return badge
|
||||
}
|
||||
|
||||
function describeProfileFeatures(profile){
|
||||
const features = ['맵']
|
||||
if(profile.modsEnabled){
|
||||
features.push('모드')
|
||||
}
|
||||
if(profile.pluginsEnabled){
|
||||
features.push('플러그인')
|
||||
}
|
||||
if(profile.serverEnabled){
|
||||
features.push('서버')
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
function createInfoLine(label, value){
|
||||
const line = document.createElement('div')
|
||||
line.className = 'launcherInfoLine'
|
||||
@@ -57,26 +59,30 @@ function buildDetailText(profile){
|
||||
return profile.details.trim()
|
||||
}
|
||||
|
||||
switch(profile.kind){
|
||||
case 'map':
|
||||
return '이 프로필은 싱글플레이 월드를 바로 실행하기 위한 항목입니다. 필요한 클라이언트 배포 파일과 월드 자료는 관리자가 미리 등록해둡니다.'
|
||||
case 'server-pack':
|
||||
return '이 프로필은 서버 실행/접속 흐름을 함께 다루는 항목입니다. 클라이언트 파일과 서버 번들은 관리자가 미리 등록하며, 사용자는 라이브러리에서 실행과 접속만 진행합니다.'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '이 프로필은 일반 모드팩 클라이언트입니다. 필요한 배포 파일은 관리자가 미리 등록하며, 사용자는 라이브러리에 추가한 뒤 실행만 하면 됩니다.'
|
||||
if(profile.serverEnabled){
|
||||
return '이 프로필은 맵을 기본으로 두고 서버 기능까지 함께 사용하는 항목입니다. 주소를 직접 넣으면 해당 서버로 접속하고, 주소를 비워두면 로컬 서버 실행 흐름을 사용할 수 있습니다.'
|
||||
}
|
||||
|
||||
if(profile.modsEnabled){
|
||||
return '이 프로필은 맵 기반 클라이언트에 모드 구성을 포함한 항목입니다. 관리자가 distribution과 월드 자료를 미리 등록해두고, 사용자는 라이브러리에 추가한 뒤 바로 실행합니다.'
|
||||
}
|
||||
|
||||
return '이 프로필은 맵 기반 기본 항목입니다. 관리자가 distribution과 월드 자료를 미리 등록해두고, 사용자는 라이브러리에 추가한 뒤 바로 실행합니다.'
|
||||
}
|
||||
|
||||
function toggleExpandedProfile(profileId){
|
||||
expandedProfileId = expandedProfileId === profileId ? null : profileId
|
||||
}
|
||||
|
||||
function isInstallable(profile){
|
||||
return profile.launchReady
|
||||
}
|
||||
|
||||
async function installProfile(profile){
|
||||
const installedProfile = await CatalogManager.installProfile(profile.id)
|
||||
await ProfileAssetManager.prefetchProfileAssets(installedProfile)
|
||||
if(installedProfile.kind === 'server-pack' && installedProfile.hostReady){
|
||||
await ProfileAssetManager.ensureServerBundleInstalled(installedProfile)
|
||||
if(installedProfile.serverEnabled && installedProfile.hostReady){
|
||||
await ProfileAssetManager.ensureServerJarInstalled(installedProfile)
|
||||
}
|
||||
|
||||
if(typeof refreshSelectedProfileButton === 'function'){
|
||||
@@ -99,36 +105,38 @@ function createExpandedDetail(profile, installed){
|
||||
|
||||
const badgeRow = document.createElement('div')
|
||||
badgeRow.className = 'launcherExpandableMeta'
|
||||
badgeRow.appendChild(createInstallBadge(describeProfileKind(profile.kind)))
|
||||
describeProfileFeatures(profile).forEach((label) => {
|
||||
badgeRow.appendChild(createInstallBadge(label))
|
||||
})
|
||||
if(installed){
|
||||
badgeRow.appendChild(createInstallBadge('설치됨'))
|
||||
}
|
||||
if(!profile.launchReady){
|
||||
badgeRow.appendChild(createInstallBadge('실행 준비 필요'))
|
||||
if(profile.serverEnabled && profile.hostReady){
|
||||
badgeRow.appendChild(createInstallBadge('로컬 서버 가능'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
badgeRow.appendChild(createInstallBadge('호스팅 가능'))
|
||||
if(!profile.launchReady){
|
||||
badgeRow.appendChild(createInstallBadge('설정 필요'))
|
||||
}
|
||||
|
||||
const infoBlock = document.createElement('div')
|
||||
infoBlock.className = 'launcherInfoBlock'
|
||||
infoBlock.appendChild(createInfoLine('프로필 ID', profile.id))
|
||||
infoBlock.appendChild(createInfoLine('종류', describeProfileKind(profile.kind)))
|
||||
infoBlock.appendChild(createInfoLine('구성', describeProfileFeatures(profile).join(' + ')))
|
||||
infoBlock.appendChild(createInfoLine('실행 준비', profile.launchReady ? '완료' : '추가 설정 필요'))
|
||||
infoBlock.appendChild(createInfoLine('월드 폴더', profile.worldDirectoryName || '미설정'))
|
||||
|
||||
if(profile.defaultServerAddress){
|
||||
infoBlock.appendChild(createInfoLine('기본 주소', profile.defaultServerAddress))
|
||||
}
|
||||
if(profile.kind === 'map' && profile.worldDirectoryName){
|
||||
infoBlock.appendChild(createInfoLine('월드 폴더', profile.worldDirectoryName))
|
||||
}
|
||||
if(profile.kind === 'server-pack'){
|
||||
infoBlock.appendChild(createInfoLine('로컬 호스팅', profile.hostReady ? '가능' : '관리자 설정 필요'))
|
||||
if(profile.serverEnabled){
|
||||
infoBlock.appendChild(createInfoLine('서버 포트', String(profile.serverPort ?? 25565)))
|
||||
infoBlock.appendChild(createInfoLine('서버 메모리', `${profile.serverMemoryMb ?? 4096}MB`))
|
||||
infoBlock.appendChild(createInfoLine('최대 인원수', String(profile.serverMaxPlayers ?? 20)))
|
||||
infoBlock.appendChild(createInfoLine('화이트리스트', profile.serverWhitelistEnabled ? '사용' : '미사용'))
|
||||
infoBlock.appendChild(createInfoLine('로컬 서버 준비', profile.hostReady ? '완료' : '버킷 JAR 필요'))
|
||||
}
|
||||
|
||||
if(profile.launchIssues.length > 0){
|
||||
infoBlock.appendChild(createInfoLine('확인 필요', profile.launchIssues.join(' / ')))
|
||||
} else if(profile.hostIssues.length > 0){
|
||||
infoBlock.appendChild(createInfoLine('호스팅 참고', profile.hostIssues.join(' / ')))
|
||||
infoBlock.appendChild(createInfoLine('서버 참고', profile.hostIssues.join(' / ')))
|
||||
}
|
||||
|
||||
const bodyGroup = document.createElement('div')
|
||||
@@ -151,7 +159,7 @@ function createExpandedDetail(profile, installed){
|
||||
const installButton = document.createElement('button')
|
||||
installButton.className = 'launcherPrimaryButton'
|
||||
installButton.textContent = installed ? '설치됨' : '라이브러리에 추가'
|
||||
installButton.disabled = installed || !profile.launchReady
|
||||
installButton.disabled = installed || !isInstallable(profile)
|
||||
installButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation()
|
||||
try {
|
||||
@@ -204,7 +212,7 @@ async function renderInstallView(){
|
||||
if(catalog.sourceError != null){
|
||||
const warningCard = document.createElement('article')
|
||||
warningCard.className = 'launcherCard'
|
||||
warningCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 경고</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다. 현재 보이는 목록이 없다면 배포 주소 또는 로컬 카탈로그 파일을 관리자 측에서 확인해야 합니다.</p>'
|
||||
warningCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 경고</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다. 현재 보이는 목록이 없다면 관리자 사이트에서 카탈로그 파일과 배포 경로를 다시 확인하세요.</p>'
|
||||
installCatalogList.appendChild(warningCard)
|
||||
}
|
||||
|
||||
@@ -236,12 +244,14 @@ async function renderInstallView(){
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'launcherListMeta'
|
||||
meta.appendChild(createInstallBadge(describeProfileKind(profile.kind)))
|
||||
describeProfileFeatures(profile).forEach((label) => {
|
||||
meta.appendChild(createInstallBadge(label))
|
||||
})
|
||||
if(installed){
|
||||
meta.appendChild(createInstallBadge('설치됨'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
meta.appendChild(createInstallBadge('호스팅 가능'))
|
||||
if(profile.serverEnabled && profile.hostReady){
|
||||
meta.appendChild(createInstallBadge('로컬 서버 가능'))
|
||||
}
|
||||
|
||||
const description = document.createElement('p')
|
||||
@@ -260,67 +270,31 @@ async function renderInstallView(){
|
||||
await renderInstallView()
|
||||
})
|
||||
|
||||
const installButton = document.createElement('button')
|
||||
installButton.className = 'launcherPrimaryButton'
|
||||
installButton.textContent = installed ? '설치됨' : '라이브러리에 추가'
|
||||
installButton.disabled = installed || !profile.launchReady
|
||||
installButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation()
|
||||
try {
|
||||
await installProfile(profile)
|
||||
expandedProfileId = profile.id
|
||||
await renderInstallView()
|
||||
showInstallMessage('추가 완료', `${profile.name} 프로필을 라이브러리에 추가했습니다.`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : '프로필 설치 중 오류가 발생했습니다.'
|
||||
showInstallMessage('설치 실패', message)
|
||||
}
|
||||
})
|
||||
|
||||
textGroup.appendChild(title)
|
||||
titleRow.appendChild(textGroup)
|
||||
titleRow.appendChild(meta)
|
||||
main.appendChild(titleRow)
|
||||
main.appendChild(description)
|
||||
|
||||
actions.appendChild(detailButton)
|
||||
actions.appendChild(installButton)
|
||||
|
||||
textGroup.appendChild(title)
|
||||
textGroup.appendChild(meta)
|
||||
titleRow.appendChild(textGroup)
|
||||
top.appendChild(main)
|
||||
top.appendChild(actions)
|
||||
main.appendChild(titleRow)
|
||||
main.appendChild(description)
|
||||
row.appendChild(top)
|
||||
|
||||
if(expanded){
|
||||
row.appendChild(createExpandedDetail(profile, installed))
|
||||
}
|
||||
|
||||
row.addEventListener('click', async () => {
|
||||
toggleExpandedProfile(profile.id)
|
||||
await renderInstallView()
|
||||
})
|
||||
|
||||
installCatalogList.appendChild(row)
|
||||
}
|
||||
|
||||
if(catalog.profiles.length === 0){
|
||||
const emptyCard = document.createElement('article')
|
||||
emptyCard.className = 'launcherCard'
|
||||
emptyCard.innerHTML = '<h3 class="launcherCardTitle">등록된 프로필이 없습니다</h3><p class="launcherCardDescription">관리자가 카탈로그에 프로필을 추가하면 여기에 표시됩니다.</p>'
|
||||
installCatalogList.appendChild(emptyCard)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
const errorCard = document.createElement('article')
|
||||
errorCard.className = 'launcherCard'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 실패</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다.</p>'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">설치 페이지 로드 실패</h3><p class="launcherCardDescription">프로필 목록을 읽지 못했습니다. 관리자 사이트에서 catalog 설정을 확인하세요.</p>'
|
||||
installCatalogList.appendChild(errorCard)
|
||||
} finally {
|
||||
if(installPageShell != null){
|
||||
requestAnimationFrame(() => {
|
||||
installPageShell.scrollTop = previousScrollTop
|
||||
})
|
||||
installPageShell.scrollTop = previousScrollTop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
// Requirements
|
||||
const { URL } = require('url')
|
||||
const {
|
||||
MojangRestAPI,
|
||||
getServerStatus
|
||||
MojangRestAPI
|
||||
} = require('helios-core/mojang')
|
||||
const {
|
||||
RestResponseStatus,
|
||||
@@ -32,7 +31,9 @@ const {
|
||||
const AuthManager = require('./assets/js/authmanager')
|
||||
const CatalogManager = require('./assets/js/catalogmanager')
|
||||
const DiscordWrapper = require('./assets/js/discordwrapper')
|
||||
const PortManager = require('./assets/js/portmanager')
|
||||
const ProcessBuilder = require('./assets/js/processbuilder')
|
||||
const ServerRuntime = require('./assets/js/serverruntime')
|
||||
|
||||
// Launch Elements
|
||||
const launch_content = document.getElementById('launch_content')
|
||||
@@ -47,6 +48,7 @@ const avatarContainer = document.getElementById('avatarContainer')
|
||||
const accountMenu = document.getElementById('accountMenu')
|
||||
const accountMenuName = document.getElementById('accountMenuName')
|
||||
const accountMenuLogoutButton = document.getElementById('accountMenuLogoutButton')
|
||||
const portStatusTooltip = document.getElementById('portStatusTooltip')
|
||||
|
||||
const loggerLanding = LoggerUtil.getLogger('Landing')
|
||||
|
||||
@@ -188,7 +190,7 @@ function refreshSelectedProfileButton(){
|
||||
}
|
||||
|
||||
function isSelectedMapReady(profile){
|
||||
if(profile == null || profile.kind !== 'map'){
|
||||
if(profile == null || profile.serverEnabled === true){
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -200,6 +202,24 @@ function isSelectedMapReady(profile){
|
||||
)
|
||||
}
|
||||
|
||||
function updateLandingStatusDisplay(label, value, tone, tooltip, fade = false){
|
||||
const applyValues = () => {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = label
|
||||
document.getElementById('player_count').innerHTML = value
|
||||
document.getElementById('player_count').dataset.tone = tone
|
||||
portStatusTooltip.textContent = tooltip
|
||||
}
|
||||
|
||||
if(fade){
|
||||
$('#server_status_wrapper').fadeOut(250, () => {
|
||||
applyValues()
|
||||
$('#server_status_wrapper').fadeIn(500)
|
||||
})
|
||||
} else {
|
||||
applyValues()
|
||||
}
|
||||
}
|
||||
|
||||
// Bind launch button
|
||||
document.getElementById('launch_button').addEventListener('click', async e => {
|
||||
loggerLanding.info('Launching game..')
|
||||
@@ -394,70 +414,45 @@ const refreshServerStatus = async (fade = false) => {
|
||||
|
||||
let pLabel = Lang.queryJS('landing.profileStatus.label')
|
||||
let pVal = Lang.queryJS('landing.selectedProfile.noSelection')
|
||||
let pTone = 'info'
|
||||
let tooltip = '라이브러리에서 실행할 프로필을 먼저 선택하세요.'
|
||||
|
||||
if(selectedProfile == null){
|
||||
if(fade){
|
||||
$('#server_status_wrapper').fadeOut(250, () => {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
$('#server_status_wrapper').fadeIn(500)
|
||||
})
|
||||
} else {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
}
|
||||
updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
|
||||
return
|
||||
}
|
||||
|
||||
if(selectedProfile.kind === 'map'){
|
||||
if(selectedProfile.serverEnabled !== true){
|
||||
pLabel = Lang.queryJS('landing.mapStatus.label')
|
||||
pVal = isSelectedMapReady(selectedProfile)
|
||||
? Lang.queryJS('landing.mapStatus.ready')
|
||||
: Lang.queryJS('landing.mapStatus.notReady')
|
||||
|
||||
if(fade){
|
||||
$('#server_status_wrapper').fadeOut(250, () => {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
$('#server_status_wrapper').fadeIn(500)
|
||||
})
|
||||
} else {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
}
|
||||
pTone = selectedProfile.launchReady ? 'success' : 'error'
|
||||
tooltip = selectedProfile.launchReady
|
||||
? '이 프로필은 맵 실행 준비가 끝났습니다.'
|
||||
: (selectedProfile.launchIssues?.join(' / ') || '맵 실행 준비가 아직 끝나지 않았습니다.')
|
||||
updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
|
||||
return
|
||||
}
|
||||
|
||||
pLabel = Lang.queryJS('landing.serverStatus.server')
|
||||
pVal = Lang.queryJS('landing.serverStatus.offline')
|
||||
|
||||
try {
|
||||
const distro = await DistroAPI.getDistribution()
|
||||
const serv = distro?.getServerById(ConfigManager.getSelectedServer())
|
||||
?? (typeof distro?.getMainServer === 'function' ? distro.getMainServer() : null)
|
||||
if(serv == null){
|
||||
throw new Error('No server available for selected profile.')
|
||||
}
|
||||
|
||||
const servStat = await getServerStatus(47, serv.hostname, serv.port)
|
||||
pLabel = Lang.queryJS('landing.serverStatus.players')
|
||||
pVal = servStat.players.online + '/' + servStat.players.max
|
||||
|
||||
pLabel = Lang.queryJS('landing.portStatus.label')
|
||||
const portState = await PortManager.ensurePortAvailability(selectedProfile)
|
||||
pVal = portState.summary
|
||||
pTone = portState.tone
|
||||
tooltip = selectedProfile.hostIssues?.length > 0
|
||||
? `${portState.message} / ${selectedProfile.hostIssues.join(' / ')}`
|
||||
: portState.message
|
||||
} catch (err) {
|
||||
loggerLanding.warn('Unable to refresh server status, assuming offline.')
|
||||
loggerLanding.warn('Unable to refresh port status.')
|
||||
loggerLanding.debug(err)
|
||||
pLabel = Lang.queryJS('landing.portStatus.label')
|
||||
pVal = Lang.queryJS('landing.portStatus.failed')
|
||||
pTone = 'error'
|
||||
tooltip = err instanceof Error ? err.message : '자동 포트 개방 상태를 확인하지 못했습니다.'
|
||||
}
|
||||
if(fade){
|
||||
$('#server_status_wrapper').fadeOut(250, () => {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
$('#server_status_wrapper').fadeIn(500)
|
||||
})
|
||||
} else {
|
||||
document.getElementById('landingPlayerLabel').innerHTML = pLabel
|
||||
document.getElementById('player_count').innerHTML = pVal
|
||||
}
|
||||
|
||||
|
||||
updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
|
||||
}
|
||||
|
||||
refreshMojangStatuses()
|
||||
@@ -468,6 +463,10 @@ let mojangStatusListener = setInterval(() => refreshMojangStatuses(true), 60*60*
|
||||
// Set refresh rate to once every 5 minutes.
|
||||
let serverStatusListener = setInterval(() => refreshServerStatus(true), 300000)
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
PortManager.cleanupAll().catch(() => {})
|
||||
})
|
||||
|
||||
/**
|
||||
* Shows an error overlay, toggles off the launch area.
|
||||
*
|
||||
@@ -747,10 +746,34 @@ async function dlAsync(login = true) {
|
||||
|
||||
if(login) {
|
||||
const authUser = ConfigManager.getSelectedAccount()
|
||||
const selectedProfile = CatalogManager.getSelectedProfileSync()
|
||||
loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`)
|
||||
let pb = new ProcessBuilder(serv, versionData, modLoaderData, authUser, remote.app.getVersion())
|
||||
setLaunchDetails(Lang.queryJS('landing.dlAsync.launchingGame'))
|
||||
|
||||
if(selectedProfile?.serverEnabled === true && CatalogManager.shouldHostLocally(selectedProfile)){
|
||||
if(!selectedProfile.hostReady){
|
||||
showLaunchFailure(
|
||||
Lang.queryJS('landing.localServer.missingJarTitle'),
|
||||
Lang.queryJS('landing.localServer.missingJarText')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setLaunchDetails(Lang.queryJS('landing.localServer.starting'))
|
||||
try {
|
||||
await ServerRuntime.startHostedProfile(selectedProfile)
|
||||
refreshServerStatus(true)
|
||||
} catch (error) {
|
||||
loggerLaunchSuite.error('Failed to start local server.', error)
|
||||
showLaunchFailure(
|
||||
Lang.queryJS('landing.localServer.failureTitle'),
|
||||
error instanceof Error ? error.message : Lang.queryJS('landing.localServer.failureText')
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/
|
||||
const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`)
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
(() => {
|
||||
const { clipboard } = require('electron')
|
||||
|
||||
const CatalogManager = require('./assets/js/catalogmanager')
|
||||
const ConfigManager = require('./assets/js/configmanager')
|
||||
const ProfileAssetManager = require('./assets/js/profileassetmanager')
|
||||
@@ -21,16 +19,18 @@ function createBadge(text){
|
||||
return badge
|
||||
}
|
||||
|
||||
function describeProfileKind(kind){
|
||||
switch(kind){
|
||||
case 'map':
|
||||
return '맵'
|
||||
case 'server-pack':
|
||||
return '서버팩'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '모드팩'
|
||||
function describeProfileFeatures(profile){
|
||||
const features = ['맵']
|
||||
if(profile.modsEnabled){
|
||||
features.push('모드')
|
||||
}
|
||||
if(profile.pluginsEnabled){
|
||||
features.push('플러그인')
|
||||
}
|
||||
if(profile.serverEnabled){
|
||||
features.push('서버')
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
function createParagraph(className, text){
|
||||
@@ -40,23 +40,6 @@ function createParagraph(className, text){
|
||||
return element
|
||||
}
|
||||
|
||||
function createInfoLine(label, value){
|
||||
const line = document.createElement('div')
|
||||
line.className = 'launcherInfoLine'
|
||||
|
||||
const labelElement = document.createElement('span')
|
||||
labelElement.className = 'launcherInfoLabel'
|
||||
labelElement.textContent = label
|
||||
|
||||
const valueElement = document.createElement('span')
|
||||
valueElement.className = 'launcherInfoValue'
|
||||
valueElement.textContent = value
|
||||
|
||||
line.appendChild(labelElement)
|
||||
line.appendChild(valueElement)
|
||||
return line
|
||||
}
|
||||
|
||||
function showLibraryMessage(title, message){
|
||||
if(typeof setOverlayContent === 'function'){
|
||||
setOverlayContent(title, message, '확인')
|
||||
@@ -65,55 +48,24 @@ function showLibraryMessage(title, message){
|
||||
}
|
||||
}
|
||||
|
||||
function describeAssetState(profile){
|
||||
const state = ConfigManager.getLibraryProfileAssetState(profile.id)
|
||||
|
||||
if(profile.kind === 'map'){
|
||||
if(state.worldInstalledAt){
|
||||
return `맵 설치 완료 · ${profile.worldDirectoryName}`
|
||||
}
|
||||
if(profile.worldArchiveUrl){
|
||||
return '맵 아카이브 준비 필요'
|
||||
}
|
||||
}
|
||||
|
||||
if(profile.kind === 'server-pack'){
|
||||
if(state.serverBundleInstalledAt){
|
||||
return '서버 번들 설치 완료'
|
||||
}
|
||||
if(profile.serverBundleUrl){
|
||||
return '서버 번들 준비 필요'
|
||||
}
|
||||
}
|
||||
|
||||
return '추가 자산 없음'
|
||||
}
|
||||
|
||||
function isProfileInstalled(profile){
|
||||
const state = ConfigManager.getLibraryProfileAssetState(profile.id)
|
||||
|
||||
if(profile.kind === 'map'){
|
||||
return state.prefetchedAt != null || profile.worldArchiveUrl == null
|
||||
}
|
||||
|
||||
if(profile.kind === 'server-pack'){
|
||||
return state.serverBundleInstalledAt != null || state.prefetchedAt != null || profile.serverBundleUrl == null
|
||||
}
|
||||
|
||||
return true
|
||||
const mapReady = state.worldInstalledAt != null || state.prefetchedAt != null || profile.worldArchiveUrl == null
|
||||
const serverReady = profile.serverEnabled !== true || profile.hostReady !== true || state.serverInstalledAt != null || state.prefetchedAt != null || profile.serverJarUrl == null
|
||||
return mapReady && serverReady
|
||||
}
|
||||
|
||||
async function prepareProfileAssets(profile){
|
||||
try {
|
||||
await ProfileAssetManager.prefetchProfileAssets(profile)
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
await ProfileAssetManager.ensureServerBundleInstalled(profile)
|
||||
if(profile.serverEnabled && profile.hostReady){
|
||||
await ProfileAssetManager.ensureServerJarInstalled(profile)
|
||||
}
|
||||
await renderLibraryView()
|
||||
showLibraryMessage('자료 준비 완료', `${profile.name} 자료를 준비했습니다.`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showLibraryMessage('자료 준비 실패', '프로필 자료를 내려받거나 해제하는 중 오류가 발생했습니다.')
|
||||
showLibraryMessage('자료 준비 실패', '프로필 자료를 내려받는 중 오류가 발생했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,63 +90,13 @@ async function applyProfileSelection(profile){
|
||||
onDistroRefresh(distro)
|
||||
}
|
||||
|
||||
async function activateProfile(profile, launchNow = false){
|
||||
if(!profile.configured){
|
||||
const firstIssue = profile.launchIssues?.[0] ?? '이 프로필은 아직 실행 조건이 충족되지 않았습니다.'
|
||||
showLibraryMessage('프로필 설정 필요', firstIssue)
|
||||
function appendAddressOverrideField(profile, container){
|
||||
if(profile.serverEnabled !== true){
|
||||
return
|
||||
}
|
||||
|
||||
CatalogManager.selectProfile(profile.id)
|
||||
CatalogManager.applyConfiguredProfile()
|
||||
if(typeof refreshSelectedProfileButton === 'function'){
|
||||
refreshSelectedProfileButton()
|
||||
}
|
||||
|
||||
try {
|
||||
const distro = await DistroAPI.refreshDistributionOrFallback()
|
||||
if(distro == null){
|
||||
throw new Error('Distribution refresh returned null.')
|
||||
}
|
||||
|
||||
const currentServer = distro.getServerById(ConfigManager.getSelectedServer())
|
||||
if(currentServer == null && typeof distro.getMainServer === 'function'){
|
||||
const mainServer = distro.getMainServer()
|
||||
if(mainServer != null){
|
||||
ConfigManager.setSelectedServer(mainServer.rawServer.id)
|
||||
ConfigManager.save()
|
||||
}
|
||||
}
|
||||
|
||||
const selectedServerId = ConfigManager.getSelectedServer()
|
||||
if(selectedServerId != null){
|
||||
await ProfileAssetManager.prepareProfileForLaunch(profile, selectedServerId)
|
||||
}
|
||||
|
||||
onDistroRefresh(distro)
|
||||
|
||||
if(getCurrentView() === VIEWS.landing){
|
||||
if(launchNow){
|
||||
document.getElementById('launch_button').click()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switchView(getCurrentView(), VIEWS.landing, 250, 250, () => {}, () => {
|
||||
if(launchNow){
|
||||
document.getElementById('launch_button').click()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showLibraryMessage('프로필 로드 실패', '선택한 프로필의 distribution.json 또는 부가 자산을 불러오지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function appendAddressOverrideField(profile, fieldGroup){
|
||||
if(!profile.allowCustomServerAddress){
|
||||
return
|
||||
}
|
||||
const fieldGroup = document.createElement('div')
|
||||
fieldGroup.className = 'launcherFieldGroup'
|
||||
|
||||
const label = document.createElement('label')
|
||||
label.className = 'launcherFieldLabel'
|
||||
@@ -203,45 +105,25 @@ function appendAddressOverrideField(profile, fieldGroup){
|
||||
const input = document.createElement('input')
|
||||
input.className = 'launcherFieldInput'
|
||||
input.type = 'text'
|
||||
input.placeholder = profile.defaultServerAddress || 'example.com:25565'
|
||||
input.placeholder = '비워두면 로컬 서버 실행'
|
||||
input.value = ConfigManager.getLibraryServerAddressOverride(profile.id) ?? ''
|
||||
input.addEventListener('change', () => {
|
||||
CatalogManager.setServerAddressOverride(profile.id, input.value)
|
||||
if(typeof refreshServerStatus === 'function'){
|
||||
refreshServerStatus(true)
|
||||
}
|
||||
})
|
||||
|
||||
const help = document.createElement('div')
|
||||
help.className = 'launcherFieldHint'
|
||||
help.textContent = profile.hostReady
|
||||
? '주소를 비워두면 PLAY 시 로컬 서버를 실행하고, 값을 넣으면 해당 주소로 바로 접속합니다.'
|
||||
: '주소를 비워두면 로컬 서버 실행을 시도합니다. 지금은 버킷 JAR이 없어 직접 실행 준비가 부족합니다.'
|
||||
|
||||
fieldGroup.appendChild(label)
|
||||
fieldGroup.appendChild(input)
|
||||
}
|
||||
|
||||
function appendPublishedAddressField(profile, hostState, fieldGroup){
|
||||
if(!hostState.publishedAddress){
|
||||
return
|
||||
}
|
||||
|
||||
const label = document.createElement('label')
|
||||
label.className = 'launcherFieldLabel'
|
||||
label.textContent = '호스트 공개 주소'
|
||||
|
||||
const row = document.createElement('div')
|
||||
row.className = 'launcherInlineField'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.className = 'launcherFieldInput'
|
||||
input.type = 'text'
|
||||
input.readOnly = true
|
||||
input.value = hostState.publishedAddress
|
||||
|
||||
const copyButton = document.createElement('button')
|
||||
copyButton.className = 'launcherSecondaryButton'
|
||||
copyButton.textContent = '주소 복사'
|
||||
copyButton.addEventListener('click', () => {
|
||||
clipboard.writeText(hostState.publishedAddress)
|
||||
})
|
||||
|
||||
row.appendChild(input)
|
||||
row.appendChild(copyButton)
|
||||
fieldGroup.appendChild(label)
|
||||
fieldGroup.appendChild(row)
|
||||
fieldGroup.appendChild(help)
|
||||
container.appendChild(fieldGroup)
|
||||
}
|
||||
|
||||
async function renderLibraryView(){
|
||||
@@ -273,24 +155,20 @@ async function renderLibraryView(){
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'launcherCardMeta'
|
||||
meta.appendChild(createBadge(describeProfileKind(profile.kind)))
|
||||
if(profile.isCustom){
|
||||
meta.appendChild(createBadge('커스텀'))
|
||||
}
|
||||
describeProfileFeatures(profile).forEach((label) => {
|
||||
meta.appendChild(createBadge(label))
|
||||
})
|
||||
if(profile.id === selectedProfileId){
|
||||
meta.appendChild(createBadge('선택됨'))
|
||||
}
|
||||
if(profile.kind === 'map' && profile.worldDirectoryName){
|
||||
meta.appendChild(createBadge(profile.worldDirectoryName))
|
||||
if(!profile.launchReady){
|
||||
meta.appendChild(createBadge('실행 준비 필요'))
|
||||
}
|
||||
if(profile.kind === 'map' && !profile.launchReady){
|
||||
meta.appendChild(createBadge('맵 설정 필요'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && !profile.hostReady){
|
||||
meta.appendChild(createBadge('호스팅 설정 필요'))
|
||||
if(profile.serverEnabled && profile.hostReady){
|
||||
meta.appendChild(createBadge('로컬 서버 가능'))
|
||||
}
|
||||
if(hostState.running){
|
||||
meta.appendChild(createBadge(hostState.tunneling ? '서버+터널' : '서버 실행 중'))
|
||||
meta.appendChild(createBadge(hostState.ready ? '서버 실행 중' : '서버 시작 중'))
|
||||
}
|
||||
|
||||
titleGroup.appendChild(title)
|
||||
@@ -299,13 +177,17 @@ async function renderLibraryView(){
|
||||
|
||||
const description = createParagraph('launcherCardDescription', profile.description || '설명이 없습니다.')
|
||||
|
||||
const extra = document.createElement('div')
|
||||
extra.className = 'launcherCardContent'
|
||||
appendAddressOverrideField(profile, extra)
|
||||
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'launcherCardActions'
|
||||
|
||||
const installButton = document.createElement('button')
|
||||
installButton.className = 'launcherSecondaryButton'
|
||||
installButton.textContent = isProfileInstalled(profile) ? '설치됨' : '설치'
|
||||
installButton.disabled = isProfileInstalled(profile) || !profile.configured
|
||||
installButton.disabled = isProfileInstalled(profile) || !profile.launchReady
|
||||
installButton.addEventListener('click', async () => {
|
||||
await prepareProfileAssets(profile)
|
||||
})
|
||||
@@ -330,14 +212,11 @@ async function renderLibraryView(){
|
||||
}
|
||||
})
|
||||
|
||||
actions.appendChild(installButton)
|
||||
actions.appendChild(selectButton)
|
||||
|
||||
const removeButton = document.createElement('button')
|
||||
removeButton.className = 'launcherGhostButton'
|
||||
removeButton.textContent = '제거'
|
||||
removeButton.addEventListener('click', async () => {
|
||||
ServerRuntime.stopHostedProfile(profile.id)
|
||||
await ServerRuntime.stopHostedProfile(profile.id)
|
||||
CatalogManager.removeProfile(profile.id)
|
||||
if(typeof refreshSelectedProfileButton === 'function'){
|
||||
refreshSelectedProfileButton()
|
||||
@@ -351,10 +230,15 @@ async function renderLibraryView(){
|
||||
}
|
||||
})
|
||||
|
||||
actions.appendChild(installButton)
|
||||
actions.appendChild(selectButton)
|
||||
actions.appendChild(removeButton)
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(description)
|
||||
if(profile.serverEnabled){
|
||||
card.appendChild(extra)
|
||||
}
|
||||
card.appendChild(actions)
|
||||
libraryList.appendChild(card)
|
||||
}
|
||||
@@ -363,7 +247,7 @@ async function renderLibraryView(){
|
||||
renderLibraryEmptyState(false)
|
||||
const errorCard = document.createElement('article')
|
||||
errorCard.className = 'launcherCard'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">라이브러리 로드 실패</h3><p class="launcherCardDescription">선택한 카탈로그를 읽지 못했습니다. 설치 페이지에서 카탈로그 경로를 다시 확인하세요.</p>'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">라이브러리 로드 실패</h3><p class="launcherCardDescription">선택한 카탈로그를 읽지 못했습니다. 관리자 사이트에서 카탈로그 경로를 다시 확인하세요.</p>'
|
||||
libraryList.appendChild(errorCard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,25 @@ const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const PortManager = require('./portmanager')
|
||||
const ProfileAssetManager = require('./profileassetmanager')
|
||||
|
||||
const runtimes = new Map()
|
||||
const READY_PATTERNS = [
|
||||
/Done \([0-9.]+s\)!/i,
|
||||
/For help, type "help"/i
|
||||
]
|
||||
|
||||
function getRuntime(profileId){
|
||||
if(!runtimes.has(profileId)){
|
||||
runtimes.set(profileId, {
|
||||
serverProcess: null,
|
||||
tunnelProcess: null,
|
||||
logs: [],
|
||||
status: 'stopped',
|
||||
publishedAddress: ConfigManager.getPublishedLibraryServerAddress(profileId)
|
||||
ready: false,
|
||||
readyPromise: null,
|
||||
readyResolver: null,
|
||||
readyRejecter: null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,124 +35,124 @@ function appendLog(runtime, line){
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateCommand(template, variables){
|
||||
return Object.entries(variables).reduce((command, [key, value]) => {
|
||||
return command.replaceAll(`\${${key}}`, String(value))
|
||||
}, template)
|
||||
function createReadyPromise(runtime){
|
||||
runtime.ready = false
|
||||
runtime.readyPromise = new Promise((resolve, reject) => {
|
||||
runtime.readyResolver = resolve
|
||||
runtime.readyRejecter = reject
|
||||
})
|
||||
return runtime.readyPromise
|
||||
}
|
||||
|
||||
function extractPublishedAddress(line, profile){
|
||||
if(profile.tunnelAddressRegex){
|
||||
const customRegex = new RegExp(profile.tunnelAddressRegex)
|
||||
const customMatch = line.match(customRegex)
|
||||
if(customMatch){
|
||||
return customMatch[1] ?? customMatch[0]
|
||||
function resolveReady(runtime){
|
||||
if(runtime.ready === true){
|
||||
return
|
||||
}
|
||||
|
||||
runtime.ready = true
|
||||
runtime.status = 'running'
|
||||
if(runtime.readyResolver){
|
||||
runtime.readyResolver(runtime)
|
||||
}
|
||||
runtime.readyResolver = null
|
||||
runtime.readyRejecter = null
|
||||
}
|
||||
|
||||
function rejectReady(runtime, error){
|
||||
runtime.ready = false
|
||||
if(runtime.readyRejecter){
|
||||
runtime.readyRejecter(error)
|
||||
}
|
||||
runtime.readyResolver = null
|
||||
runtime.readyRejecter = null
|
||||
}
|
||||
|
||||
function resolveServerJavaExecutable(){
|
||||
const selectedServerId = ConfigManager.getSelectedServer()
|
||||
if(selectedServerId != null){
|
||||
const configured = ConfigManager.getJavaExecutable(selectedServerId)
|
||||
if(configured){
|
||||
return configured
|
||||
}
|
||||
}
|
||||
|
||||
const genericMatch = line.match(/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:\d+|[a-zA-Z0-9.-]+:\d{2,5})/)
|
||||
return genericMatch ? genericMatch[1] : null
|
||||
return 'java'
|
||||
}
|
||||
|
||||
async function resolveServerLaunchCommand(profile, serverDirectory){
|
||||
if(profile.serverLaunchCommand){
|
||||
return profile.serverLaunchCommand
|
||||
}
|
||||
async function writeServerFiles(profile, serverDirectory){
|
||||
await fs.writeFile(path.join(serverDirectory, 'eula.txt'), 'eula=true\n', 'utf8')
|
||||
|
||||
const startScript = process.platform === 'win32' ? 'start.bat' : 'start.sh'
|
||||
const startScriptPath = path.join(serverDirectory, startScript)
|
||||
if(await fs.pathExists(startScriptPath)){
|
||||
return process.platform === 'win32' ? startScript : `./${startScript}`
|
||||
}
|
||||
const properties = [
|
||||
['enable-query', 'false'],
|
||||
['max-players', String(profile.serverMaxPlayers ?? 20)],
|
||||
['online-mode', 'true'],
|
||||
['server-port', String(profile.serverPort ?? 25565)],
|
||||
['white-list', profile.serverWhitelistEnabled ? 'true' : 'false'],
|
||||
['level-name', 'world']
|
||||
]
|
||||
|
||||
const jarPath = path.join(serverDirectory, 'server.jar')
|
||||
if(await fs.pathExists(jarPath)){
|
||||
return 'java -jar server.jar nogui'
|
||||
}
|
||||
|
||||
throw new Error('서버 시작 명령을 결정할 수 없습니다. serverLaunchCommand 또는 server.jar/start 스크립트를 준비하세요.')
|
||||
await fs.writeFile(
|
||||
path.join(serverDirectory, 'server.properties'),
|
||||
properties.map(([key, value]) => `${key}=${value}`).join('\n') + '\n',
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveWorkingDirectory(profile, serverDirectory){
|
||||
if(profile.serverWorkingDirectory){
|
||||
return path.join(serverDirectory, profile.serverWorkingDirectory)
|
||||
}
|
||||
return serverDirectory
|
||||
}
|
||||
function buildLaunchCommand(profile, javaExecutable){
|
||||
const memoryMb = Number.isFinite(Number(profile.serverMemoryMb))
|
||||
? Math.max(512, Number(profile.serverMemoryMb))
|
||||
: 4096
|
||||
|
||||
async function startTunnelProcess(profile, runtime, serverDirectory){
|
||||
if(!profile.tunnelCommand){
|
||||
return null
|
||||
}
|
||||
|
||||
const command = interpolateCommand(profile.tunnelCommand, {
|
||||
port: profile.serverPort ?? 25565,
|
||||
serverDir: serverDirectory
|
||||
})
|
||||
|
||||
const tunnelProcess = childProcess.spawn(command, {
|
||||
cwd: serverDirectory,
|
||||
shell: true,
|
||||
detached: false
|
||||
})
|
||||
|
||||
runtime.tunnelProcess = tunnelProcess
|
||||
|
||||
tunnelProcess.stdout?.on('data', (chunk) => {
|
||||
const text = chunk.toString()
|
||||
text.split(/\r?\n/).filter(Boolean).forEach((line) => {
|
||||
appendLog(runtime, `[tunnel] ${line}`)
|
||||
const address = extractPublishedAddress(line, profile)
|
||||
if(address){
|
||||
runtime.publishedAddress = address
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, address)
|
||||
ConfigManager.save()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
tunnelProcess.stderr?.on('data', (chunk) => {
|
||||
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
|
||||
appendLog(runtime, `[tunnel:err] ${line}`)
|
||||
})
|
||||
})
|
||||
|
||||
tunnelProcess.on('close', () => {
|
||||
runtime.tunnelProcess = null
|
||||
})
|
||||
|
||||
return tunnelProcess
|
||||
return `"${javaExecutable}" -Xms${memoryMb}M -Xmx${memoryMb}M -jar server.jar nogui`
|
||||
}
|
||||
|
||||
exports.startHostedProfile = async function(profile){
|
||||
if(profile.serverEnabled !== true){
|
||||
throw new Error('서버 사용이 꺼진 프로필입니다.')
|
||||
}
|
||||
|
||||
const runtime = getRuntime(profile.id)
|
||||
if(runtime.serverProcess != null){
|
||||
if(runtime.readyPromise != null){
|
||||
await runtime.readyPromise.catch(() => {})
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
const serverDirectory = await ProfileAssetManager.ensureServerBundleInstalled(profile)
|
||||
const workingDirectory = resolveWorkingDirectory(profile, serverDirectory)
|
||||
const command = await resolveServerLaunchCommand(profile, workingDirectory)
|
||||
const installedServer = await ProfileAssetManager.ensureServerJarInstalled(profile)
|
||||
if(installedServer?.serverJarPath == null){
|
||||
throw new Error('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
|
||||
}
|
||||
|
||||
runtime.status = 'starting'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
await ProfileAssetManager.ensureServerWorldInstalled(profile, installedServer.serverDirectory)
|
||||
await writeServerFiles(profile, installedServer.serverDirectory)
|
||||
await PortManager.ensurePortAvailability(profile)
|
||||
|
||||
const command = buildLaunchCommand(profile, resolveServerJavaExecutable())
|
||||
appendLog(runtime, `[launcher] starting server: ${command}`)
|
||||
|
||||
runtime.status = 'starting'
|
||||
createReadyPromise(runtime)
|
||||
|
||||
const serverProcess = childProcess.spawn(command, {
|
||||
cwd: workingDirectory,
|
||||
cwd: installedServer.serverDirectory,
|
||||
shell: true,
|
||||
detached: false
|
||||
})
|
||||
|
||||
runtime.serverProcess = serverProcess
|
||||
|
||||
const readyFallbackTimer = setTimeout(() => {
|
||||
if(runtime.serverProcess != null){
|
||||
resolveReady(runtime)
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
serverProcess.stdout?.on('data', (chunk) => {
|
||||
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
|
||||
appendLog(runtime, `[server] ${line}`)
|
||||
if(runtime.status !== 'running'){
|
||||
runtime.status = 'running'
|
||||
if(READY_PATTERNS.some((pattern) => pattern.test(line))){
|
||||
resolveReady(runtime)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -157,37 +164,39 @@ exports.startHostedProfile = async function(profile){
|
||||
})
|
||||
|
||||
serverProcess.on('close', () => {
|
||||
clearTimeout(readyFallbackTimer)
|
||||
runtime.serverProcess = null
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
runtime.ready = false
|
||||
rejectReady(runtime, new Error('서버 프로세스가 종료되었습니다.'))
|
||||
PortManager.releaseProfilePort(profile.id).catch(() => {})
|
||||
})
|
||||
|
||||
if(profile.tunnelCommand){
|
||||
await startTunnelProcess(profile, runtime, workingDirectory)
|
||||
}
|
||||
|
||||
await runtime.readyPromise.catch(() => {})
|
||||
return runtime
|
||||
}
|
||||
|
||||
exports.stopHostedProfile = function(profileId){
|
||||
exports.stopHostedProfile = async function(profileId){
|
||||
const runtime = getRuntime(profileId)
|
||||
|
||||
if(runtime.tunnelProcess != null){
|
||||
runtime.tunnelProcess.kill()
|
||||
runtime.tunnelProcess = null
|
||||
}
|
||||
|
||||
if(runtime.serverProcess != null){
|
||||
runtime.serverProcess.kill()
|
||||
runtime.serverProcess = null
|
||||
runtime.status = 'stopping'
|
||||
try {
|
||||
runtime.serverProcess.stdin?.write('stop\n')
|
||||
} catch (error) {
|
||||
void error
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if(runtime.serverProcess != null){
|
||||
runtime.serverProcess.kill()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profileId, null)
|
||||
ConfigManager.save()
|
||||
runtime.ready = false
|
||||
await PortManager.releaseProfilePort(profileId).catch(() => {})
|
||||
}
|
||||
|
||||
exports.getHostedProfileState = function(profileId){
|
||||
@@ -195,15 +204,14 @@ exports.getHostedProfileState = function(profileId){
|
||||
return {
|
||||
status: runtime.status,
|
||||
running: runtime.serverProcess != null,
|
||||
tunneling: runtime.tunnelProcess != null,
|
||||
publishedAddress: runtime.publishedAddress ?? ConfigManager.getPublishedLibraryServerAddress(profileId),
|
||||
ready: runtime.ready,
|
||||
logs: [...runtime.logs]
|
||||
}
|
||||
}
|
||||
|
||||
exports.hasRunningProfiles = function(){
|
||||
for(const runtime of runtimes.values()){
|
||||
if(runtime.serverProcess != null || runtime.tunnelProcess != null){
|
||||
if(runtime.serverProcess != null){
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user