import type { Labrinth } from '@modrinth/api-client' import { reactive } from 'vue' import { resolveServerLauncher } from '@/components/multiplayer/servers/server-flow-utils' import { get_version } from '@/helpers/cache.js' import { type InstallModpackOptions, serverEventListener, type ServerInfoData, servers, } from '@/helpers/servers' import { createServerDownloadBridge } from './server-download-bridge' import { refresh as refreshServerList } from './useServers' const LOG_CAPACITY = 500 export interface ActiveServerInstall { progress: { downloaded: number; total: number | null } | null log: string[] } /** * Why a server's start controls are replaced by download actions: * - `installing`: files are transferring right now (this app session) * - `interrupted`: the manifest was flagged mid-install and never finished * (app closed while downloading); resumable * - `failed`: the last install attempt errored; retryable */ export type ServerSetupStatus = 'installing' | 'interrupted' | 'failed' /** Module-level singleton: install activity is global to the app. */ const activeInstalls = reactive>({}) export function activeInstallFor(serverId: string): ActiveServerInstall | null { return activeInstalls[serverId] ?? null } export function serverSetupStatus(server: ServerInfoData): ServerSetupStatus | null { if (activeInstalls[server.id]) return 'installing' if (server.installState === 'incomplete') return 'interrupted' if (server.installState === 'failed') return 'failed' return null } /** * [SERVER-DOWNLOAD-BRIDGE] Runs a modpack server install in the background. * * During the install, progress and log events are tracked in the shared * `activeInstalls` registry so any surface (wizard, servers list, detail * page) can render live state. * * Additionally, when a `DownloadManager` reference is provided, a synthetic * install job is injected so the server download also appears in the global * Downloads page (/downloads) and the sidebar download badge. * * NOTE: The download manager MUST be captured during Vue setup context and * passed in — `injectDownloadManager()` only works inside synchronous setup * scope. After any `await` the injection context is lost and the call * would throw. */ export async function startModpackServerInstall( serverId: string, options: InstallModpackOptions, downloadManager?: import('@/providers/download-manager').DownloadManager | null, ): Promise { if (activeInstalls[serverId]) { throw new Error('This server already has an install running') } const entry: ActiveServerInstall = { progress: null, log: [] } activeInstalls[serverId] = entry // [SERVER-DOWNLOAD-BRIDGE] Create a synthetic job that mirrors this server // install in the global Downloads page. The job_id uses a `server-` prefix // to avoid collisions with backend-tracked install jobs. The shared bridge // centralises the snapshot shape used by every server download (modpack and // vanilla) so they stay consistent in the sidebar. const syntheticJobId = `server-${serverId}` const bridge = downloadManager ? createServerDownloadBridge(downloadManager, syntheticJobId, { title: options.modpackTitle ?? 'Server', icon: options.modpackIconUrl ?? null, provider: options.modpackProjectId ? 'modrinth' : 'local', }) : null if (!bridge) { console.warn( `[SERVER-DOWNLOAD-BRIDGE] No download manager available — job ${syntheticJobId} will NOT appear in sidebar`, ) } // [SERVER-DOWNLOAD-BRIDGE] Listen for backend progress events and forward // them to both the local `entry` (used by wizard UI) and the synthetic // job (used by the global Downloads page). let prevBytes = 0 let prevTime = performance.now() let smoothedSpeed = 0 const unlistenProgress = await serverEventListener((id, payload) => { if (id !== serverId || payload.event !== 'download_progress') return entry.progress = { downloaded: payload.downloaded, total: payload.total ?? null } const now2 = performance.now() const dt = (now2 - prevTime) / 1000 if (dt > 0.3 && prevBytes > 0 && payload.downloaded >= prevBytes) { const rawSpeed = (payload.downloaded - prevBytes) / dt smoothedSpeed = smoothedSpeed === 0 ? rawSpeed : smoothedSpeed * 0.7 + rawSpeed * 0.3 } prevBytes = payload.downloaded prevTime = now2 const speed = smoothedSpeed > 100 ? smoothedSpeed : null const total = payload.total ?? null const eta = speed && total != null && total > payload.downloaded ? (total - payload.downloaded) / speed : null bridge?.update({ downloaded: payload.downloaded, total }, speed, eta) }) const unlistenLogs = await serverEventListener((id, payload) => { if (id !== serverId || payload.event !== 'log') return entry.log.push(payload.line) if (entry.log.length > LOG_CAPACITY) entry.log.splice(0, entry.log.length - LOG_CAPACITY) }) // [SERVER-DOWNLOAD-BRIDGE] Register a cancel handler so the Downloads page // can abort the running server install when the user clicks Cancel. bridge?.cancel(async () => { console.log(`[SERVER-DOWNLOAD-BRIDGE] Cancelling server install ${serverId}`) await servers.stop(serverId).catch(() => {}) }) let installSucceeded = false try { await servers.installModpack(serverId, options) installSucceeded = true } finally { unlistenProgress() unlistenLogs() // [SERVER-DOWNLOAD-BRIDGE] Mark the synthetic job as succeeded or failed // so it transitions out of the active tab and into history. bridge?.complete(installSucceeded, entry.progress ?? undefined) activeInstalls[serverId] = undefined void refreshServerList() } } /** * [SERVER-DOWNLOAD-BRIDGE] Resumes an interrupted or failed modpack install * for a server created from a modpack: re-resolves the pack file and launcher * jar from the recorded source project, then reruns the same background install. * * The optional `downloadManager` parameter is forwarded to * `startModpackServerInstall` so the synthetic job appears in sidebar. * See the note in `startModpackServerInstall` for why this must be passed * explicitly. */ export async function resumeModpackInstall( server: ServerInfoData, downloadManager?: import('@/providers/download-manager').DownloadManager | null, ): Promise { const modpack = server.modpack if (!modpack?.versionId) { throw new Error('This server has no modpack source to resume from') } const version = (await get_version(modpack.versionId)) as Labrinth.Versions.v2.Version const primaryFile = version.files.find((file) => file.primary) ?? version.files[0] if (!primaryFile?.url) { throw new Error('Modpack has no downloadable file') } const jar = await resolveServerLauncher( server.serverType, server.gameVersion, server.loaderVersion, ) if (!jar) { throw new Error( `No server launcher available for ${server.serverType} on ${server.gameVersion}`, ) } await startModpackServerInstall( server.id, { mrpackUrl: primaryFile.url, mrpackSha1: primaryFile.hashes?.sha1, jarUrl: jar.url, jarFilename: jar.filename, jarSha1: jar.sha1, modpackProjectId: modpack.projectId, modpackVersionId: modpack.versionId, modpackTitle: modpack.title, modpackIconUrl: modpack.iconUrl, }, downloadManager, ) }