344 lines
10 KiB
JavaScript
344 lines
10 KiB
JavaScript
const fs = require('fs-extra')
|
|
const got = require('got')
|
|
const path = require('path')
|
|
|
|
const ConfigManager = require('./configmanager')
|
|
const { DEFAULT_REMOTE_DISTRO_URL, setRemoteDistributionUrl } = require('./distromanager')
|
|
|
|
const LOCAL_CATALOG_PATH = path.join(__dirname, '..', 'launcher', 'catalog.json')
|
|
const ROOT_ASSET_PATH = path.join(__dirname, '..', '..', '..')
|
|
const DEFAULT_REMOTE_CATALOG_URL = normalizeNullableText(process.env.LAUNCHER_CATALOG_URL) || 'http://127.0.0.1:8787/catalog.json'
|
|
|
|
function normalizeText(value){
|
|
return typeof value === 'string' ? value.trim() : ''
|
|
}
|
|
|
|
function normalizeNullableText(value){
|
|
const nextValue = normalizeText(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 isRemoteSource(source){
|
|
return /^https?:\/\//i.test(source)
|
|
}
|
|
|
|
function isAbsoluteFileSystemPath(source){
|
|
return path.isAbsolute(source) || /^[a-zA-Z]:[\\/]/.test(source)
|
|
}
|
|
|
|
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 resolveProfileSourceValue(value, catalogSource){
|
|
const nextValue = normalizeNullableText(value)
|
|
if(nextValue == null){
|
|
return null
|
|
}
|
|
|
|
if(isRemoteSource(nextValue) || nextValue.startsWith('file://') || isAbsoluteFileSystemPath(nextValue)){
|
|
return nextValue
|
|
}
|
|
|
|
if(isRemoteSource(catalogSource)){
|
|
return new URL(nextValue, catalogSource).toString()
|
|
}
|
|
|
|
return path.resolve(ROOT_ASSET_PATH, nextValue)
|
|
}
|
|
|
|
function toStoredProfile(rawProfile, catalogSource){
|
|
const flags = deriveFeatureFlags(rawProfile)
|
|
const storedProfile = {
|
|
id: normalizeText(rawProfile.id),
|
|
name: normalizeText(rawProfile.name),
|
|
kind: deriveLegacyKind(flags),
|
|
description: normalizeText(rawProfile.description),
|
|
details: normalizeText(rawProfile.details),
|
|
distributionUrl: resolveProfileSourceValue(rawProfile.distributionUrl, catalogSource),
|
|
modsEnabled: flags.modsEnabled,
|
|
pluginsEnabled: flags.pluginsEnabled,
|
|
serverEnabled: flags.serverEnabled,
|
|
worldArchiveUrl: resolveProfileSourceValue(rawProfile.worldArchiveUrl, catalogSource),
|
|
worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName),
|
|
serverJarUrl: resolveProfileSourceValue(resolveLegacyServerJar(rawProfile), catalogSource),
|
|
serverDirectoryName: normalizeText(rawProfile.serverDirectoryName) || 'server',
|
|
serverPort: Number.isFinite(Number(rawProfile.serverPort)) ? Number(rawProfile.serverPort) : 25565,
|
|
serverMemoryMb: normalizePositiveInteger(rawProfile.serverMemoryMb, 4096, 512),
|
|
serverMaxPlayers: normalizePositiveInteger(rawProfile.serverMaxPlayers, 20, 1),
|
|
serverWhitelistEnabled: normalizeBoolean(rawProfile.serverWhitelistEnabled),
|
|
artwork: resolveProfileSourceValue(rawProfile.artwork, catalogSource) ?? ''
|
|
}
|
|
|
|
if(!storedProfile.serverEnabled){
|
|
storedProfile.serverJarUrl = null
|
|
storedProfile.serverDirectoryName = 'server'
|
|
storedProfile.serverPort = 25565
|
|
storedProfile.serverMemoryMb = 4096
|
|
storedProfile.serverMaxPlayers = 20
|
|
storedProfile.serverWhitelistEnabled = false
|
|
}
|
|
|
|
return storedProfile
|
|
}
|
|
|
|
function normalizeProfile(rawProfile, sourceType = 'catalog', catalogSource = LOCAL_CATALOG_PATH){
|
|
const storedProfile = toStoredProfile(rawProfile, catalogSource)
|
|
const launchIssues = []
|
|
const hostIssues = []
|
|
|
|
if(storedProfile.distributionUrl == null){
|
|
launchIssues.push('distribution URL이 필요합니다.')
|
|
}
|
|
|
|
if(storedProfile.worldArchiveUrl == null){
|
|
launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.')
|
|
}
|
|
if(storedProfile.worldDirectoryName == null){
|
|
launchIssues.push('월드 폴더 이름이 필요합니다.')
|
|
}
|
|
|
|
if(storedProfile.serverEnabled && storedProfile.serverJarUrl == null){
|
|
hostIssues.push('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
|
|
}
|
|
|
|
const launchReady = launchIssues.length === 0
|
|
const hostReady = storedProfile.serverEnabled ? hostIssues.length === 0 : false
|
|
|
|
return {
|
|
...storedProfile,
|
|
sourceType,
|
|
isCustom: sourceType === 'custom',
|
|
configured: launchReady,
|
|
launchReady,
|
|
launchIssues,
|
|
hostReady,
|
|
hostIssues
|
|
}
|
|
}
|
|
|
|
function resolveCatalogFileSource(source){
|
|
if(source.startsWith('file://')){
|
|
return decodeURIComponent(source.substring('file://'.length))
|
|
}
|
|
return path.resolve(source)
|
|
}
|
|
|
|
async function readCatalogSource(source){
|
|
if(/^https?:\/\//i.test(source)){
|
|
return got(source, {
|
|
responseType: 'json'
|
|
}).json()
|
|
}
|
|
|
|
return fs.readJson(resolveCatalogFileSource(source))
|
|
}
|
|
|
|
exports.getLocalCatalogPath = function(){
|
|
return LOCAL_CATALOG_PATH
|
|
}
|
|
|
|
exports.getDefaultRemoteCatalogUrl = function(){
|
|
return DEFAULT_REMOTE_CATALOG_URL
|
|
}
|
|
|
|
function buildCatalogSourceCandidates(configuredSource){
|
|
const trimmedSource = normalizeNullableText(configuredSource)
|
|
if(trimmedSource != null){
|
|
return [trimmedSource]
|
|
}
|
|
|
|
return [DEFAULT_REMOTE_CATALOG_URL, LOCAL_CATALOG_PATH]
|
|
}
|
|
|
|
exports.loadCatalog = async function(){
|
|
const configuredSource = ConfigManager.getLibraryCatalogSource()
|
|
const sourceCandidates = buildCatalogSourceCandidates(configuredSource)
|
|
let rawCatalog = {
|
|
version: 1,
|
|
profiles: []
|
|
}
|
|
let sourceError = null
|
|
let source = sourceCandidates[sourceCandidates.length - 1]
|
|
|
|
for(const candidate of sourceCandidates){
|
|
try {
|
|
rawCatalog = await readCatalogSource(candidate)
|
|
source = candidate
|
|
sourceError = null
|
|
break
|
|
} catch (error) {
|
|
source = candidate
|
|
sourceError = error
|
|
}
|
|
}
|
|
|
|
const rawProfiles = Array.isArray(rawCatalog.profiles) ? rawCatalog.profiles : []
|
|
|
|
return {
|
|
version: rawCatalog.version ?? 1,
|
|
source,
|
|
sourceError,
|
|
profiles: rawProfiles
|
|
.filter((profile) => profile != null && typeof profile.id === 'string' && typeof profile.name === 'string')
|
|
.map((profile) => normalizeProfile(profile, 'catalog', source))
|
|
.sort((left, right) => left.name.localeCompare(right.name, 'ko'))
|
|
}
|
|
}
|
|
|
|
exports.getInstalledProfiles = async function(){
|
|
const catalog = await exports.loadCatalog()
|
|
const installedProfiles = ConfigManager.getInstalledLibraryProfiles()
|
|
|
|
const mergedProfiles = installedProfiles.map((installedProfile) => {
|
|
const latestProfile = catalog.profiles.find((profile) => profile.id === installedProfile.id)
|
|
return latestProfile != null
|
|
? {
|
|
...installedProfile,
|
|
...latestProfile,
|
|
installedAt: installedProfile.installedAt
|
|
}
|
|
: installedProfile
|
|
})
|
|
|
|
ConfigManager.setInstalledLibraryProfiles(mergedProfiles)
|
|
ConfigManager.save()
|
|
|
|
return mergedProfiles
|
|
}
|
|
|
|
exports.installProfile = async function(profileId){
|
|
const catalog = await exports.loadCatalog()
|
|
const profile = catalog.profiles.find((entry) => entry.id === profileId)
|
|
|
|
if(profile == null){
|
|
throw new Error(`Unknown profile: ${profileId}`)
|
|
}
|
|
|
|
const installedProfile = {
|
|
...profile,
|
|
installedAt: new Date().toISOString()
|
|
}
|
|
|
|
ConfigManager.upsertInstalledLibraryProfile(installedProfile)
|
|
|
|
if(ConfigManager.getSelectedLibraryProfile() == null){
|
|
ConfigManager.setSelectedLibraryProfile(profile.id)
|
|
}
|
|
|
|
ConfigManager.save()
|
|
return installedProfile
|
|
}
|
|
|
|
exports.removeProfile = function(profileId){
|
|
ConfigManager.removeInstalledLibraryProfile(profileId)
|
|
ConfigManager.save()
|
|
}
|
|
|
|
exports.selectProfile = function(profileId){
|
|
ConfigManager.setSelectedLibraryProfile(profileId)
|
|
ConfigManager.save()
|
|
}
|
|
|
|
exports.getSelectedProfileId = function(){
|
|
return ConfigManager.getSelectedLibraryProfile()
|
|
}
|
|
|
|
exports.getSelectedProfileSync = function(){
|
|
const selectedProfileId = ConfigManager.getSelectedLibraryProfile()
|
|
if(selectedProfileId == null){
|
|
return null
|
|
}
|
|
return ConfigManager.getInstalledLibraryProfile(selectedProfileId)
|
|
}
|
|
|
|
exports.resolveServerAddress = function(profile){
|
|
if(profile == null){
|
|
return null
|
|
}
|
|
|
|
const manualAddress = ConfigManager.getLibraryServerAddressOverride(profile.id)
|
|
if(manualAddress != null && manualAddress.length > 0){
|
|
return manualAddress
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
exports.setServerAddressOverride = function(profileId, address){
|
|
ConfigManager.setLibraryServerAddressOverride(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
|
|
setRemoteDistributionUrl(distributionUrl)
|
|
return selectedProfile
|
|
}
|