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 }