feat:移除了弹窗,服务器添加sls
This commit is contained in:
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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user