feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

@@ -0,0 +1,903 @@
import { CheckIcon, CopyIcon, UpdatedIcon, XIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
type PopupNotificationProgressItem,
type PopupNotificationProgressType,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, watch } from 'vue'
import type { Router } from 'vue-router'
import {
install_job_dismiss,
install_job_repair_cache_and_retry,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
import { get_many as getInstances } from '@/helpers/instance'
import type { DownloadManager } from '@/providers/download-manager'
const messages = defineMessages({
installs: {
id: 'app.action-bar.installs',
defaultMessage: 'Installs',
},
cancel: {
id: 'app.action-bar.install.cancel',
defaultMessage: 'Cancel',
},
retry: {
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
copyDetails: {
id: 'app.action-bar.install.copy-details',
defaultMessage: 'Copy details',
},
copied: {
id: 'app.action-bar.install.copied-details',
defaultMessage: 'Copied',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
},
completed: {
id: 'app.downloads.status.succeeded',
defaultMessage: 'Completed',
},
openInstance: {
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
deletedInstance: {
id: 'app.action-bar.install.deleted-instance',
defaultMessage: 'Deleted instance',
},
cacheRepairTitle: {
id: 'app.action-bar.install.cache-repair.title',
defaultMessage: 'Project metadata cache error',
},
cacheRepairDescription: {
id: 'app.action-bar.install.cache-repair.description',
defaultMessage:
'The launcher could not read the local CurseForge project cache, so installation was stopped.',
},
cacheRepairAction: {
id: 'app.action-bar.install.cache-repair.action',
defaultMessage: 'Clear project cache and retry',
},
cacheRepairing: {
id: 'app.action-bar.install.cache-repair.in-progress',
defaultMessage: 'Clearing project cache…',
},
cacheRepairFailed: {
id: 'app.action-bar.install.cache-repair.failed',
defaultMessage: 'Project cache cleanup failed; retry was not started.',
},
})
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Queued to install',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
defaultMessage: 'Resolving content',
},
downloading_pack_file: {
id: 'app.install.phase.downloading_pack_file',
defaultMessage: 'Downloading .mrpack',
},
reading_pack_manifest: {
id: 'app.install.phase.reading_pack_manifest',
defaultMessage: 'Reading pack manifest',
},
downloading_content: {
id: 'app.install.phase.downloading_content',
defaultMessage: 'Downloading content',
},
extracting_overrides: {
id: 'app.install.phase.extracting_overrides',
defaultMessage: 'Extracting overrides',
},
resolving_minecraft: {
id: 'app.install.phase.resolving_minecraft',
defaultMessage: 'Resolving Minecraft',
},
resolving_loader: {
id: 'app.install.phase.resolving_loader',
defaultMessage: 'Resolving loader',
},
preparing_java: {
id: 'app.install.phase.preparing_java',
defaultMessage: 'Preparing Java',
},
downloading_minecraft: {
id: 'app.install.phase.downloading_minecraft',
defaultMessage: 'Downloading Minecraft',
},
running_loader_processors: {
id: 'app.install.phase.running_loader_processors',
defaultMessage: 'Running loader processors',
},
finalizing: {
id: 'app.install.phase.finalizing',
defaultMessage: 'Finalizing',
},
rolling_back: {
id: 'app.install.phase.rolling_back',
defaultMessage: 'Rolling back',
},
})
const javaStepMessages = defineMessages({
resolving: {
id: 'app.install.phase.preparing_java.resolving',
defaultMessage: 'Preparing Java {version}',
},
fetching_metadata: {
id: 'app.install.phase.preparing_java.fetching-metadata',
defaultMessage: 'Fetching Java {version}',
},
downloading: {
id: 'app.install.phase.preparing_java.downloading',
defaultMessage: 'Downloading Java {version}',
},
extracting: {
id: 'app.install.phase.preparing_java.extracting',
defaultMessage: 'Extracting Java {version}',
},
validating: {
id: 'app.install.phase.preparing_java.validating',
defaultMessage: 'Validating Java {version}',
},
})
const failureSummaryMessages = defineMessages({
canceled: {
id: 'app.action-bar.install.summary.canceled',
defaultMessage: 'Canceled',
},
appClosed: {
id: 'app.action-bar.install.summary.app-closing',
defaultMessage: 'Canceled due to app closing',
},
downloadFailed: {
id: 'app.action-bar.install.summary.download-failed',
defaultMessage: "Download couldn't finish",
},
fileDownloadFailed: {
id: 'app.action-bar.install.summary.file-download-failed',
defaultMessage: "Couldn't download {file}",
},
modrinthUnreachable: {
id: 'app.action-bar.install.summary.modrinth-unreachable',
defaultMessage: "Couldn't reach Modrinth",
},
packDownloadFailed: {
id: 'app.action-bar.install.summary.pack-download-failed',
defaultMessage: "Couldn't download pack",
},
badModpackFile: {
id: 'app.action-bar.install.summary.bad-modpack-file',
defaultMessage: "Couldn't read modpack",
},
unrecognizedFormat: {
id: 'app.action-bar.install.summary.unrecognized-format',
defaultMessage: 'This does not appear to be a modpack archive',
},
invalidModpack: {
id: 'app.action-bar.install.summary.invalid-modpack',
defaultMessage: 'Modpack data invalid',
},
contentDownloadFailed: {
id: 'app.action-bar.install.summary.content-download-failed',
defaultMessage: "Couldn't download files",
},
corruptDownload: {
id: 'app.action-bar.install.summary.corrupt-download',
defaultMessage: 'Downloaded file is corrupt',
},
invalidModpackFiles: {
id: 'app.action-bar.install.summary.invalid-modpack-files',
defaultMessage: 'Modpack files have invalid metadata',
},
noWritePermission: {
id: 'app.action-bar.install.summary.no-write-permission',
defaultMessage: 'No permission to write',
},
couldNotSaveFiles: {
id: 'app.action-bar.install.summary.could-not-save-files',
defaultMessage: "Couldn't save files",
},
invalidFilePath: {
id: 'app.action-bar.install.summary.invalid-file-path',
defaultMessage: 'File path is invalid',
},
instanceNotFound: {
id: 'app.action-bar.install.summary.instance-not-found',
defaultMessage: "Instance couldn't be found",
},
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
javaSetupFailed: {
id: 'app.action-bar.install.summary.java-setup-failed',
defaultMessage: "Java setup couldn't finish",
},
minecraftSetupFailed: {
id: 'app.action-bar.install.summary.minecraft-setup-failed',
defaultMessage: 'Minecraft setup failed',
},
loaderSetupFailed: {
id: 'app.action-bar.install.summary.loader-setup-failed',
defaultMessage: 'Loader setup failed',
},
localDataError: {
id: 'app.action-bar.install.summary.local-data-error',
defaultMessage: "Couldn't update local data",
},
unexpectedError: {
id: 'app.action-bar.install.summary.unexpected-error',
defaultMessage: 'Something went wrong',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
'failed',
'interrupted',
])
const retainedJobStatuses = new Set<InstallJobStatus>(['succeeded', 'canceled'])
const activeJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
])
const cancelableJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'waiting_for_user',
])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) return icon
return convertFileSrc(icon)
}
export async function useInstallJobNotifications(opts: {
router: Router
manager: DownloadManager
handleError: (err: unknown) => void
onChange: (resummon?: boolean) => void
}) {
const { formatMessage } = useVIntl()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const repairingJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.error?.code === 'cache_repair_required') {
return formatMessage(messages.cacheRepairTitle)
}
if (job.display?.title) return job.display.title
if (job.details.type === 'instance') return job.details.name
if (job.details.type === 'modpack' && job.details.title) return job.details.title
const instanceId = installJobInstanceId(job)
return job.instance_deleted
? formatMessage(messages.deletedInstance)
: ((instanceId ? instanceNames.value[instanceId] : null) ??
formatMessage(messages.unknownInstance))
}
function getText(job: InstallJobSnapshot): string {
if (job.error?.code === 'cache_repair_required') {
return formatMessage(messages.cacheRepairDescription)
}
if (job.status === 'succeeded') {
return formatMessage(messages.completed)
}
if (job.status === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (job.status === 'failed' || job.status === 'interrupted') {
return getFailureSummary(job)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
version: job.details.major_version,
})
}
return formatMessage(phaseMessages[job.phase])
}
function getFailureSummary(job: InstallJobSnapshot): string {
const code = job.error?.code
const phase = job.error?.phase ?? job.phase
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
return formatMessage(failureSummaryMessages.appClosed)
}
if (job.rollback_error || code === 'rollback_error') {
return formatMessage(failureSummaryMessages.cleanupIncomplete)
}
if (code === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (hasPermissionError(job)) {
return formatMessage(failureSummaryMessages.noWritePermission)
}
switch (code) {
case 'network_error':
if (job.error?.context?.file_path) {
return formatMessage(failureSummaryMessages.fileDownloadFailed, {
file: job.error.context.file_path,
})
}
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.downloadFailed,
)
case 'api_error':
return formatMessage(failureSummaryMessages.modrinthUnreachable)
case 'unrecognized_format':
return formatMessage(failureSummaryMessages.unrecognizedFormat)
case 'pack_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.invalidModpack,
)
case 'archive_error':
return formatMessage(failureSummaryMessages.badModpackFile)
case 'parse_error':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'content_error':
return formatMessage(failureSummaryMessages.invalidModpackFiles)
case 'hash_error':
return formatMessage(failureSummaryMessages.corruptDownload)
case 'filesystem_error':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'path_error':
return formatMessage(failureSummaryMessages.invalidFilePath)
case 'instance_error':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'java_error':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'loader_error':
case 'processor_error':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'database_error':
return formatMessage(failureSummaryMessages.localDataError)
case 'launcher_error':
case 'metadata_error':
return getFailureSummaryForPhase(phase)
default:
return getFailureSummaryForPhase(phase)
}
}
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
switch (phase) {
case 'downloading_pack_file':
return formatMessage(failureSummaryMessages.packDownloadFailed)
case 'resolving_pack':
case 'reading_pack_manifest':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'downloading_content':
return formatMessage(failureSummaryMessages.contentDownloadFailed)
case 'extracting_overrides':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'resolving_minecraft':
case 'downloading_minecraft':
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
case 'resolving_loader':
case 'running_loader_processors':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'preparing_java':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'preparing_instance':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'rolling_back':
return formatMessage(failureSummaryMessages.cleanupIncomplete)
default:
return formatMessage(failureSummaryMessages.unexpectedError)
}
}
function hasPermissionError(job: InstallJobSnapshot): boolean {
const message = job.error?.message.toLowerCase() ?? ''
return (
message.includes('permission denied') ||
message.includes('access is denied') ||
message.includes('operation not permitted')
)
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!hasDeterminateInstallProgress(getEffectiveProgress(job))) return undefined
if (
job.phase === 'preparing_java' &&
job.details.type === 'java' &&
job.details.step === 'downloading'
) {
return 'bytes'
}
if (job.phase === 'downloading_content') {
return job.progress?.secondary ? 'bytes' : 'count'
}
if (
job.phase === 'downloading_pack_file' ||
job.phase === 'extracting_overrides' ||
job.phase === 'downloading_minecraft'
) {
return 'bytes'
}
if (job.phase === 'running_loader_processors') {
return 'count'
}
return 'percentage'
}
function getEffectiveProgress(job: InstallJobSnapshot): InstallProgress | null | undefined {
return effectiveInstallProgress(job)
}
function getProgress(job: InstallJobSnapshot): number {
if (job.status === 'succeeded') return 1
if (job.status === 'failed' || job.status === 'interrupted') return 0
const progress = getEffectiveProgress(job)
if (!hasDeterminateInstallProgress(progress)) return 0
return Math.max(0, Math.min(1, progress.current / progress.total))
}
function isFailedJob(job: InstallJobSnapshot): boolean {
return job.status === 'failed' || job.status === 'interrupted'
}
function isFinishedJob(job: InstallJobSnapshot): boolean {
return isFailedJob(job) || retainedJobStatuses.has(job.status)
}
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isFinishedJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isFailedJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
}
function isCopied(job: InstallJobSnapshot): boolean {
return copiedJobIds.value.has(job.job_id)
}
function setCopied(job: InstallJobSnapshot) {
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
const existingTimeout = copiedResetTimeouts.get(job.job_id)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
}
copiedResetTimeouts.set(
job.job_id,
window.setTimeout(() => {
copiedResetTimeouts.delete(job.job_id)
if (!copiedJobIds.value.has(job.job_id)) {
return
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(job.job_id)
copiedJobIds.value = nextCopiedJobIds
opts.onChange()
}, 1_000),
)
opts.onChange()
}
async function copyJobDetails(job: InstallJobSnapshot) {
const details = await install_job_support_details(job.job_id).catch((error) => {
opts.handleError(error)
return null
})
if (!details) {
return
}
try {
await navigator.clipboard.writeText(details)
setCopied(job)
} catch (error) {
opts.handleError(error)
}
}
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
const buttons: PopupNotificationButton[] = []
if (cancelableJobStatuses.has(job.status)) {
buttons.push({
label: formatMessage(messages.cancel),
icon: XIcon,
color: 'red',
keepOpen: true,
action: async () => {
await opts.manager.cancel(job.job_id).catch(opts.handleError)
},
})
}
if (isFailedJob(job)) {
const requiresCacheRepair = job.error?.code === 'cache_repair_required'
const repairing = repairingJobIds.value.has(job.job_id)
buttons.push({
label: formatMessage(
requiresCacheRepair
? repairing
? messages.cacheRepairing
: messages.cacheRepairAction
: messages.retry,
),
icon: UpdatedIcon,
color: 'brand',
keepOpen: true,
action: async () => {
if (repairingJobIds.value.has(job.job_id)) return
if (!requiresCacheRepair) {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
return
}
repairingJobIds.value = new Set([...repairingJobIds.value, job.job_id])
opts.onChange()
try {
const snapshot = await install_job_repair_cache_and_retry(job.job_id)
setJobs(jobs.value.map((item) => (item.job_id === job.job_id ? snapshot : item)))
opts.onChange()
await refresh()
} catch (error) {
opts.handleError(
new Error(`${formatMessage(messages.cacheRepairFailed)} ${String(error)}`),
)
} finally {
const next = new Set(repairingJobIds.value)
next.delete(job.job_id)
repairingJobIds.value = next
opts.onChange()
}
},
})
}
if (shouldShowCopyDetails(job)) {
const copied = isCopied(job)
buttons.push({
label: formatMessage(copied ? messages.copied : messages.copyDetails),
icon: copied ? CheckIcon : CopyIcon,
color: 'standard',
keepOpen: true,
action: async () => {
await copyJobDetails(job)
},
})
}
return buttons
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
jobOrder.set(job.job_id, nextJobOrder++)
}
}
const currentJobIds = new Set(jobs.value.map((job) => job.job_id))
const visibleJobs = nextJobs.filter(
(job) =>
visibleJobStatuses.has(job.status) ||
(retainedJobStatuses.has(job.status) && currentJobIds.has(job.job_id)),
)
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
getJobSortRank(a) - getJobSortRank(b) ||
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
jobs.value.map((job) => {
const progress = getEffectiveProgress(job)
const finished = isFinishedJob(job)
return {
id: job.job_id,
title: getTitle(job),
text: getText(job),
iconUrl: iconUrls.value[job.job_id] ?? null,
progress: getProgress(job),
waiting:
['queued', 'running'].includes(job.status) && !hasDeterminateInstallProgress(progress),
showProgress: !finished,
wrapText: isFailedJob(job),
progressType: finished ? undefined : getProgressType(job),
progressCurrent:
!finished && hasDeterminateInstallProgress(progress)
? progress.current
: undefined,
progressTotal:
!finished && hasDeterminateInstallProgress(progress)
? progress.total
: undefined,
buttons: getButtons(job),
onDismiss: finished
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
}
}),
)
const buttons = computed<PopupNotificationButton[] | undefined>(() => undefined)
async function refreshMetadata(notify = true) {
const request = ++metadataRequest
const sourceJobs = jobs.value.filter((job) => !job.instance_deleted)
const instanceIds = Array.from(
new Set(
sourceJobs
.map((job) => installJobInstanceId(job))
.filter((instanceId): instanceId is string => !!instanceId),
),
)
let metadataError: unknown = null
const instances = instanceIds.length
? await getInstances(instanceIds).catch((error) => {
metadataError = error
return []
})
: []
if (request !== metadataRequest) {
return
}
if (metadataError) {
opts.handleError(metadataError)
return
}
const instanceIconUrls = new Map(
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
)
instanceNames.value = Object.fromEntries(
instances.map((instance) => [instance.id, instance.name]),
)
iconUrls.value = Object.fromEntries(
sourceJobs.map((job) => [
job.job_id,
getDisplayIconUrl(job.display?.icon) ??
instanceIconUrls.get(installJobInstanceId(job) ?? '') ??
null,
]),
)
if (notify) {
opts.onChange()
}
}
async function refresh(notify = true) {
const request = ++refreshRequest
await opts.manager.refresh().catch((error) => {
opts.handleError(error)
})
const nextJobs = opts.manager.jobs.value
if (request !== refreshRequest) {
return
}
setJobs(nextJobs)
await refreshMetadata(false)
if (request !== refreshRequest) {
return
}
if (notify) {
opts.onChange()
}
}
let lastCompositionSignature: string | null = null
let lastMetadataRefreshAt = 0
let lastEnteredPackDownload = false
setJobs(opts.manager.jobs.value)
await refreshMetadata(false)
const stopJobsWatch = watch(
() => opts.manager.jobs.value,
(nextJobs) => {
const previousPhases = new Map(jobs.value.map((job) => [job.job_id, job.phase]))
const enteredPackDownload = nextJobs.some(
(job) =>
job.phase === 'downloading_pack_file' &&
previousPhases.get(job.job_id) !== 'downloading_pack_file',
)
const signature = nextJobs.map((job) => `${job.job_id}:${job.status}:${job.phase}`).join('|')
const compositionChanged = signature !== lastCompositionSignature
lastCompositionSignature = signature
setJobs(nextJobs)
if (enteredPackDownload !== lastEnteredPackDownload) {
lastEnteredPackDownload = enteredPackDownload
opts.onChange(enteredPackDownload)
}
if (compositionChanged || Date.now() - lastMetadataRefreshAt >= 2000) {
lastMetadataRefreshAt = Date.now()
void refreshMetadata()
}
},
)
return {
active: computed(() => jobs.value.some((job) => activeJobStatuses.has(job.status))),
hasItems: computed(() => jobs.value.length > 0),
title: computed(() => formatMessage(messages.installs)),
progressItems,
buttons,
refresh,
dispose: () => {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
stopJobsWatch()
},
}
}
@@ -0,0 +1,328 @@
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
interface BrowseServerInstance {
id: string
name: string
path: string
}
interface ContextMenuHandle {
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
}
export interface UseAppServerBrowseOptions {
instance: Ref<BrowseServerInstance | null>
isFromWorlds: ComputedRef<boolean>
allInstalledIds: ComputedRef<Set<string>>
newlyInstalled: Ref<string[]>
installingServerProjects: Ref<string[]>
playServerProject: (projectId: string) => Promise<void>
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
handleError: (error: unknown) => void
router: Router
}
const messages = defineMessages({
add: {
id: 'app.browse.add',
defaultMessage: 'Add',
},
addToInstance: {
id: 'app.browse.add-to-instance',
defaultMessage: 'Add to instance',
},
addToInstanceName: {
id: 'app.browse.add-to-instance-name',
defaultMessage: 'Add to {instanceName}',
},
added: {
id: 'app.browse.added',
defaultMessage: 'Added',
},
alreadyAdded: {
id: 'app.browse.already-added',
defaultMessage: 'Already added',
},
})
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length })
const packs = await listInstances().catch((error) => {
options.handleError(error)
return []
})
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find((pack: GameInstance) => pack.link?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_instance_id(inst.id).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.id
}
}
}
debugLog('runningServerProjects updated', newRunning)
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
debugLog('handleStopServerProject', projectId)
const instanceId = runningServerProjects.value[projectId]
if (!instanceId) return
await kill(instanceId).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
debugLog('handlePlayServerProject', projectId)
await options.playServerProject(projectId)
checkServerRunningStates(lastServerHits.value)
}
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
if (options.instance.value) {
const instanceId = options.instance.value.id
try {
await add_server_to_instance(
instanceId,
project.name,
address,
'prompt',
project.project_id,
project.minecraft_java_server?.content?.kind,
)
options.newlyInstalled.value.push(project.project_id)
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
} catch (error) {
options.handleError(error)
}
} else {
options.showAddServerToInstanceModal(project.name, address)
}
}
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.flatMap((hit) => {
const address = hit.minecraft_java_server?.address
if (!address) return []
return [{ hit, address }]
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (getFreshCachedServerStatus(queryClient, address)) return
try {
const status = await fetchCachedServerStatus(queryClient, address)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
} catch (error) {
console.error(`Failed to ping server ${address}:`, error)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
}
}),
)
}
function updateServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
lastServerHits.value = hits
pingServerHits(hits)
checkServerRunningStates(hits)
}
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon ?? undefined,
onclick:
project_id !== project.project_id
? () => {
options.router.push(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
function getServerCardActions(
serverResult: Labrinth.Search.v3.ResultSearchProject,
): CardAction[] {
const isInstalled = options.allInstalledIds.value.has(serverResult.project_id)
if (options.isFromWorlds.value && options.instance.value) {
return [
{
key: 'add-to-instance',
label: formatMessage(isInstalled ? messages.added : messages.addToInstance),
compactLabel: isInstalled ? undefined : formatMessage(messages.add),
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
color: 'brand',
type: 'outlined',
onClick: () => handleAddServerToInstance(serverResult),
},
]
}
const actions: CardAction[] = []
actions.push({
key: 'add',
label: '',
icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled,
circular: true,
tooltip: isInstalled
? formatMessage(messages.alreadyAdded)
: options.instance.value
? formatMessage(messages.addToInstanceName, {
instanceName: options.instance.value.name,
})
: formatMessage(commonMessages.addServerToInstanceButton),
onClick: () => handleAddServerToInstance(serverResult),
})
if (runningServerProjects.value[serverResult.project_id]) {
actions.push({
key: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
color: 'red',
type: 'outlined',
onClick: () => handleStopServerProject(serverResult.project_id),
})
} else {
const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id)
actions.push({
key: 'play',
label: formatMessage(
isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
),
icon: PlayIcon,
disabled: isInstalling,
color: 'brand',
type: 'outlined',
onClick: () => handlePlayServerProject(serverResult.project_id),
})
}
return actions
}
function handleRightClick(
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
function handleOptionsClick(args: ContextMenuOptionClick) {
const url = getProjectUrl(args.item)
switch (args.option) {
case 'open_link':
openUrl(url)
break
case 'copy_link':
navigator.clipboard.writeText(url)
break
}
}
process_listener((event: { event: string; instance_id: string }) => {
debugLog('process event', event)
if (event.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === event.instance_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
})
.then((unlisten) => {
unlistenProcesses = unlisten
})
.catch(options.handleError)
onUnmounted(() => {
serverPingsActive = false
unlistenProcesses?.()
})
return {
serverPings,
contextMenuRef,
updateServerHits,
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
}
}
function getProjectUrl(
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
}
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mergeLiveLogHistory } from './console-history.ts'
test('retains the persisted latest.log history and appends a live-only tail', () => {
assert.equal(
mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird\nfourth'),
'first\nsecond\nthird\nfourth',
)
})
test('does not duplicate a buffer already persisted in latest.log', () => {
assert.equal(mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird'), 'first\nsecond\nthird')
})
test('keeps live output when it is not present in latest.log', () => {
assert.equal(mergeLiveLogHistory('minecraft', 'launcher'), 'minecraft\nlauncher')
})
test('accepts an unavailable latest.log while the instance is starting', () => {
assert.equal(mergeLiveLogHistory('', 'launcher output'), 'launcher output')
})
@@ -0,0 +1,31 @@
function nonEmptyLines(content: string): string[] {
return content.split(/\r\n|\r|\n/).filter(Boolean)
}
function overlapLength(history: readonly string[], liveBuffer: readonly string[]): number {
const maximum = Math.min(history.length, liveBuffer.length)
for (let length = maximum; length > 0; length--) {
let overlaps = true
for (let index = 0; index < length; index++) {
if (history[history.length - length + index] !== liveBuffer[index]) {
overlaps = false
break
}
}
if (overlaps) return length
}
return 0
}
/**
* Combines the persisted Minecraft log with output that may not have reached
* latest.log yet. The ring buffer commonly repeats the tail of latest.log, so
* retain only its non-overlapping suffix.
*/
export function mergeLiveLogHistory(latestLog: string, liveBuffer: string): string {
const history = nonEmptyLines(latestLog)
const buffer = nonEmptyLines(liveBuffer)
return [...history, ...buffer.slice(overlapLength(history, buffer))].join('\n')
}
@@ -0,0 +1,50 @@
import type { QueryClient } from '@tanstack/vue-query'
import {
get_server_status,
normalizeServerAddress,
type ProtocolVersion,
type ServerStatus,
} from '@/helpers/worlds'
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
if (!protocolVersion) return 'default'
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
}
export function getServerStatusQueryKey(
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return [
'minecraft-server-status',
normalizeServerAddress(address) || address.trim().toLowerCase(),
getProtocolVersionKey(protocolVersion),
] as const
}
export function getFreshCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
const queryKey = getServerStatusQueryKey(address, protocolVersion)
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
return queryClient.getQueryData<ServerStatus>(queryKey)
}
export async function fetchCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return await queryClient.fetchQuery({
queryKey: getServerStatusQueryKey(address, protocolVersion),
queryFn: () => get_server_status(address, protocolVersion),
staleTime: SERVER_STATUS_CACHE_MS,
gcTime: SERVER_STATUS_CACHE_MS,
})
}
@@ -0,0 +1,39 @@
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { isResultValue, nextResultCount } from '@/lab/recipe-generator/count-display'
import type { RecipeSlot, SlotValue } from '@/lab/recipe-generator/types'
const messages = defineMessages({
scrollToAdjust: {
id: 'app.lab.recipe-generator.scroll-to-adjust',
defaultMessage: 'Scroll to adjust quantity',
},
})
export function useResultCountWheel(options: {
getSlot: () => RecipeSlot | null
getValue: () => SlotValue | undefined
getCount: () => number
setCount: (count: number) => void
}) {
const { formatMessage } = useVIntl()
const canAdjust = computed(() => Boolean(options.getSlot() && isResultValue(options.getValue())))
const hint = computed(() =>
canAdjust.value ? formatMessage(messages.scrollToAdjust) : undefined,
)
function onWheel(slot: RecipeSlot, event: WheelEvent) {
if (slot !== options.getSlot()) return
const value = options.getValue()
if (!isResultValue(value)) return
if (event.deltaY === 0) return
event.preventDefault()
event.stopPropagation()
const current = options.getCount()
const next = nextResultCount(current, event.deltaY)
if (next !== current) options.setCount(next)
}
return { canAdjust, hint, onWheel }
}
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core'
import cssContent from '@/assets/stylesheets/macFix.css?inline'
export async function useCheckDisableMouseover() {
try {
// Fetch the CSS content from the Rust backend
let should_disable_mouseover = await invoke('plugin:utils|should_disable_mouseover')
if (should_disable_mouseover) {
// Create a style element and set its content
const styleElement = document.createElement('style')
styleElement.innerHTML = cssContent
// Append the style element to the document's head
document.head.appendChild(styleElement)
}
} catch (error) {
console.error('Error checking OS version from Rust backend', error)
}
}
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ServerConsoleBuffer } from './server-console-buffer.ts'
test('bounds buffered console output without rescanning prior chunks', () => {
const buffer = new ServerConsoleBuffer(5)
buffer.push(Uint8Array.from([1, 2]))
buffer.push(Uint8Array.from([3, 4]))
buffer.push(Uint8Array.from([5, 6]))
assert.equal(buffer.size, 4)
assert.deepEqual(
[...buffer.values()].map((chunk) => [...chunk]),
[
[3, 4],
[5, 6],
],
)
})
test('keeps only the tail of a chunk larger than the capacity', () => {
const buffer = new ServerConsoleBuffer(3)
buffer.push(Uint8Array.from([1, 2, 3, 4, 5]))
assert.equal(buffer.size, 3)
assert.deepEqual(
[...buffer.values()].map((chunk) => [...chunk]),
[[3, 4, 5]],
)
})
@@ -0,0 +1,41 @@
export class ServerConsoleBuffer {
private readonly chunks: Uint8Array[] = []
private readonly capacity: number
private head = 0
private byteLength = 0
constructor(capacity: number) {
this.capacity = capacity
}
push(data: Uint8Array) {
if (data.length >= this.capacity) {
this.chunks.length = 0
this.chunks.push(data.slice(-this.capacity))
this.head = 0
this.byteLength = this.capacity
return
}
this.chunks.push(data)
this.byteLength += data.length
while (this.byteLength > this.capacity && this.head < this.chunks.length - 1) {
this.byteLength -= this.chunks[this.head++]!.length
}
if (this.head >= 1024 && this.head * 2 >= this.chunks.length) {
this.chunks.splice(0, this.head)
this.head = 0
}
}
*values(): IterableIterator<Uint8Array> {
for (let index = this.head; index < this.chunks.length; index++) {
yield this.chunks[index]!
}
}
get size() {
return this.byteLength
}
}
@@ -0,0 +1,106 @@
import type { InstallJobSnapshot } from '@/helpers/install'
import type { DownloadManager } from '@/providers/download-manager'
export interface ServerDownloadJobDisplay {
title: string
icon: string | null
provider: InstallJobSnapshot['provider']
}
export interface ServerDownloadProgress {
downloaded: number
total: number | null
}
/**
* Drives a synthetic install job in the global Downloads page so that a
* server download (modpack or vanilla) appears in the sidebar and history
* exactly like a backend-tracked install job.
*
* A single shared implementation is used by every server-download flow so the
* snapshot shape, status transitions, and cancel handling stay consistent.
*/
export interface ServerDownloadBridge {
update(progress: ServerDownloadProgress, speed: number | null, eta: number | null): void
complete(success: boolean, progress?: ServerDownloadProgress): void
cancel(handler: () => void | Promise<void>): void
}
export function createServerDownloadBridge(
downloadManager: DownloadManager,
jobId: string,
display: ServerDownloadJobDisplay,
): ServerDownloadBridge {
const created = new Date().toISOString()
function snapshot(
status: InstallJobSnapshot['status'],
phase: InstallJobSnapshot['phase'],
progress: ServerDownloadProgress,
speed: number | null,
eta: number | null,
finished?: string,
): InstallJobSnapshot {
const modified = new Date().toISOString()
return {
job_id: jobId,
instance_id: null,
instance_deleted: false,
kind: 'create_instance',
status,
execution_mode: 'normal',
provider: display.provider,
target: { type: 'new_instance' },
phase,
details: { type: 'empty' },
created,
modified,
finished,
display: { title: display.title, icon: display.icon },
summary: {
files_completed: 0,
files_total: null,
bytes_downloaded: progress.downloaded,
bytes_total: progress.total,
speed_bytes_per_second: speed,
eta_seconds: eta,
source: null,
fallback_count: 0,
},
items: [],
}
}
downloadManager.addSyntheticJob(
snapshot('running', 'downloading_content', { downloaded: 0, total: null }, null, null),
)
return {
update(progress, speed, eta) {
downloadManager.setSyntheticJob(
snapshot('running', 'downloading_content', progress, speed, eta),
)
},
complete(success, progress) {
downloadManager.offSyntheticCancel(jobId)
const finalProgress = progress ?? { downloaded: 0, total: null }
const finished = new Date().toISOString()
downloadManager.setSyntheticJob(
snapshot(
success ? 'succeeded' : 'failed',
success ? 'completed' : 'downloading_content',
finalProgress,
null,
null,
finished,
),
)
},
cancel(handler) {
downloadManager.onSyntheticCancel(jobId, async () => {
downloadManager.offSyntheticCancel(jobId)
await handler()
})
},
}
}
@@ -0,0 +1,131 @@
import { forgePromotionsSlimUrl, SERVER_TYPES, type ServerTypeId } from '@modrinth/server'
import {
fetchJson,
resolveServerLauncher,
} from '@/components/multiplayer/servers/server-flow-utils'
import { refresh } from '@/composables/useServers'
import { serverEventListener, servers } from '@/helpers/servers'
import type { DownloadManager } from '@/providers/download-manager'
import { createServerDownloadBridge, type ServerDownloadBridge } from './server-download-bridge'
export interface ServerInstallInputs {
gameVersion: string
loaderVersion?: string
javaPath?: string
memoryMb?: number
}
/**
* A server-type install strategy encapsulates the "how" of materializing a
* server's launchable files. The shared orchestrator (`runServerInstall`) owns
* the cross-cutting concerns — the sidebar download job, progress/log event
* forwarding, and cancellation — so every strategy only drives the backend for
* its specific loader. This is what lets vanilla, Fabric, Paper, Forge and
* (via `ModpackServerInstallStrategy`) modpack servers share one flow.
*/
export interface ServerInstallStrategy {
readonly id: ServerTypeId | 'modpack'
install(serverId: string, inputs: ServerInstallInputs): Promise<void>
}
/** `direct` install-mode types: a single launcher jar is downloaded and booted. */
export class JarServerInstallStrategy implements ServerInstallStrategy {
readonly id: ServerTypeId
constructor(private readonly type: ServerTypeId) {
this.id = type
}
async install(serverId: string, inputs: ServerInstallInputs): Promise<void> {
const jar = await resolveServerLauncher(this.type, inputs.gameVersion, inputs.loaderVersion)
if (!jar) {
throw new Error(`No server launcher available for ${this.type} on ${inputs.gameVersion}`)
}
await servers.downloadFile(serverId, jar.url, jar.filename, jar.sha1)
}
}
/** Resolves the recommended (or, failing that, latest) Forge build for a game version. */
export async function resolveForgeBuild(gameVersion: string): Promise<string> {
const promos = await fetchJson<{ promos: Record<string, string> }>(forgePromotionsSlimUrl())
const build =
promos.promos[`${gameVersion}-recommended`] ?? promos.promos[`${gameVersion}-latest`]
if (!build) throw new Error(`No Forge build available for Minecraft ${gameVersion}`)
return build
}
/** `installer` install-mode Forge: download the installer and run it headlessly. */
export class ForgeServerInstallStrategy implements ServerInstallStrategy {
readonly id = 'forge' as const
async install(serverId: string, inputs: ServerInstallInputs): Promise<void> {
const build = await resolveForgeBuild(inputs.gameVersion)
await servers.installForge(serverId, inputs.gameVersion, build, inputs.javaPath)
}
}
/** Picks the install strategy for a server type from its declared install mode. */
export function getServerInstallStrategy(type: ServerTypeId): ServerInstallStrategy {
const def = SERVER_TYPES[type]
if (def.installMode === 'installer') {
if (type === 'forge') return new ForgeServerInstallStrategy()
throw new Error(`Server type '${type}' installer is not supported yet`)
}
return new JarServerInstallStrategy(type)
}
export interface RunServerInstallOptions {
serverId: string
name: string
inputs: ServerInstallInputs
strategy: ServerInstallStrategy
downloadManager: DownloadManager | null
onProgress?: (progress: { downloaded: number; total: number | null }) => void
onLog?: (line: string) => void
}
/**
* Runs a server install end to end: registers a sidebar download job, forwards
* backend progress/log events to the caller, and invokes the strategy. It does
* not write `eula.txt` or perform cleanup — those depend on flow state and stay
* with the caller.
*/
export async function runServerInstall(options: RunServerInstallOptions): Promise<void> {
const { serverId, name, inputs, strategy, downloadManager, onProgress, onLog } = options
const bridge: ServerDownloadBridge | null = downloadManager
? createServerDownloadBridge(downloadManager, `server-${serverId}`, {
title: name,
icon: null,
provider: 'minecraft',
})
: null
bridge?.cancel(async () => {
await servers.stop(serverId).catch(() => {})
})
const unlisten = await serverEventListener((eventServerId, payload) => {
if (eventServerId !== serverId) return
if (payload.event === 'download_progress') {
const progress = { downloaded: payload.downloaded, total: payload.total ?? null }
onProgress?.(progress)
bridge?.update(progress, null, null)
} else if (payload.event === 'log') {
onLog?.(payload.line)
}
})
try {
await strategy.install(serverId, inputs)
bridge?.complete(true)
// The server was created up front, but the shared list only refreshes on
// demand. When the modal was closed mid-install the `@created` event
// never fires, so refresh here (context-free) to render the new server
// as soon as the install finishes — regardless of modal state.
await refresh().catch(() => {})
} catch (error) {
bridge?.complete(false)
throw error
} finally {
unlisten()
}
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ContentFavorite, ContentFavoriteInput } from '../helpers/content-favorites.ts'
import { createContentFavoritesStore } from './useContentFavorites.ts'
function favorite(
provider: ContentFavorite['provider'],
projectId: string,
contentType: ContentFavorite['content_type'],
savedAt: number,
): ContentFavorite {
return {
provider,
project_id: projectId,
content_type: contentType,
saved_at: savedAt,
}
}
test('content favorites load, add, remove, and keep provider-qualified identities', async () => {
const records = [
favorite('modrinth', 'same-id', 'mod', 1),
favorite('curseforge', 'same-id', 'resourcepack', 2),
]
const store = createContentFavoritesStore({
async list() {
return records
},
async add(input: ContentFavoriteInput) {
const saved = favorite(input.provider, input.project_id, input.content_type, 3)
records.push(saved)
return saved
},
async remove(provider, projectId) {
const index = records.findIndex(
(record) => record.provider === provider && record.project_id === projectId,
)
if (index >= 0) records.splice(index, 1)
},
})
await store.load()
assert.deepEqual(
store.favorites.value.map((record) => `${record.provider}:${record.project_id}`),
['curseforge:same-id', 'modrinth:same-id'],
)
assert.equal(store.isFavorite('modrinth', 'same-id'), true)
assert.equal(store.isFavorite('curseforge', 'same-id'), true)
await store.add({ provider: 'modrinth', project_id: 'iris', content_type: 'shader' })
assert.equal(store.isFavorite('modrinth', 'iris'), true)
await store.remove('modrinth', 'same-id')
assert.equal(store.isFavorite('modrinth', 'same-id'), false)
assert.equal(store.isFavorite('curseforge', 'same-id'), true)
})
test('content favorite writes roll back busy state when persistence fails', async () => {
const store = createContentFavoritesStore({
async list() {
return []
},
async add() {
throw new Error('database unavailable')
},
async remove() {
throw new Error('database unavailable')
},
})
await assert.rejects(
store.add({ provider: 'modrinth', project_id: 'sodium', content_type: 'mod' }),
)
assert.equal(store.isFavorite('modrinth', 'sodium'), false)
assert.equal(store.isPending('modrinth', 'sodium'), false)
await store.load()
store.favorites.value = [favorite('curseforge', 'embeddium', 'mod', 4)]
await assert.rejects(store.remove('curseforge', 'embeddium'))
assert.equal(store.isFavorite('curseforge', 'embeddium'), true)
assert.equal(store.isPending('curseforge', 'embeddium'), false)
})
@@ -0,0 +1,162 @@
import { computed, ref } from 'vue'
import {
addContentFavorite,
type ContentFavorite,
type ContentFavoriteInput,
contentFavoriteKey,
type FavoriteProvider,
listContentFavorites,
removeContentFavorite,
} from '../helpers/content-favorites.ts'
export interface ContentFavoritesApi {
list: () => Promise<ContentFavorite[]>
add: (input: ContentFavoriteInput) => Promise<ContentFavorite>
remove: (provider: FavoriteProvider, projectId: string) => Promise<void>
}
function sortFavorites(values: ContentFavorite[]) {
return [...values].sort(
(left, right) =>
right.saved_at - left.saved_at ||
left.provider.localeCompare(right.provider) ||
left.project_id.localeCompare(right.project_id),
)
}
function sameFavorites(left: ContentFavorite[], right: ContentFavorite[]) {
return (
left.length === right.length &&
left.every(
(favorite, index) =>
favorite.provider === right[index]?.provider &&
favorite.project_id === right[index]?.project_id &&
favorite.content_type === right[index]?.content_type &&
favorite.saved_at === right[index]?.saved_at,
)
)
}
export function createContentFavoritesStore(api: ContentFavoritesApi) {
const favorites = ref<ContentFavorite[]>([])
const loaded = ref(false)
const loading = ref(false)
const pendingKeys = ref(new Set<string>())
let loadPromise: Promise<ContentFavorite[]> | null = null
function setFavorites(values: ContentFavorite[]) {
const sorted = sortFavorites(values)
if (!sameFavorites(favorites.value, sorted)) favorites.value = sorted
return favorites.value
}
function setPending(key: string, pending: boolean) {
const next = new Set(pendingKeys.value)
if (pending) next.add(key)
else next.delete(key)
pendingKeys.value = next
}
async function load(force = false): Promise<ContentFavorite[]> {
if (loaded.value && !force) return favorites.value
if (loadPromise) return await loadPromise
loading.value = true
loadPromise = api
.list()
.then((values) => {
setFavorites(values)
loaded.value = true
return favorites.value
})
.finally(() => {
loading.value = false
loadPromise = null
})
return await loadPromise
}
const favoriteKeys = computed(
() =>
new Set(
favorites.value.map((favorite) =>
contentFavoriteKey(favorite.provider, favorite.project_id),
),
),
)
function isFavorite(provider: FavoriteProvider, projectId: string) {
return favoriteKeys.value.has(contentFavoriteKey(provider, projectId))
}
function isPending(provider: FavoriteProvider, projectId: string) {
return pendingKeys.value.has(contentFavoriteKey(provider, projectId))
}
async function add(input: ContentFavoriteInput) {
const key = contentFavoriteKey(input.provider, input.project_id)
if (pendingKeys.value.has(key)) return
setPending(key, true)
try {
const saved = await api.add(input)
setFavorites([
...favorites.value.filter(
(favorite) => contentFavoriteKey(favorite.provider, favorite.project_id) !== key,
),
saved,
])
loaded.value = true
} finally {
setPending(key, false)
}
}
async function remove(provider: FavoriteProvider, projectId: string) {
const key = contentFavoriteKey(provider, projectId)
if (pendingKeys.value.has(key)) return
setPending(key, true)
try {
await api.remove(provider, projectId)
setFavorites(
favorites.value.filter(
(favorite) => contentFavoriteKey(favorite.provider, favorite.project_id) !== key,
),
)
loaded.value = true
} finally {
setPending(key, false)
}
}
async function toggle(input: ContentFavoriteInput) {
if (isFavorite(input.provider, input.project_id)) {
await remove(input.provider, input.project_id)
} else {
await add(input)
}
}
return {
favorites,
loaded,
loading,
load,
isFavorite,
isPending,
add,
remove,
toggle,
}
}
const sharedStore = createContentFavoritesStore({
list: listContentFavorites,
add: addContentFavorite,
remove: removeContentFavorite,
})
export function useContentFavorites() {
return sharedStore
}
@@ -0,0 +1,129 @@
import { type Ref, ref } from 'vue'
import { analyze_crash } from '@/helpers/logs'
export interface CrashAnalysisEvidence {
filename: string
line: number
text: string
}
export interface CrashAnalysisFinding {
id: string
confidence: 'high' | 'medium'
evidence: CrashAnalysisEvidence[]
}
export interface CrashAnalysisMod {
file_name: string
id?: string
name?: string
matched_class?: string
}
export interface CrashModChange {
kind: 'added' | 'removed' | 'modified'
filename: string
previous_size?: number
current_size?: number
current_sha256?: string
project_id?: string
project_title?: string
icon_url?: string
version_id?: string
version_number?: string
}
export interface CrashModChangeCounts {
added: number
removed: number
modified: number
}
export interface WindowsCrashEvent {
event_id: number
provider: string
time_created: string
message: string
}
export interface CrashAnalysisResult {
instance_id: string
ruleset: string
crashed: boolean
sources: Array<{
filename: string
source_type: string
modified_at: number
line_count: number
content: string
}>
findings: CrashAnalysisFinding[]
mods: CrashAnalysisMod[]
combined_log: string
mod_changes: CrashModChange[]
mod_change_counts: CrashModChangeCounts
windows_events: WindowsCrashEvent[]
}
interface CrashAnalysisState {
analysis: Ref<CrashAnalysisResult | null>
loading: Ref<boolean>
error: Ref<unknown>
pending?: Promise<CrashAnalysisResult | null>
}
const states = new Map<string, CrashAnalysisState>()
function getState(instanceId: string): CrashAnalysisState {
let state = states.get(instanceId)
if (!state) {
state = {
analysis: ref(null),
loading: ref(false),
error: ref(null),
}
states.set(instanceId, state)
}
return state
}
export async function refreshCrashAnalysis(
instanceId: string,
): Promise<CrashAnalysisResult | null> {
const state = getState(instanceId)
if (state.pending) return state.pending
state.loading.value = true
state.error.value = null
state.pending = analyze_crash(instanceId)
.then((analysis: CrashAnalysisResult) => {
state.analysis.value = analysis
return analysis
})
.catch((error) => {
state.error.value = error
throw error
})
.finally(() => {
state.loading.value = false
state.pending = undefined
})
return state.pending
}
export function clearCrashAnalysis(instanceId: string): void {
const state = getState(instanceId)
state.analysis.value = null
state.error.value = null
}
export function useCrashAnalysis(instanceId: string) {
const state = getState(instanceId)
return {
analysis: state.analysis,
loading: state.loading,
error: state.error,
refresh: () => refreshCrashAnalysis(instanceId),
clear: () => clearCrashAnalysis(instanceId),
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,185 @@
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import { computed } from 'vue'
export const UNGROUPED_GROUP_KEY = '__ungrouped__'
export type GridGroupingOption = 'Group' | 'Loader' | 'Game version' | 'None'
export type GridSortOption =
| 'Name'
| 'Last played'
| 'Date created'
| 'Date modified'
| 'Game version'
export interface GridDisplayState {
group: GridGroupingOption
sortBy: GridSortOption
collapsedGroups: string[]
}
export function useGridGrouping<T extends Record<string, unknown>>(
label: string,
instances: Ref<T[]>,
options: {
getSearchText?: (instance: T) => string
getLoader?: (instance: T) => string
getGameVersion?: (instance: T) => string
getGroups?: (instance: T) => string[]
getLastPlayed?: (instance: T) => string | number | null
getDateCreated?: (instance: T) => string
getDateModified?: (instance: T) => string
formatLoader?: (loader: string) => string
} = {},
) {
const state = useStorage<GridDisplayState>(
`${label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const grouping = computed(() => state.value.group)
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName: string) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName: string) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName: string, collapsed: boolean) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
function sortInstances(instances: T[], sortBy: GridSortOption): T[] {
const sorted = [...instances]
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getLastPlayed = options.getLastPlayed ?? ((i: T) => i.last_played ?? 0)
const getDateCreated = options.getDateCreated ?? ((i: T) => i.date_created ?? '')
const getDateModified = options.getDateModified ?? ((i: T) => i.date_modified ?? '')
switch (sortBy) {
case 'Name':
sorted.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
break
case 'Game version':
sorted.sort((a, b) =>
getGameVersion(a).localeCompare(getGameVersion(b), undefined, { numeric: true }),
)
break
case 'Last played':
sorted.sort((a, b) => dayjs(getLastPlayed(b)).diff(dayjs(getLastPlayed(a))))
break
case 'Date created':
sorted.sort((a, b) => dayjs(getDateCreated(b)).diff(dayjs(getDateCreated(a))))
break
case 'Date modified':
sorted.sort((a, b) => dayjs(getDateModified(b)).diff(dayjs(getDateModified(a))))
break
}
return sorted
}
function groupInstances(instances: T[], group: GridGroupingOption): Map<string, T[]> {
const instanceMap = new Map<string, T[]>()
const getLoader = options.getLoader ?? ((i: T) => i.loader ?? '')
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getGroups = options.getGroups ?? ((i: T) => i.groups ?? [])
const formatLoader = options.formatLoader ?? ((l: string) => l)
switch (group) {
case 'Loader':
instances.forEach((instance) => {
const loader = formatLoader(getLoader(instance))
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader)!.push(instance)
})
break
case 'Game version':
instances.forEach((instance) => {
const gameVersion = getGameVersion(instance)
if (!instanceMap.has(gameVersion)) {
instanceMap.set(gameVersion, [])
}
instanceMap.get(gameVersion)!.push(instance)
})
break
case 'Group':
instances.forEach((instance) => {
const groups = getGroups(instance)
const category = groups.length > 0 ? groups[0] : UNGROUPED_GROUP_KEY
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category)!.push(instance)
})
break
case 'None':
default:
instanceMap.set('None', instances)
break
}
return instanceMap
}
function sortSections(
instanceMap: Map<string, T[]>,
group: GridGroupingOption,
): Map<string, T[]> {
if (group !== 'Group' && group !== 'Game version') {
return instanceMap
}
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
if (group === 'Group') {
if (a[0] === UNGROUPED_GROUP_KEY && b[0] !== UNGROUPED_GROUP_KEY) return -1
if (a[0] !== UNGROUPED_GROUP_KEY && b[0] === UNGROUPED_GROUP_KEY) return 1
}
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => instanceMap.set(entry[0], entry[1]))
return instanceMap
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const sorted = sortInstances(instances.value, sortBy)
const grouped = groupInstances(sorted, group)
return sortSections(grouped, group)
})
return {
state,
grouping,
filteredResults,
isSectionCollapsed,
setSectionCollapsed,
UNGROUPED_GROUP_KEY,
}
}
@@ -0,0 +1,132 @@
import { createConsoleState } from '@modrinth/ui'
import {
clear_log_buffer,
get_live_log_buffer,
get_logs,
get_minecraft_latest_log_cursor,
} from '@/helpers/logs'
import { mergeLiveLogHistory } from './console-history'
type ConsoleState = ReturnType<typeof createConsoleState>
interface LogEntry {
filename: string
name?: string
log_type: string
output?: string | null
age?: number
live?: boolean
}
interface InstanceConsoleEntry {
liveConsole: ConsoleState
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
liveHistoryHydration: Promise<void> | null
}
const instances = new Map<string, InstanceConsoleEntry>()
function getOrCreate(instanceId: string): InstanceConsoleEntry {
let entry = instances.get(instanceId)
if (entry) return entry
entry = {
liveConsole: createConsoleState(),
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
liveHistoryHydration: null,
}
instances.set(instanceId, entry)
return entry
}
async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
if (entry.liveConsole.output.value.length > 0) return
if (entry.liveHistoryHydration) {
return entry.liveHistoryHydration
}
const hydration = (async () => {
const [latestLog, buffer] = await Promise.all([
get_minecraft_latest_log_cursor(instanceId, 0)
.then((result) => result.output)
.catch(() => ''),
get_live_log_buffer(instanceId),
])
if (entry.liveConsole.output.value.length > 0) return
const history = mergeLiveLogHistory(latestLog, buffer)
if (history) {
await entry.liveConsole.addLegacyLog(history)
}
})()
entry.liveHistoryHydration = hydration
try {
await hydration
} finally {
if (entry.liveHistoryHydration === hydration) {
entry.liveHistoryHydration = null
}
}
}
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
const entry = getOrCreate(instanceId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instanceId, true)
entry.logList = logs
for (const log of logs) {
if (log.output) {
entry.historicalCache.set(log.filename, log.output)
}
}
return logs
}
function getHistoricalContent(instanceId: string, filename: string): string | undefined {
return instances.get(instanceId)?.historicalCache.get(filename)
}
function invalidate(instanceId: string): void {
const entry = instances.get(instanceId)
if (!entry) return
entry.historicalCache.clear()
entry.logList = null
}
async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
entry.liveConsole.clear()
await clear_log_buffer(instanceId).catch(() => {})
}
async function destroy(instanceId: string): Promise<void> {
instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
}
export function useInstanceConsole(instanceId: string) {
const entry = getOrCreate(instanceId)
return {
liveConsole: entry.liveConsole,
historicalConsole: entry.historicalConsole,
hydrate: () => hydrate(instanceId),
getHistoricalLogs: () => getHistoricalLogs(instanceId),
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId),
destroy: () => destroy(instanceId),
}
}
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_jre, test_jre } from '@/helpers/jre.js'
export default function useJavaTest() {
const testingJava = ref(false)
const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number | null, track = true) {
if (testDebounceTimer) {
clearTimeout(testDebounceTimer)
testDebounceTimer = null
}
if (!path) {
javaTestResult.value = null
return
}
testingJava.value = true
try {
if (version == null) {
javaTestResult.value = !!(await get_jre(path))
} else {
javaTestResult.value = await test_jre(path, version)
}
} catch {
javaTestResult.value = false
}
testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
}
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
if (testDebounceTimer) clearTimeout(testDebounceTimer)
if (!path) {
javaTestResult.value = null
return
}
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
}
async function testJavaInstallation(path: string, version: number | null, track = false) {
await runJavaTest(path, version, track)
}
return {
testingJava,
javaTestResult,
testJavaInstallationDebounced,
testJavaInstallation,
}
}
@@ -0,0 +1,21 @@
import { computed, ref } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
export default async function () {
const maxMemory = ref(Math.floor((await get_max_memory()) / 1024))
const snapPoints = computed(() => {
let points = []
let memory = 2048
while (memory <= maxMemory.value) {
points.push(memory)
memory *= 2
}
return points
})
return { maxMemory, snapPoints }
}
@@ -0,0 +1,19 @@
import { inject, type InjectionKey } from 'vue'
export interface MinecraftLaunchErrorPayload {
instance_id: string
instance_name: string
}
export type MinecraftLaunchErrorHandler = (
error: unknown,
payload: MinecraftLaunchErrorPayload,
) => Promise<boolean>
export const minecraftLaunchErrorKey: InjectionKey<MinecraftLaunchErrorHandler> =
Symbol('minecraft-launch-error')
export function useMinecraftLaunchError(): MinecraftLaunchErrorHandler {
const handler = inject(minecraftLaunchErrorKey)
return async (error, payload) => (await handler?.(error, payload)) ?? false
}
@@ -0,0 +1,183 @@
import { injectNotificationManager } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
type DetectedLanPort,
type HongshiNode,
multiplayer,
type MultiplayerProvider,
type MultiplayerState,
} from '@/helpers/multiplayer'
import { terracotta, type TerracottaUpdate } from '@/helpers/terracotta'
import { exportErrorLogs } from '@/helpers/utils'
const ACTIVE_POLL_INTERVAL = 500
const IDLE_POLL_INTERVAL = 2000
export function useMultiplayerSession() {
const { handleError } = injectNotificationManager()
const multiplayerState = ref<MultiplayerState | null>(null)
const nodes = ref<HongshiNode[]>([])
const detectedPorts = ref<DetectedLanPort[]>([])
const playerName = ref('')
const roomCodeInput = ref('')
const platformKey = ref('unknown')
const isActionPending = ref(false)
const isNodesLoading = ref(false)
const isExportingReport = ref(false)
const terracottaUpdate = ref<TerracottaUpdate | null>(null)
let mounted = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let pollPromise: Promise<void> | undefined
const state = computed(() => multiplayerState.value?.terracotta ?? null)
const hongshiState = computed(() => multiplayerState.value?.hongshi ?? null)
const activeProvider = computed(() => multiplayerState.value?.active_provider ?? null)
function pollInterval() {
const status = hongshiState.value?.status
return activeProvider.value || ['selecting_node', 'starting', 'open'].includes(status ?? '')
? ACTIVE_POLL_INTERVAL
: IDLE_POLL_INTERVAL
}
function schedulePoll() {
if (!mounted) return
clearTimeout(pollTimer)
pollTimer = setTimeout(() => void pollState(), pollInterval())
}
async function pollState() {
if (!mounted) return
if (pollPromise) return pollPromise
pollPromise = Promise.all([multiplayer.getState(), multiplayer.getDetectedPorts()])
.then(([nextState, ports]) => {
if (!mounted) return
multiplayerState.value = nextState
detectedPorts.value = ports
})
.catch((error: unknown) => {
if (mounted) console.error(error)
})
.finally(() => {
pollPromise = undefined
schedulePoll()
})
return pollPromise
}
async function runAction(action: () => Promise<void>) {
if (isActionPending.value) return false
isActionPending.value = true
try {
await action()
return true
} catch (error: unknown) {
handleError(error)
return false
} finally {
isActionPending.value = false
await pollState()
}
}
async function refreshNodes(forceRefresh = false) {
if (isNodesLoading.value) return
isNodesLoading.value = true
try {
nodes.value = await multiplayer.getNodes(forceRefresh)
} catch (error: unknown) {
handleError(error)
} finally {
isNodesLoading.value = false
}
}
const switchProvider = (provider: MultiplayerProvider) =>
runAction(() => multiplayer.switchProvider(provider))
const startTerracotta = () => runAction(multiplayer.prepareTerracotta)
const hostTerracotta = () => runAction(() => multiplayer.hostTerracotta(playerName.value))
const joinTerracotta = () =>
runAction(() => multiplayer.joinTerracotta(playerName.value, roomCodeInput.value))
const hostHongshi = (localPort: number, nodeName: string | null, instanceId: string | null) =>
runAction(() => multiplayer.hostHongshi(localPort, nodeName, instanceId))
const stop = () => runAction(multiplayer.stop)
const reset = () => runAction(multiplayer.reset)
const downloadTerracotta = () => runAction(terracotta.download)
const updateTerracotta = () =>
runAction(async () => {
terracottaUpdate.value = await terracotta.update()
})
const downloadHongshi = () => runAction(multiplayer.downloadHongshi)
async function exportTerracottaReport() {
if (isExportingReport.value) return
isExportingReport.value = true
try {
const report = await terracotta.getDiagnosticReport()
await exportErrorLogs(report, 'Axolotl multiplayer error report')
} catch (error: unknown) {
handleError(error)
} finally {
isExportingReport.value = false
}
}
const openHongshiLogs = () => runAction(multiplayer.openHongshiLogs)
onMounted(() => {
mounted = true
void pollState()
void terracotta
.getPlatformKey()
.then((value) => {
if (mounted) platformKey.value = value
})
.catch(() => undefined)
void terracotta
.checkForUpdate()
.then((update) => {
if (mounted) terracottaUpdate.value = update
})
.catch(() => undefined)
void multiplayer
.getPlayerName()
.then((value) => {
if (mounted && value) playerName.value = value
})
.catch(() => undefined)
})
onUnmounted(() => {
mounted = false
clearTimeout(pollTimer)
})
return {
activeProvider,
detectedPorts,
downloadTerracotta,
downloadHongshi,
exportTerracottaReport,
hongshiState,
hostHongshi,
hostTerracotta,
isActionPending,
isNodesLoading,
isExportingReport,
joinTerracotta,
multiplayerState,
nodes,
openHongshiLogs,
platformKey,
playerName,
refreshNodes,
reset,
roomCodeInput,
startTerracotta,
state,
stop,
switchProvider,
terracottaUpdate,
updateTerracotta,
}
}
@@ -0,0 +1,32 @@
import { computed, readonly, ref } from 'vue'
const browserOffline = ref(typeof navigator !== 'undefined' && !navigator.onLine)
const networkReachable = ref<boolean | undefined>()
const offline = computed(() => browserOffline.value || networkReachable.value === false)
if (typeof window !== 'undefined') {
window.addEventListener('offline', () => {
browserOffline.value = true
})
window.addEventListener('online', () => {
browserOffline.value = false
networkReachable.value = undefined
})
}
export function useNetworkStatus() {
return {
offline: readonly(offline),
browserOffline: readonly(browserOffline),
setNetworkReachable(reachable: boolean) {
networkReachable.value = reachable
},
refreshBrowserOffline() {
browserOffline.value = typeof navigator !== 'undefined' && !navigator.onLine
},
}
}
export function isOfflineMode() {
return offline.value
}
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { get_post_upgrade_notice } from '@/helpers/instance'
export function postUpgradeNoticeQueryKey(instanceId: string) {
return ['post-upgrade-notice', instanceId] as const
}
export function usePostUpgradeNotice(instanceId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => postUpgradeNoticeQueryKey(toValue(instanceId))),
queryFn: async () => {
try {
return await get_post_upgrade_notice(toValue(instanceId))
} catch (error) {
if (import.meta.env.DEV) {
console.error('Failed to load post-upgrade notice', error)
}
throw error
}
},
enabled: computed(() => toValue(instanceId).length > 0),
staleTime: 0,
refetchOnMount: 'always',
})
}
@@ -0,0 +1,196 @@
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<Record<string, ActiveServerInstall | undefined>>({})
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<void> {
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<void> {
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,
)
}
@@ -0,0 +1,166 @@
import { parseEula, setEulaAccepted } from '@modrinth/server'
import { injectNotificationManager } from '@modrinth/ui'
import { onScopeDispose, ref, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import type EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
import { resumeModpackInstall } from '@/composables/useServerInstalls'
import { type ServerView, setServerExitReasonHandler, useServers } from '@/composables/useServers'
import { serverEventListener, servers as serversApi } from '@/helpers/servers'
import { injectDownloadManager } from '@/providers/download-manager'
/**
* Shared "start with EULA gate" flow used by the servers overview and the
* server detail page. Bind the returned `eulaModal` ref to an `<EulaModal>`
* rendered with `ref="eulaModal"` in the component template.
*/
export function useServerLifecycle() {
const { startServer } = useServers()
const { handleError } = injectNotificationManager()
// [SERVER-DOWNLOAD-BRIDGE] Capture the download manager once during Vue
// setup context. See the note in `startModpackServerInstall` for why
// this must be done here and not later.
let downloadManager: ReturnType<typeof injectDownloadManager> | null = null
try {
downloadManager = injectDownloadManager()
} catch {
// Not inside a provider tree — server downloads will not appear in sidebar.
}
const eulaModal = useTemplateRef<ComponentExposed<typeof EulaModal>>('eulaModal')
const eulaText = ref('')
let pendingId = ''
// Listen for real-time EULA prompt detection from the Rust backend
let unregisterEulaListener: (() => void) | null = null
serverEventListener((serverId, payload) => {
if (payload.event === 'eula_required' && payload.server_id === pendingId) {
eulaText.value = payload.eula_text
eulaModal.value?.show()
}
}).then((unregister) => {
unregisterEulaListener = unregister
})
onScopeDispose(() => {
unregisterEulaListener?.()
})
/**
* Starts the server, gating on the EULA file:
* - eula.txt missing → code-create it with `eula=false`, then show the modal
* - eula.txt present and `eula=false` → show the modal
* - eula.txt present and `eula=true` → start directly
*/
async function tryStartServer(server: ServerView) {
const accepted = await ensureEula(server.id)
if (accepted) {
await launchServer(server.id)
} else {
pendingId = server.id
eulaModal.value?.show()
}
}
/**
* Reads the server's eula.txt. When absent, writes a code-created
* `eula=false` file so the acceptance prompt can be shown without booting
* the server. Returns whether the EULA is already accepted.
*/
async function ensureEula(serverId: string): Promise<boolean> {
let text: string
try {
text = await serversApi.readFile(serverId, 'eula.txt')
} catch {
// eula.txt doesn't exist yet — code-create it with eula=false so the
// manual start gate can offer the prompt without starting the jar.
text = setEulaAccepted('', false)
await serversApi.writeFile(serverId, 'eula.txt', text).catch(() => {})
}
const doc = parseEula(text)
if (doc.accepted) return true
eulaText.value = text
return false
}
/**
* Starts the server and, when the start itself fails over an unaccepted
* EULA, falls back to the confirmation dialog instead of just the error.
*/
async function launchServer(serverId: string) {
const started = await startServer(serverId)
if (started) return
try {
const text = await serversApi.readFile(serverId, 'eula.txt')
if (parseEula(text).accepted) return
eulaText.value = text
pendingId = serverId
eulaModal.value?.show()
} catch {
// Start failed for a non-EULA reason; that error was already surfaced.
}
}
async function acceptEula() {
const id = pendingId
if (!id) return
try {
const updated = setEulaAccepted(eulaText.value, true)
await serversApi.writeFile(id, 'eula.txt', updated)
pendingId = ''
eulaModal.value?.hide()
// Start the server after accepting EULA
await startServer(id)
} catch (error) {
console.error(error)
}
}
function declineEula() {
pendingId = ''
eulaModal.value?.hide()
}
/**
* Offers the EULA dialog after the server exited on its own over an
* unaccepted EULA (detected from the process's final output). Accepting
* writes `eula.txt` and restarts, matching the pre-start gate.
*/
async function offerEulaAfterExit(serverId: string) {
try {
const text = await serversApi.readFile(serverId, 'eula.txt')
if (parseEula(text).accepted) return
eulaText.value = text
pendingId = serverId
eulaModal.value?.show()
} catch {
// No eula.txt to show; the exit stays unexplained.
}
}
const unregisterExitReasonHandler = setServerExitReasonHandler((serverId, reason) => {
if (reason === 'eula') void offerEulaAfterExit(serverId)
})
onScopeDispose(unregisterExitReasonHandler)
/** Resumes or retries an interrupted/failed modpack download for this server. */
async function resumeInstall(server: ServerView) {
try {
// [SERVER-DOWNLOAD-BRIDGE] Pass the download manager captured
// during setup so the synthetic job appears in sidebar.
await resumeModpackInstall(server, downloadManager)
} catch (error) {
handleError(error)
}
}
return {
eulaModal,
eulaText,
tryStartServer,
acceptEula,
declineEula,
resumeInstall,
offerEulaAfterExit,
}
}
@@ -0,0 +1,194 @@
import { computeServerStatus, type ServerStatus } from '@modrinth/server'
import { injectNotificationManager } from '@modrinth/ui'
import { computed, reactive, ref } from 'vue'
import {
base64ToBytes,
serverEventListener,
type ServerExitReason,
type ServerInfoData,
servers,
} from '@/helpers/servers'
import { ServerConsoleBuffer } from './server-console-buffer'
const LOG_CAPACITY = 5000
/** Reacts to a classified server self-exit, e.g. opening the EULA dialog. */
type ServerExitReasonHandler = (serverId: string, reason: ServerExitReason) => void
let exitReasonHandler: ServerExitReasonHandler | null = null
/**
* Registers the single handler invoked when a server exits with a classified
* reason. Returns a disposer that only clears the handler while it is still
* the registered one.
*/
export function setServerExitReasonHandler(handler: ServerExitReasonHandler | null) {
const registered = handler
exitReasonHandler = handler
return () => {
if (exitReasonHandler === registered) exitReasonHandler = null
}
}
const serverList = ref<ServerInfoData[]>([])
const logLines = reactive<Record<string, string[]>>({})
const isRefreshing = ref(false)
let listenerPromise: Promise<() => void> | null = null
const consoleOutputListeners = new Map<string, Set<(data: Uint8Array) => void>>()
const consoleOutputBuffers = new Map<string, ServerConsoleBuffer>()
const CONSOLE_OUTPUT_CAPACITY = 64 * 1024
export interface ServerView extends ServerInfoData {
status: ServerStatus
}
export function serverStatus(server: ServerInfoData): ServerStatus {
return computeServerStatus({
manifest: { id: server.id },
isRunning: server.running,
isStarting: false,
lastExitWasCrash: server.lastExitCrashed,
eulaAccepted: server.eulaAccepted,
eulaFileExists: server.eulaExists,
})
}
async function appendLog(serverId: string, line: string) {
const lines = (logLines[serverId] ??= [])
lines.push(line)
if (lines.length > LOG_CAPACITY) lines.splice(0, lines.length - LOG_CAPACITY)
}
async function ensureListener() {
if (!listenerPromise) {
listenerPromise = serverEventListener((serverId, payload) => {
if (payload.event === 'log') {
void appendLog(serverId, payload.line)
} else if (payload.event === 'console_output') {
const data = base64ToBytes(payload.data)
const buffer =
consoleOutputBuffers.get(serverId) ?? new ServerConsoleBuffer(CONSOLE_OUTPUT_CAPACITY)
buffer.push(data)
consoleOutputBuffers.set(serverId, buffer)
for (const listener of consoleOutputListeners.get(serverId) ?? []) {
listener(data)
}
} else if (payload.event === 'started') {
void refresh()
} else if (payload.event === 'stopped') {
consoleOutputBuffers.delete(serverId)
void refresh()
if (payload.reason) exitReasonHandler?.(serverId, payload.reason)
}
})
}
return listenerPromise
}
export function subscribeServerConsoleOutput(
serverId: string,
listener: (data: Uint8Array) => void,
): () => void {
const listeners = consoleOutputListeners.get(serverId) ?? new Set()
listeners.add(listener)
consoleOutputListeners.set(serverId, listeners)
for (const data of consoleOutputBuffers.get(serverId)?.values() ?? []) listener(data)
void ensureListener()
return () => {
listeners.delete(listener)
if (listeners.size === 0) consoleOutputListeners.delete(serverId)
}
}
export async function hydrateLog(serverId: string) {
try {
const buffer = await servers.getLogBuffer(serverId)
// The backend log buffer is the authoritative, lossless source: the
// per-line `server` events can be dropped in bursts (e.g. the server's
// startup or a `help` dump), but every line is still persisted there.
// Reconcile by appending only the lines we haven't displayed yet rather
// than blindly replacing, so live events and this catch-up stay in sync.
const current = (logLines[serverId] ??= [])
if (buffer.length > current.length) {
for (const line of buffer.slice(current.length)) current.push(line)
}
} catch {
// Server may not have logs yet
}
}
export async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
try {
await ensureListener()
serverList.value = await servers.list()
} finally {
isRefreshing.value = false
}
}
export function useServers() {
const { handleError } = injectNotificationManager()
const serverViews = computed<ServerView[]>(() =>
serverList.value.map((server) => ({ ...server, status: serverStatus(server) })),
)
async function run(action: () => Promise<unknown>): Promise<boolean> {
try {
await action()
return true
} catch (error) {
handleError(error)
return false
}
}
async function refreshList() {
await run(refresh)
}
async function startServer(serverId: string) {
await ensureListener()
logLines[serverId] = []
const ok = await run(() => servers.start(serverId))
if (ok) await refresh()
return ok
}
async function stopServer(serverId: string) {
return run(() => servers.stop(serverId))
}
async function killServer(serverId: string) {
return run(() => servers.kill(serverId))
}
async function deleteServer(serverId: string) {
const ok = await run(() => servers.delete(serverId))
if (ok) {
logLines[serverId] = []
await refresh()
}
return ok
}
async function sendCommand(serverId: string, command: string) {
return run(() => servers.sendCommand(serverId, command))
}
return {
servers: serverViews,
rawServers: serverList,
logLines,
isRefreshing,
refresh: refreshList,
startServer,
stopServer,
killServer,
deleteServer,
sendCommand,
}
}
@@ -0,0 +1,79 @@
import type { MaybeRef } from '@vueuse/core'
import { computed, inject, provide, ref, unref, watch } from 'vue'
const STORAGE_KEY = 'axolotl:symlink-warning-dismissed'
const PROVIDE_KEY = Symbol('symlinkWarningDismiss')
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
return new Set(JSON.parse(raw))
}
} catch {
// ignore
}
return new Set()
}
function saveDismissed(set: Set<string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
} catch {
// ignore
}
}
const dismissedInstances = ref<Set<string>>(loadDismissed())
export interface SymlinkWarningDismissState {
isHidden: import('vue').ComputedRef<boolean>
dismissTemp: () => void
dismissPermanently: () => void
}
export function useSymlinkWarningDismiss(instanceId: MaybeRef<string | null | undefined>) {
const locallyHidden = ref(false)
const isDismissed = computed(() => {
const id = unref(instanceId)
if (!id) return false
return dismissedInstances.value.has(id)
})
const isHidden = computed(() => locallyHidden.value || isDismissed.value)
watch(
() => unref(instanceId),
() => {
locallyHidden.value = false
},
)
function dismissTemp() {
locallyHidden.value = true
}
function dismissPermanently() {
const id = unref(instanceId)
if (!id) return
const next = new Set(dismissedInstances.value)
next.add(id)
dismissedInstances.value = next
saveDismissed(next)
}
const state: SymlinkWarningDismissState = {
isHidden,
dismissTemp,
dismissPermanently,
}
provide(PROVIDE_KEY, state)
return state
}
export function injectSymlinkWarningDismiss(): SymlinkWarningDismissState | null {
return inject<SymlinkWarningDismissState>(PROVIDE_KEY, null)
}
@@ -0,0 +1,118 @@
import { injectNotificationManager } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { terracotta, type TerracottaState } from '@/helpers/terracotta'
import { exportErrorLogs } from '@/helpers/utils'
const DEFAULT_POLL_INTERVAL = 1000
const DOWNLOAD_POLL_INTERVAL = 500
export function useTerracottaSession() {
const { handleError } = injectNotificationManager()
const state = ref<TerracottaState | null>(null)
const playerName = ref('')
const roomCodeInput = ref('')
const platformKey = ref('unknown')
const isActionPending = ref(false)
const isExportingReport = ref(false)
let mounted = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let pollInterval = DEFAULT_POLL_INTERVAL
let pollPromise: Promise<void> | undefined
function schedulePoll(delay = pollInterval) {
if (!mounted) return
clearTimeout(pollTimer)
pollTimer = setTimeout(() => void pollState(), delay)
}
async function pollState() {
if (!mounted) return
if (pollPromise) return pollPromise
pollPromise = terracotta
.getState()
.then((nextState) => {
if (mounted) state.value = nextState
})
.catch((error: unknown) => {
if (mounted) console.error(error)
})
.finally(() => {
pollPromise = undefined
schedulePoll()
})
return pollPromise
}
async function runAction(action: () => Promise<void>, interval = DEFAULT_POLL_INTERVAL) {
if (isActionPending.value) return
isActionPending.value = true
pollInterval = interval
try {
await action()
} catch (error: unknown) {
handleError(error)
} finally {
isActionPending.value = false
pollInterval = DEFAULT_POLL_INTERVAL
await pollState()
}
}
const start = () => runAction(terracotta.start)
const host = () => runAction(() => terracotta.host(playerName.value))
const join = () => runAction(() => terracotta.join(playerName.value, roomCodeInput.value))
const reset = () => runAction(terracotta.reset)
const download = () => runAction(terracotta.download, DOWNLOAD_POLL_INTERVAL)
async function exportReport() {
if (isExportingReport.value) return
isExportingReport.value = true
try {
const report = await terracotta.getDiagnosticReport()
await exportErrorLogs(report, 'Axolotl multiplayer error report')
} catch (error: unknown) {
handleError(error)
} finally {
isExportingReport.value = false
}
}
onMounted(() => {
mounted = true
void pollState()
void terracotta
.getPlatformKey()
.then((value) => {
if (mounted) platformKey.value = value
})
.catch(() => undefined)
void terracotta
.getPlayerName()
.then((value) => {
if (mounted && value) playerName.value = value
})
.catch(() => undefined)
})
onUnmounted(() => {
mounted = false
clearTimeout(pollTimer)
})
return {
download,
exportReport,
host,
isActionPending,
isExportingReport,
join,
platformKey,
playerName,
reset,
roomCodeInput,
start,
state,
}
}
@@ -0,0 +1,89 @@
import { ref } from 'vue'
/**
* Manages translation toggle state (active/loading) with version-based
* cancellation to handle async race conditions.
*
* Usage:
* ```
* const ctrl = useTranslationToggle()
*
* async function doTranslate() {
* const version = ctrl.start()
* try {
* const result = await api()
* if (ctrl.isStale(version)) return // superseded, discard
* // apply result
* ctrl.translationActive.value = true
* } finally {
* ctrl.done(version)
* }
* }
*
* function toggleTranslation() {
* ctrl.toggle(
* () => { restoreOriginal() },
* () => void doTranslate(),
* )
* }
*
* // When new data arrives (e.g. new search results):
* ctrl.cancel()
* ```
*/
export function useTranslationToggle() {
const translationActive = ref(false)
const translationLoading = ref(false)
let _version = 0
/** Bump version, set loading, and return the new version number for staleness checks. */
function start(): number {
translationLoading.value = true
return ++_version
}
/** Returns true if `version` is no longer the current request. */
function isStale(version: number): boolean {
return version !== _version
}
/** Stop loading only if `version` is still current. */
function done(version: number): void {
if (version === _version) translationLoading.value = false
}
/**
* Toggle between original and translated content.
*
* @param showOriginal - callback that restores the original untranslated data
* @param doTranslate - callback that kicks off a new translation
*/
function toggle(showOriginal: () => void, doTranslate: () => void): void {
if (translationActive.value) {
_version++ // cancel any in-flight translation
showOriginal()
translationActive.value = false
translationLoading.value = false
return
}
doTranslate()
}
/** Cancel any in-flight translation and reset to inactive state. Returns the current version. */
function cancel(): number {
_version++
translationActive.value = false
translationLoading.value = false
return _version
}
return {
translationActive,
translationLoading,
start,
isStale,
done,
toggle,
cancel,
}
}
@@ -0,0 +1,158 @@
import { type ContentItem, useDebugLogger } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
deleteDatapack,
listDatapacks,
setDatapackEnabled,
type WorldWithDatapacks,
} from '@/helpers/datapacks'
import { getPackFormatRange } from '@/helpers/pack-formats'
function datapackDescription(description: unknown): string | undefined {
if (typeof description === 'string') return description
if (Array.isArray(description)) {
return description.map(datapackDescription).filter(Boolean).join(' ')
}
if (description && typeof description === 'object') {
const record = description as Record<string, unknown>
if (typeof record.text === 'string') {
const extra = Array.isArray(record.extra)
? record.extra.map(datapackDescription).filter(Boolean).join('')
: ''
return record.text + extra
}
if (typeof record.translate === 'string') return record.translate
}
return undefined
}
export function useWorldDatapacks(getInstanceId: () => string) {
const debugState = useDebugLogger('Mods:world-datapacks')
const worldDatapacks = ref<WorldWithDatapacks[]>([])
let worldDatapackRequest = 0
async function loadWorldDatapacks() {
const request = ++worldDatapackRequest
try {
const data = await listDatapacks(getInstanceId())
if (request !== worldDatapackRequest) return
worldDatapacks.value = data ?? []
debugState('world datapacks loaded', {
worlds: worldDatapacks.value.length,
datapacks: worldDatapacks.value.reduce((total, world) => total + world.datapacks.length, 0),
})
} catch (error) {
if (request !== worldDatapackRequest) return
worldDatapacks.value = []
console.warn('Could not load world datapacks:', error)
}
}
/**
* World datapacks (files inside `saves/<world>/datapacks/`) are surfaced as
* plain content items so the content tab treats them exactly like mods,
* resource packs, shaders and schematics: the type filter pill and its count
* come from real items, rows render in the content table grouped per save,
* and delete goes through the world-datapack command.
*/
const worldDatapackItems = computed<ContentItem[]>(() =>
worldDatapacks.value.flatMap((entry) =>
entry.datapacks.map((datapack) => {
const id = `local:world-datapack:${entry.path}:${datapack.file_name}`
const versionRange = getPackFormatRange(datapack.pack_format)
const versionNumber =
versionRange?.min ?? (datapack.pack_format != null ? String(datapack.pack_format) : '')
return {
id,
file_name: datapack.file_name,
file_path: `saves/${entry.path}/datapacks/${datapack.file_name}`,
project_type: 'datapack',
update: null,
origin_provider: null,
enabled: datapack.enabled !== false,
external: true,
source_kind: 'world_datapack',
groupMeta: {
icon_url: entry.icon,
title: entry.name,
last_played: entry.last_played,
game_mode: entry.game_mode,
hardcore: entry.hardcore,
},
instanceOwnershipKind: 'local_discovered',
instanceMaterializationState: 'present',
instanceCapabilities: {
canToggle: true,
canDelete: true,
canUpdate: false,
canChangeVersion: false,
canRestorePackDefault: false,
},
project: {
id,
slug: datapack.display_name,
title: datapack.display_name,
icon_url: datapack.icon,
description: datapackDescription(datapack.description),
},
version: {
id: datapack.file_name,
version_number: versionNumber,
file_name: datapack.file_name,
},
provider_refs: [],
} satisfies ContentItem
}),
),
)
function isWorldDatapackItem(item: ContentItem) {
return item.project_type === 'datapack' && item.source_kind === 'world_datapack'
}
async function deleteWorldDatapackItem(item: ContentItem) {
const segments = (item.file_path ?? '').split('/')
const worldPath = segments[1]
const fileName = segments[3]
if (!worldPath || !fileName) {
throw new Error('Invalid world datapack path')
}
await deleteDatapack(getInstanceId(), worldPath, fileName)
await loadWorldDatapacks()
}
async function toggleWorldDatapackItem(item: ContentItem, enabled: boolean) {
const segments = (item.file_path ?? '').split('/')
const worldPath = segments[1]
const fileName = segments[3]
if (!worldPath || !fileName) {
throw new Error('Invalid world datapack path')
}
const world = worldDatapacks.value.find((entry) => entry.path === worldPath)
const datapack = world?.datapacks.find((entry) => entry.file_name === fileName)
const previousEnabled = datapack?.enabled
if (datapack) {
datapack.enabled = enabled
}
try {
await setDatapackEnabled(getInstanceId(), worldPath, fileName, enabled)
} catch (error) {
if (datapack) {
datapack.enabled = previousEnabled
}
throw error
}
}
return {
worldDatapacks,
loadWorldDatapacks,
worldDatapackItems,
isWorldDatapackItem,
deleteWorldDatapackItem,
toggleWorldDatapackItem,
}
}