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

@@ -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
}
}
}

View File

@@ -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`)

View File

@@ -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)
}
}