From 87c56a21d555073dc45d815c2ca7e09990d5ff8c Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 5 May 2026 22:46:03 +0900 Subject: [PATCH] Enable remote launcher catalog source --- README.md | 7 +++ admin/public/index.html | 2 +- admin/server.js | 15 +++++-- app/assets/js/catalogmanager.js | 73 +++++++++++++++++++++++++------ app/assets/js/configmanager.js | 25 ++++++++++- app/assets/js/preloader.js | 37 ++++++++++------ app/assets/js/scripts/settings.js | 34 +++++++++++--- app/assets/lang/en_US.toml | 3 ++ app/assets/lang/ko_KR.toml | 3 ++ app/settings.ejs | 17 +++++++ docs/admin-site.md | 10 ++++- 11 files changed, 188 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f3b4dbe..54bfa88 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ npm run admin - 월드 ZIP 업로드 가능 - 서버용 버킷 JAR 업로드 가능 - 서버 메모리, 최대 인원수, 화이트리스트, 포트 설정 가능 +- 상단의 `앱 연결용 catalog URL`을 런처 설정의 `카탈로그 주소`에 넣으면 앱이 원격 catalog를 읽습니다. + +공개 주소로 관리자 사이트를 띄울 때: + +```bash +LAUNCHER_PUBLIC_BASE_URL=https://your-domain.example npm run admin +``` 문서: diff --git a/admin/public/index.html b/admin/public/index.html index 4577ca1..691452e 100644 --- a/admin/public/index.html +++ b/admin/public/index.html @@ -21,7 +21,7 @@ 불러오는 중
- 로컬 catalog URL + 앱 연결용 catalog URL 불러오는 중
diff --git a/admin/server.js b/admin/server.js index e38c74a..6692b13 100644 --- a/admin/server.js +++ b/admin/server.js @@ -14,6 +14,7 @@ const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher const PUBLIC_DIR = path.join(__dirname, 'public') const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json') const EXAMPLE_DISTRIBUTION_PREFIX = 'https://example.com/launcher/' +const PUBLIC_BASE_URL = normalizeText(process.env.LAUNCHER_PUBLIC_BASE_URL).replace(/\/+$/, '') function normalizeText(value){ return typeof value === 'string' ? value.trim() : '' @@ -45,6 +46,13 @@ function normalizeBoolean(value){ return value === true } +function getPublicBaseUrl(){ + if(PUBLIC_BASE_URL.length > 0){ + return PUBLIC_BASE_URL + } + return `http://${HOST}:${PORT}` +} + function resolveSafeProjectPath(relativePath){ const resolvedPath = path.resolve(PROJECT_ROOT, relativePath) if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){ @@ -259,6 +267,7 @@ async function start(){ app.use(express.json({ limit: '5mb' })) app.use('/uploads', express.static(UPLOADS_DIR)) + app.use('/admin/data/uploads', express.static(UPLOADS_DIR)) app.use('/admin/data/distributions', express.static(DISTRIBUTIONS_DIR)) app.get('/api/meta', async (_req, res) => { @@ -267,7 +276,7 @@ async function start(){ port: PORT, runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH), launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH), - localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`, + localCatalogUrl: `${getPublicBaseUrl()}/catalog.json`, distributionsPath: toProjectRelativePath(DISTRIBUTIONS_DIR) }) }) @@ -362,7 +371,7 @@ async function start(){ storedName: req.file.filename, size: req.file.size, path: relativePath, - localUrl: `http://${HOST}:${PORT}/uploads/${encodeURIComponent(req.file.filename)}` + localUrl: `${getPublicBaseUrl()}/uploads/${encodeURIComponent(req.file.filename)}` } }) } catch (error) { @@ -394,7 +403,7 @@ async function start(){ ok: true, file: { path: relativePath, - localUrl: `http://${HOST}:${PORT}/${relativePath}` + localUrl: `${getPublicBaseUrl()}/${relativePath}` } }) } catch (error) { diff --git a/app/assets/js/catalogmanager.js b/app/assets/js/catalogmanager.js index ba0afe4..6fe9b3c 100644 --- a/app/assets/js/catalogmanager.js +++ b/app/assets/js/catalogmanager.js @@ -6,6 +6,8 @@ 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() : '' @@ -28,6 +30,14 @@ function normalizePositiveInteger(value, fallback, minimum = 1){ 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) @@ -77,7 +87,24 @@ function resolveLegacyServerJar(rawProfile){ return null } -function toStoredProfile(rawProfile){ +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), @@ -85,19 +112,19 @@ function toStoredProfile(rawProfile){ kind: deriveLegacyKind(flags), description: normalizeText(rawProfile.description), details: normalizeText(rawProfile.details), - distributionUrl: normalizeNullableText(rawProfile.distributionUrl), + distributionUrl: resolveProfileSourceValue(rawProfile.distributionUrl, catalogSource), modsEnabled: flags.modsEnabled, pluginsEnabled: flags.pluginsEnabled, serverEnabled: flags.serverEnabled, - worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl), + worldArchiveUrl: resolveProfileSourceValue(rawProfile.worldArchiveUrl, catalogSource), worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName), - serverJarUrl: resolveLegacyServerJar(rawProfile), + 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: normalizeText(rawProfile.artwork) + artwork: resolveProfileSourceValue(rawProfile.artwork, catalogSource) ?? '' } if(!storedProfile.serverEnabled){ @@ -112,8 +139,8 @@ function toStoredProfile(rawProfile){ return storedProfile } -function normalizeProfile(rawProfile, sourceType = 'catalog'){ - const storedProfile = toStoredProfile(rawProfile) +function normalizeProfile(rawProfile, sourceType = 'catalog', catalogSource = LOCAL_CATALOG_PATH){ + const storedProfile = toStoredProfile(rawProfile, catalogSource) const launchIssues = [] const hostIssues = [] @@ -168,19 +195,39 @@ 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 source = configuredSource != null && configuredSource.trim().length > 0 ? configuredSource.trim() : LOCAL_CATALOG_PATH + const sourceCandidates = buildCatalogSourceCandidates(configuredSource) let rawCatalog = { version: 1, profiles: [] } let sourceError = null + let source = sourceCandidates[sourceCandidates.length - 1] - try { - rawCatalog = await readCatalogSource(source) - } catch (error) { - sourceError = error + 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 : [] @@ -191,7 +238,7 @@ exports.loadCatalog = async function(){ sourceError, profiles: rawProfiles .filter((profile) => profile != null && typeof profile.id === 'string' && typeof profile.name === 'string') - .map((profile) => normalizeProfile(profile, 'catalog')) + .map((profile) => normalizeProfile(profile, 'catalog', source)) .sort((left, right) => left.name.localeCompare(right.name, 'ko')) } } diff --git a/app/assets/js/configmanager.js b/app/assets/js/configmanager.js index e8155cf..e921024 100644 --- a/app/assets/js/configmanager.js +++ b/app/assets/js/configmanager.js @@ -476,7 +476,11 @@ exports.getLibraryCatalogSource = function(){ * @param {string|null} source A remote URL or local path. */ exports.setLibraryCatalogSource = function(source){ - config.library.catalogSource = source + if(typeof source !== 'string' || source.trim().length === 0){ + config.library.catalogSource = null + } else { + config.library.catalogSource = source.trim() + } } /** @@ -998,3 +1002,22 @@ exports.getAllowPrerelease = function(def = false){ exports.setAllowPrerelease = function(allowPrerelease){ config.settings.launcher.allowPrerelease = allowPrerelease } + +/** + * Validate a library catalog source. + * + * @param {string} source A remote URL or local path. + * @returns {boolean} Whether the value is allowed. + */ +exports.validateLibraryCatalogSource = function(source){ + const trimmedSource = typeof source === 'string' ? source.trim() : '' + if(trimmedSource.length === 0){ + return true + } + + if(trimmedSource.includes('://')){ + return /^https?:\/\/\S+$/i.test(trimmedSource) || trimmedSource.startsWith('file://') + } + + return true +} diff --git a/app/assets/js/preloader.js b/app/assets/js/preloader.js index af8e932..4601e40 100644 --- a/app/assets/js/preloader.js +++ b/app/assets/js/preloader.js @@ -17,7 +17,6 @@ logger.info('Loading..') // Load ConfigManager ConfigManager.load() -CatalogManager.applyConfiguredProfile() // Yuck! // TODO Fix this @@ -44,20 +43,32 @@ function onDistroLoad(data){ ipcRenderer.send('distributionIndexDone', data != null) } -// Ensure Distribution is downloaded and cached. -DistroAPI.getDistribution() - .then(heliosDistro => { - logger.info('Loaded distribution index.') +async function bootstrapDistribution(){ + try { + await CatalogManager.getInstalledProfiles() + } catch (err) { + logger.warn('Unable to refresh installed profiles from catalog at startup.', err) + } - onDistroLoad(heliosDistro) - }) - .catch(err => { - logger.info('Failed to load an older version of the distribution index.') - logger.info('Application cannot run.') - logger.error(err) + CatalogManager.applyConfiguredProfile() - onDistroLoad(null) - }) + // Ensure Distribution is downloaded and cached. + DistroAPI.getDistribution() + .then(heliosDistro => { + logger.info('Loaded distribution index.') + + onDistroLoad(heliosDistro) + }) + .catch(err => { + logger.info('Failed to load an older version of the distribution index.') + logger.info('Application cannot run.') + logger.error(err) + + onDistroLoad(null) + }) +} + +bootstrapDistribution() // Clean up temp dir incase previous launches ended unexpectedly. fs.remove(path.join(os.tmpdir(), ConfigManager.getTempNativeFolder()), (err) => { diff --git a/app/assets/js/scripts/settings.js b/app/assets/js/scripts/settings.js index 0a018a6..ea82a65 100644 --- a/app/assets/js/scripts/settings.js +++ b/app/assets/js/scripts/settings.js @@ -6,6 +6,7 @@ const { ensureJavaDirIsRoot } = require('helios-core/java') +const CatalogManager = require('./assets/js/catalogmanager') const DropinModUtil = require('./assets/js/dropinmodutil') const { MSFT_OPCODE, MSFT_REPLY_TYPE, MSFT_ERROR } = require('./assets/js/ipcconstants') const settingsBackButton = document.getElementById('settingsBackButton') @@ -326,22 +327,45 @@ function settingsSaveDisabled(v){ settingsNavDone.disabled = v } -function fullSettingsSave() { +async function refreshCatalogSourceViews(){ + await CatalogManager.getInstalledProfiles() + CatalogManager.applyConfiguredProfile() + + if(typeof refreshInstallView === 'function'){ + await refreshInstallView() + } + if(typeof refreshLibraryView === 'function'){ + await refreshLibraryView() + } + if(typeof refreshSelectedProfileButton === 'function'){ + refreshSelectedProfileButton() + } + if(typeof refreshServerStatus === 'function'){ + await refreshServerStatus(true) + } +} + +async function fullSettingsSave() { + const previousCatalogSource = ConfigManager.getLibraryCatalogSource() saveSettingsValues() saveModConfiguration() ConfigManager.save() saveDropinModConfiguration() saveShaderpackSettings() + + if(previousCatalogSource !== ConfigManager.getLibraryCatalogSource()){ + await refreshCatalogSourceViews() + } } /* Closes the settings view and saves all data. */ -settingsNavDone.onclick = () => { - fullSettingsSave() +settingsNavDone.onclick = async () => { + await fullSettingsSave() switchView(getCurrentView(), VIEWS.landing) } -settingsBackButton.onclick = () => { - fullSettingsSave() +settingsBackButton.onclick = async () => { + await fullSettingsSave() switchView(getCurrentView(), VIEWS.landing) } diff --git a/app/assets/lang/en_US.toml b/app/assets/lang/en_US.toml index 505418c..291f6bf 100644 --- a/app/assets/lang/en_US.toml +++ b/app/assets/lang/en_US.toml @@ -102,6 +102,9 @@ launcherTabHeaderText = "Launcher Settings" launcherTabHeaderDesc = "Options related to the launcher itself." allowPrereleaseTitle = "Allow Pre-Release Updates." allowPrereleaseDesc = "Pre-Releases include new features which may have not been fully tested or integrated.
This will always be true if you are using a pre-release version." +catalogSourceTitle = "Catalog URL" +catalogSourcePlaceholder = "https://your-domain.example/catalog.json" +catalogSourceDesc = "Leave blank to use the automatic default catalog source.
For remote operation, enter the catalog.json URL served by the admin site." dataDirectoryTitle = "Data Directory" selectDataDirectory = "Select Data Directory" chooseFolder = "Choose Folder" diff --git a/app/assets/lang/ko_KR.toml b/app/assets/lang/ko_KR.toml index 28e7cc4..9ce36d5 100644 --- a/app/assets/lang/ko_KR.toml +++ b/app/assets/lang/ko_KR.toml @@ -102,6 +102,9 @@ launcherTabHeaderText = "런처 설정" launcherTabHeaderDesc = "런처와 관련된 설정입니다." allowPrereleaseTitle = "프리릴리즈 업데이트를 허용합니다." allowPrereleaseDesc = "프리릴리즈는 안정성이 보장되지 않은 기능을 포함할 수 있습니다.
현재 실행 중인 런처가 프리릴리즈 버전이라면, 이 설정은 항상 활성화됩니다." +catalogSourceTitle = "카탈로그 주소" +catalogSourcePlaceholder = "https://your-domain.example/catalog.json" +catalogSourceDesc = "비워두면 기본 카탈로그를 자동으로 찾습니다.
원격 운영 시에는 관리자 사이트가 제공하는 catalog.json URL을 입력하세요." dataDirectoryTitle = "데이터 디렉토리" selectDataDirectory = "데이터 디렉토리 선택" chooseFolder = "폴더 선택" diff --git a/app/settings.ejs b/app/settings.ejs index ab10ead..841f716 100644 --- a/app/settings.ejs +++ b/app/settings.ejs @@ -289,6 +289,23 @@ +
+
+
<%- lang('settings.catalogSourceTitle') %>
+
+
+ + + + + + +
+ +
+
+
<%- lang('settings.catalogSourceDesc') %>
+
<%- lang('settings.dataDirectoryTitle') %>
diff --git a/docs/admin-site.md b/docs/admin-site.md index 21d897a..dfbd419 100644 --- a/docs/admin-site.md +++ b/docs/admin-site.md @@ -12,6 +12,11 @@ npm run admin - `http://127.0.0.1:8787` +공개 주소로 운영할 때: + +- `LAUNCHER_PUBLIC_BASE_URL=https://your-domain.example npm run admin` +- 그러면 관리자 사이트 상단의 `앱 연결용 catalog URL`이 그 공개 주소 기준으로 표시됩니다. + ## 현재 구현 범위 - 프로필 추가 / 수정 / 삭제 / 복제 @@ -54,5 +59,6 @@ npm run admin ## 추천 운영 방식 1. 관리자 사이트에서 프로필과 자료 파일을 입력 -2. 로컬 런처에서 실제 표시와 실행 확인 -3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장 +2. 상단의 `앱 연결용 catalog URL`을 복사 +3. 런처 설정의 `카탈로그 주소`에 붙여넣기 +4. 저장 후 실제 표시와 실행 확인