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') 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 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 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: normalizeNullableText(rawProfile.distributionUrl), modsEnabled: flags.modsEnabled, pluginsEnabled: flags.pluginsEnabled, serverEnabled: flags.serverEnabled, worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl), worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName), serverJarUrl: resolveLegacyServerJar(rawProfile), 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: normalizeText(rawProfile.artwork) } 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'){ const storedProfile = toStoredProfile(rawProfile) 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.loadCatalog = async function(){ const configuredSource = ConfigManager.getLibraryCatalogSource() const source = configuredSource != null && configuredSource.trim().length > 0 ? configuredSource.trim() : LOCAL_CATALOG_PATH let rawCatalog = { version: 1, profiles: [] } let sourceError = null try { rawCatalog = await readCatalogSource(source) } catch (error) { 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')) .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 }