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

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,102 @@
import {
AbstractWebNotificationManager,
type NotificationPanelLocation,
type WebNotification,
} from '@modrinth/ui'
import { type Ref, ref } from 'vue'
export class AppNotificationManager extends AbstractWebNotificationManager {
private static readonly STORAGE_KEY = 'axolotl:dismissed-web-notifications'
private readonly state: Ref<WebNotification[]>
private readonly locationState: Ref<NotificationPanelLocation>
private readonly dismissed = this.loadDismissed()
public constructor() {
super()
this.state = ref<WebNotification[]>([])
this.locationState = ref<NotificationPanelLocation>('right')
}
public getNotificationLocation(): NotificationPanelLocation {
return this.locationState.value
}
public setNotificationLocation(location: NotificationPanelLocation): void {
this.locationState.value = location
}
public getNotifications(): WebNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: WebNotification): void {
if (this.isDismissed(notification)) return
this.state.value.unshift(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected removeNotificationFromStorageByIndex(index: number): void {
this.state.value.splice(index, 1)
}
protected clearAllNotificationsFromStorage(): void {
const keys = this.state.value.map((notification) => this.key(notification))
this.state.value.splice(0)
this.dismissed.clearedAt = Date.now()
this.dismissed.keys = [...new Set([...this.dismissed.keys, ...keys])]
this.saveDismissed()
}
private loadDismissed(): { clearedAt: number; keys: string[] } {
try {
const value = JSON.parse(localStorage.getItem(AppNotificationManager.STORAGE_KEY) ?? '{}')
return {
clearedAt: typeof value.clearedAt === 'number' ? value.clearedAt : 0,
keys: Array.isArray(value.keys)
? value.keys.filter((key: unknown) => typeof key === 'string')
: [],
}
} catch {
return { clearedAt: 0, keys: [] }
}
}
private saveDismissed(): void {
try {
localStorage.setItem(AppNotificationManager.STORAGE_KEY, JSON.stringify(this.dismissed))
} catch {
// Notification history is still usable when storage is unavailable.
}
}
private key(notification: WebNotification): string {
return JSON.stringify([
notification.title ?? '',
notification.text ?? '',
notification.type ?? '',
notification.errorCode ?? '',
])
}
private isDismissed(notification: WebNotification): boolean {
return (
(notification.createdAt ?? Date.now()) <= this.dismissed.clearedAt ||
this.dismissed.keys.includes(this.key(notification))
)
}
public override removeNotification(id: string | number): WebNotification | undefined {
const notification = super.removeNotification(id)
if (notification) {
this.dismissed.keys = [...new Set([...this.dismissed.keys, this.key(notification)])]
this.saveDismissed()
}
return notification
}
}

View File

@ -0,0 +1,81 @@
import { AbstractPopupNotificationManager, type PopupNotification } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
export class AppPopupNotificationManager extends AbstractPopupNotificationManager {
private static readonly STORAGE_KEY = 'axolotl:dismissed-popup-notifications'
private readonly state: Ref<PopupNotification[]>
private readonly dismissed = this.loadDismissed()
public constructor() {
super()
this.state = ref<PopupNotification[]>([])
}
public getNotifications(): PopupNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: PopupNotification): void {
if (this.isDismissed(notification)) return
this.state.value.unshift(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected clearAllNotificationsFromStorage(): void {
const keys = this.state.value.map((notification) => this.key(notification))
this.state.value.splice(0)
this.dismissed.clearedAt = Date.now()
this.dismissed.keys = [...new Set([...this.dismissed.keys, ...keys])]
this.saveDismissed()
}
private loadDismissed(): { clearedAt: number; keys: string[] } {
try {
const value = JSON.parse(
localStorage.getItem(AppPopupNotificationManager.STORAGE_KEY) ?? '{}',
)
return {
clearedAt: typeof value.clearedAt === 'number' ? value.clearedAt : 0,
keys: Array.isArray(value.keys)
? value.keys.filter((key: unknown) => typeof key === 'string')
: [],
}
} catch {
return { clearedAt: 0, keys: [] }
}
}
private saveDismissed(): void {
try {
localStorage.setItem(AppPopupNotificationManager.STORAGE_KEY, JSON.stringify(this.dismissed))
} catch {
// Notification history is still usable when storage is unavailable.
}
}
private key(notification: PopupNotification): string {
return JSON.stringify([notification.title, notification.text ?? '', notification.type ?? ''])
}
private isDismissed(notification: PopupNotification): boolean {
return (
(notification.createdAt ?? Date.now()) <= this.dismissed.clearedAt ||
this.dismissed.keys.includes(this.key(notification))
)
}
public override removeNotification(id: string | number): void {
const notification = this.state.value.find((item) => item.id === id)
super.removeNotification(id)
if (notification) {
this.dismissed.keys = [...new Set([...this.dismissed.keys, this.key(notification)])]
this.saveDismissed()
}
}
}

View File

@ -0,0 +1,186 @@
import { computed, ref } from 'vue'
export const APP_UPDATE_POPUP_DELAY_MS = 24 * 60 * 60 * 1000
const UPDATE_PROMPT_STORAGE_KEY = 'modrinth-app-update-prompt-state'
export interface AppUpdate {
rid: number
version: string
currentVersion?: string
publishedAt?: string
forceUpdate?: boolean
}
interface UpdatePromptState {
version: string
stage: AppUpdatePromptStage
actionableSince: number
lastUserActionAt?: number
popupShownAt?: number
}
export type AppUpdatePromptStage = 'available' | 'downloaded'
export type AppUpdateCheckResult = 'available' | 'up-to-date' | 'disabled' | 'offline' | 'paused'
interface AppUpdateActions {
check?: () => Promise<AppUpdateCheckResult>
download?: () => Promise<void> | void
install?: () => Promise<void> | void
changelog?: (version?: string) => Promise<void> | void
}
const progress = ref(0)
const metered = ref(true)
const finishedDownloading = ref(false)
const downloading = ref(false)
const restarting = ref(false)
const availableUpdate = ref<AppUpdate | null>(null)
const updateSize = ref<number | null>(null)
const updatesEnabled = ref(true)
const updatesPaused = ref(false)
let actions: AppUpdateActions = {}
function getCurrentAppUpdatePromptStage(): AppUpdatePromptStage {
return finishedDownloading.value ? 'downloaded' : 'available'
}
export const appUpdateState = {
progress,
metered,
finishedDownloading,
downloading,
restarting,
availableUpdate,
updateSize,
updatesEnabled,
updatesPaused,
downloadProgress: computed(() => progress.value),
downloadPercent: computed(() => Math.trunc(progress.value * 100)),
isVisible: computed(
() =>
!!availableUpdate.value && !restarting.value && updatesEnabled.value && !updatesPaused.value,
),
}
function readPromptState(): UpdatePromptState | null {
try {
const raw = localStorage.getItem(UPDATE_PROMPT_STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw) as Partial<UpdatePromptState>
if (!parsed.version || typeof parsed.actionableSince !== 'number') {
return null
}
return {
...parsed,
stage: parsed.stage ?? 'available',
} as UpdatePromptState
} catch {
return null
}
}
function writePromptState(state: UpdatePromptState): void {
try {
localStorage.setItem(UPDATE_PROMPT_STORAGE_KEY, JSON.stringify(state))
} catch (error) {
console.warn('Failed to persist update prompt state:', error)
}
}
export function markAppUpdateActionable(
version: string,
stage: AppUpdatePromptStage = 'available',
now = Date.now(),
): void {
const existing = readPromptState()
if (existing?.version === version && existing.stage === stage) {
return
}
writePromptState({
version,
stage,
actionableSince: now,
})
}
export function recordAppUpdateUserAction(
version = availableUpdate.value?.version,
stage: AppUpdatePromptStage = getCurrentAppUpdatePromptStage(),
): void {
if (!version) {
return
}
const now = Date.now()
const existing = readPromptState()
const isSamePrompt = existing?.version === version && existing.stage === stage
writePromptState({
version,
stage,
actionableSince: isSamePrompt ? existing.actionableSince : now,
lastUserActionAt: now,
popupShownAt: isSamePrompt ? existing.popupShownAt : undefined,
})
}
export function markAppUpdatePopupShown(
version: string,
stage: AppUpdatePromptStage = 'available',
now = Date.now(),
): void {
const existing = readPromptState()
const isSamePrompt = existing?.version === version && existing.stage === stage
writePromptState({
version,
stage,
actionableSince: isSamePrompt ? existing.actionableSince : now,
lastUserActionAt: isSamePrompt ? existing.lastUserActionAt : undefined,
popupShownAt: now,
})
}
export function getNextAppUpdatePopupTime(
version: string,
stage: AppUpdatePromptStage = 'available',
): number | null {
const existing = readPromptState()
if (existing?.version !== version || existing.stage !== stage || existing.popupShownAt) {
return null
}
return (
Math.max(existing.actionableSince, existing.lastUserActionAt ?? 0) + APP_UPDATE_POPUP_DELAY_MS
)
}
export function setAppUpdateActions(nextActions: AppUpdateActions): void {
actions = nextActions
}
export async function checkForAppUpdate(): Promise<AppUpdateCheckResult> {
return (await actions.check?.()) ?? 'disabled'
}
export async function downloadAvailableAppUpdate(): Promise<void> {
recordAppUpdateUserAction(undefined, 'available')
await actions.download?.()
}
export async function installAvailableAppUpdate(): Promise<void> {
recordAppUpdateUserAction(undefined, 'downloaded')
await actions.install?.()
}
export async function openAppUpdateChangelog(
version = availableUpdate.value?.version,
): Promise<void> {
recordAppUpdateUserAction()
await actions.changelog?.(version)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
aggregateContentSelectionDependencies,
type AggregatedDependency,
getActiveDependencyConflictIdentities,
} from './content-selection-logic.ts'
function dependency(id: string, requiredBy: string, ownerKey: string) {
return {
id,
title: `Dependency ${id}`,
requiredBy: [requiredBy],
requiredByKeys: [ownerKey],
alreadyInstalled: false,
}
}
test('deduplicates a shared dependency and merges its owners', () => {
const result = aggregateContentSelectionDependencies(
[
{
ownerKey: 'modrinth:first',
dependencies: [dependency('modrinth:shared:v1', 'First', 'modrinth:first')],
},
{
ownerKey: 'curseforge:second',
dependencies: [dependency('modrinth:shared:v1', 'Second', 'curseforge:second')],
},
],
(item) => `Conflict: ${item.title}`,
)
assert.equal(result.dependencies.length, 1)
assert.deepEqual(result.dependencies[0].requiredBy, ['First', 'Second'])
assert.deepEqual(result.dependencies[0].requiredByKeys, ['modrinth:first', 'curseforge:second'])
assert.equal(result.conflicts.size, 0)
})
test('marks every affected primary when one dependency resolves to different versions', () => {
const result = aggregateContentSelectionDependencies(
[
{
ownerKey: 'modrinth:first',
dependencies: [dependency('modrinth:shared:v1', 'First', 'modrinth:first')],
},
{
ownerKey: 'modrinth:second',
dependencies: [dependency('modrinth:shared:v2', 'Second', 'modrinth:second')],
},
],
(item) => `Conflict: ${item.title}`,
)
assert.equal(result.dependencies.length, 2)
assert.match(result.conflicts.get('modrinth:first') ?? '', /^Conflict:/)
assert.match(result.conflicts.get('modrinth:second') ?? '', /^Conflict:/)
assert.deepEqual(result.conflictIdentities.get('modrinth:first'), ['modrinth:shared'])
assert.deepEqual(result.conflictIdentities.get('modrinth:second'), ['modrinth:shared'])
})
test('keeps provider-qualified dependencies separate', () => {
const result = aggregateContentSelectionDependencies(
[
{
ownerKey: 'primary',
dependencies: [
dependency('modrinth:42:v1', 'Primary', 'primary'),
dependency('curseforge:42:v1', 'Primary', 'primary'),
],
},
],
(item) => `Conflict: ${item.title}`,
)
assert.equal(result.dependencies.length, 2)
assert.equal(result.conflicts.size, 0)
})
test('merges required owners for a shared dependency', () => {
const optional = dependency('modrinth:shared:v1', 'First', 'modrinth:first')
const required = {
...dependency('modrinth:shared:v1', 'Second', 'curseforge:second'),
required: true,
}
const result = aggregateContentSelectionDependencies<AggregatedDependency>(
[
{ ownerKey: 'modrinth:first', dependencies: [optional] },
{ ownerKey: 'curseforge:second', dependencies: [required] },
],
(item) => `Conflict: ${item.title}`,
)
assert.equal(result.dependencies[0].required, true)
assert.deepEqual(result.dependencies[0].requiredForKeys, ['curseforge:second'])
})
test('removing one conflicting owner clears the active conflict', () => {
const dependencies = [
dependency('modrinth:shared:v1', 'First', 'modrinth:first'),
dependency('modrinth:shared:v2', 'Second', 'modrinth:second'),
]
assert.deepEqual(
[
...getActiveDependencyConflictIdentities(
dependencies,
new Set(['modrinth:first', 'modrinth:second']),
),
],
['modrinth:shared'],
)
assert.equal(
getActiveDependencyConflictIdentities(dependencies, new Set(['modrinth:second'])).size,
0,
)
})

View File

@ -0,0 +1,107 @@
export interface AggregatedDependency {
id: string
title: string
requiredBy: string[]
requiredByKeys?: string[]
required?: boolean
requiredForKeys?: string[]
}
export interface DependencyAggregationInput<T extends AggregatedDependency> {
ownerKey: string
dependencies: T[]
}
function dependencyIdentity(id: string) {
const separator = id.lastIndexOf(':')
return {
identity: separator === -1 ? id : id.slice(0, separator),
versionId: separator === -1 ? '' : id.slice(separator + 1),
}
}
export function getActiveDependencyConflictIdentities(
dependencies: Pick<AggregatedDependency, 'id' | 'requiredByKeys'>[],
visibleOwnerKeys: Set<string>,
) {
const versions = new Map<string, Set<string>>()
for (const dependency of dependencies) {
if (
dependency.requiredByKeys?.length &&
!dependency.requiredByKeys.some((key) => visibleOwnerKeys.has(key))
) {
continue
}
const { identity, versionId } = dependencyIdentity(dependency.id)
const identityVersions = versions.get(identity) ?? new Set<string>()
identityVersions.add(versionId)
versions.set(identity, identityVersions)
}
return new Set(
[...versions.entries()]
.filter(([, versionIds]) => versionIds.size > 1)
.map(([identity]) => identity),
)
}
export function aggregateContentSelectionDependencies<T extends AggregatedDependency>(
selections: DependencyAggregationInput<T>[],
conflictMessage: (dependency: T) => string,
) {
const dependencies = new Map<string, T>()
const versionOwners = new Map<string, Map<string, Set<string>>>()
const dependencyByIdentity = new Map<string, T>()
const conflicts = new Map<string, string>()
const conflictIdentities = new Map<string, string[]>()
for (const selection of selections) {
for (const dependency of selection.dependencies) {
const { identity, versionId } = dependencyIdentity(dependency.id)
const ownersByVersion = versionOwners.get(identity) ?? new Map<string, Set<string>>()
const owners = ownersByVersion.get(versionId) ?? new Set<string>()
owners.add(selection.ownerKey)
ownersByVersion.set(versionId, owners)
versionOwners.set(identity, ownersByVersion)
dependencyByIdentity.set(identity, dependency)
const existing = dependencies.get(dependency.id)
if (existing) {
existing.requiredBy = [...new Set([...existing.requiredBy, ...dependency.requiredBy])]
existing.requiredByKeys = [
...new Set([...(existing.requiredByKeys ?? []), ...(dependency.requiredByKeys ?? [])]),
]
existing.required = existing.required || dependency.required
existing.requiredForKeys = [
...new Set([
...(existing.requiredForKeys ?? []),
...(dependency.required ? [selection.ownerKey] : []),
...(dependency.requiredForKeys ?? []),
]),
]
} else {
dependencies.set(dependency.id, {
...dependency,
requiredForKeys: dependency.required
? [...new Set([selection.ownerKey, ...(dependency.requiredForKeys ?? [])])]
: dependency.requiredForKeys,
})
}
}
}
for (const [identity, ownersByVersion] of versionOwners) {
if (ownersByVersion.size < 2) continue
const dependency = dependencyByIdentity.get(identity)
if (!dependency) continue
for (const owners of ownersByVersion.values()) {
for (const ownerKey of owners) {
conflicts.set(ownerKey, conflictMessage(dependency))
conflictIdentities.set(ownerKey, [
...new Set([...(conflictIdentities.get(ownerKey) ?? []), identity]),
])
}
}
}
return { dependencies: [...dependencies.values()], conflicts, conflictIdentities }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,438 @@
import { createContext } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref } from 'vue'
import { setCurseForgeManualDownloads } from '@/helpers/curseforge-manual'
import { download_request_listener, install_job_listener, loading_listener } from '@/helpers/events'
import {
download_history_clear,
download_job_cancel,
download_job_delete,
download_job_get,
download_job_list,
download_job_resume,
download_job_retry,
type DownloadRequestUpdate,
install_job_skip_missing_content,
installJobInstanceId,
type InstallJobSnapshot,
} from '@/helpers/install'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
const activeStatuses = new Set(['queued', 'running', 'canceling', 'waiting_for_user'])
export const downloadBarTypes = new Set([
'java_download',
'pack_file_download',
'pack_download',
'minecraft_download',
'instance_update',
'launcher_update',
])
export interface DownloadManager {
jobs: Ref<InstallJobSnapshot[]>
legacyDownloads: Ref<LoadingBar[]>
activeJobs: ComputedRef<InstallJobSnapshot[]>
historyJobs: ComputedRef<InstallJobSnapshot[]>
activeCount: ComputedRef<number>
queuedCount: ComputedRef<number>
start: () => Promise<void>
refresh: () => Promise<void>
cancel: (jobId: string) => Promise<void>
retry: (jobId: string) => Promise<void>
resume: (jobId: string) => Promise<void>
skipMissingContent: (jobId: string) => Promise<void>
remove: (jobId: string) => Promise<void>
clearHistory: () => Promise<void>
/**
* Insert a synthetic job created on the frontend (e.g. a server download
* that does not go through the backend install-pipeline). The job is kept
* in-memory and will disappear on refresh — callers must update it via
* `setSyntheticJob` to keep it alive.
*/
addSyntheticJob: (job: InstallJobSnapshot) => void
/**
* Replace a synthetic job (identified by `job_id`) with a fresh snapshot.
* This is a no-op for backend-tracked jobs.
*/
setSyntheticJob: (job: InstallJobSnapshot) => void
/**
* Register a cancel handler for a synthetic job. When `cancel()` is
* called with this jobId the handler is invoked *before* the job is
* removed from the list, allowing the caller to abort the underlying
* operation (e.g. stop a server install).
*/
onSyntheticCancel: (jobId: string, handler: () => void | Promise<void>) => void
/**
* Unregister a previously registered cancel handler.
*/
offSyntheticCancel: (jobId: string) => void
dispose: () => void
}
export function createDownloadManager(handleError: (error: unknown) => void): DownloadManager {
const jobs = ref<InstallJobSnapshot[]>([])
const legacyDownloads = ref<LoadingBar[]>([])
let started = false
let disposed = false
let unlistenJobs: (() => void) | null = null
let unlistenRequests: (() => void) | null = null
let unlistenLoading: (() => void) | null = null
let initializing = false
const pendingInitialUpdates: Array<
{ kind: 'job'; job: InstallJobSnapshot } | { kind: 'request'; update: DownloadRequestUpdate }
> = []
const pendingRequestUpdatesByJob = new Map<string, DownloadRequestUpdate[]>()
const pendingRequestUpdates: DownloadRequestUpdate[] = []
let requestFlushTimer: ReturnType<typeof setTimeout> | null = null
let legacyRefreshTimer: ReturnType<typeof setTimeout> | null = null
function persistManualDownloadsFromJob(job: InstallJobSnapshot) {
if (job.status !== 'waiting_for_user' && job.status !== 'succeeded') return
const instanceId = installJobInstanceId(job)
const hasManualDownloadHistory = job.items.some((item) => item.manual_url)
if (!instanceId || !hasManualDownloadHistory) return
const manualItems = job.items
.filter(
(item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
)
.map((item) => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}))
setCurseForgeManualDownloads(instanceId, manualItems)
}
function setJob(job: InstallJobSnapshot) {
if (initializing) {
pendingInitialUpdates.push({ kind: 'job', job })
return
}
const current = jobs.value.find((candidate) => candidate.job_id === job.job_id)
if (current && current.modified.localeCompare(job.modified) > 0) return
const currentIndex = jobs.value.findIndex((candidate) => candidate.job_id === job.job_id)
if (currentIndex !== -1) {
// Progress snapshots are frequent. Keep an existing job in its current
// position instead of rebuilding and sorting the whole list on every
// update. Jobs created within the same second have identical timestamps;
// sorting those snapshots repeatedly makes cards jump and can cause an
// expanded details view to be patched onto a neighbouring card.
const nextJobs = [...jobs.value]
nextJobs[currentIndex] = job
jobs.value = nextJobs
} else {
jobs.value = [job, ...jobs.value].sort((a, b) => b.created.localeCompare(a.created))
}
const pending = pendingRequestUpdatesByJob.get(job.job_id)
if (pending) {
pendingRequestUpdatesByJob.delete(job.job_id)
for (const update of pending) updateRequest(update)
}
persistManualDownloadsFromJob(job)
}
function updateRequest(update: DownloadRequestUpdate) {
if (initializing) {
pendingInitialUpdates.push({ kind: 'request', update })
return
}
pendingRequestUpdates.push(update)
scheduleRequestFlush()
}
function scheduleRequestFlush() {
if (requestFlushTimer !== null) return
requestFlushTimer = setTimeout(() => {
requestFlushTimer = null
flushRequestUpdates()
}, 120)
}
function flushRequestUpdates() {
if (pendingRequestUpdates.length === 0) return
const updates = pendingRequestUpdates.splice(0)
let next = jobs.value
for (const update of updates) {
next = applyRequestUpdate(update, next)
}
jobs.value = next
}
function applyRequestUpdate(
update: DownloadRequestUpdate,
jobs: InstallJobSnapshot[],
): InstallJobSnapshot[] {
const jobIndex = jobs.findIndex((job) => job.job_id === update.job_id)
if (jobIndex === -1) {
const pending = pendingRequestUpdatesByJob.get(update.job_id) ?? []
pending.push(update)
pendingRequestUpdatesByJob.set(update.job_id, pending)
return jobs
}
const job = jobs[jobIndex]
const itemIndex = job.items.findIndex((item) => item.id === update.id)
const current = itemIndex === -1 ? null : job.items[itemIndex]
let item: InstallJobSnapshot['items'][number]
switch (update.type) {
case 'started':
item = {
...(current ?? {
id: update.id,
name: update.name,
bytes_downloaded: 0,
}),
status: 'downloading',
bytes_total: current?.bytes_total ?? update.bytes_total,
attempt: update.attempt,
max_attempts: update.max_attempts,
error: null,
request_url: update.url,
source: update.source,
}
break
case 'progress':
if (!current) return jobs
item = {
...current,
status: update.status,
bytes_downloaded: update.bytes,
}
break
case 'finished':
if (!current) return jobs
item = {
...current,
// A request has reached disk, but an install item may still be
// hashing, registering metadata, or waiting for SQLite.
status: 'verifying',
bytes_downloaded: update.bytes,
bytes_total: current.bytes_total ?? update.bytes,
}
break
case 'failed':
if (!current) return jobs
item = { ...current, status: 'failed' }
break
}
const items = [...job.items]
if (itemIndex === -1) items.push(item)
else items[itemIndex] = item
const nextJobs = [...jobs]
nextJobs[jobIndex] = {
...job,
items,
summary:
update.type === 'progress'
? {
...job.summary,
speed_bytes_per_second: update.speed_bytes_per_second,
eta_seconds: update.eta_seconds,
}
: job.summary,
}
return nextJobs
}
async function refresh() {
const page = await download_job_list({ limit: 250 }).catch((error) => {
handleError(error)
return null
})
if (page && !disposed) {
const activeSynthetics = jobs.value.filter(
(job) => syntheticIds.has(job.job_id) && activeStatuses.has(job.status),
)
jobs.value = [...page.jobs, ...activeSynthetics].sort((a, b) =>
b.created.localeCompare(a.created),
)
const seenInstances = new Set<string>()
for (const job of page.jobs) {
if (job.status !== 'succeeded') continue
const instanceId = installJobInstanceId(job)
if (!instanceId || seenInstances.has(instanceId)) continue
seenInstances.add(instanceId)
persistManualDownloadsFromJob(job)
}
}
}
async function refreshLegacyDownloads() {
const bars = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
legacyDownloads.value = Object.values(bars)
.filter((bar) => downloadBarTypes.has(bar.bar_type?.type ?? ''))
.map((bar) => ({
...bar,
title: bar.title ?? bar.bar_type?.pack_name ?? bar.bar_type?.instance_name ?? bar.message,
}))
}
function scheduleLegacyRefresh() {
if (legacyRefreshTimer !== null) return
legacyRefreshTimer = setTimeout(() => {
legacyRefreshTimer = null
void refreshLegacyDownloads()
}, 300)
}
async function start() {
if (started || disposed) return
started = true
initializing = true
unlistenRequests = await download_request_listener((update: DownloadRequestUpdate) =>
updateRequest(update),
)
unlistenJobs = await install_job_listener((job: InstallJobSnapshot) => setJob(job))
unlistenLoading = await loading_listener(() => scheduleLegacyRefresh())
await Promise.all([refresh(), refreshLegacyDownloads()])
initializing = false
for (const update of pendingInitialUpdates.splice(0)) {
if (update.kind === 'job') setJob(update.job)
else updateRequest(update.update)
}
}
async function cancel(jobId: string) {
if (syntheticIds.has(jobId)) {
// Remove the ID *before* removing the job from the list so that any
// in-flight progress listener callback that calls setSyntheticJob
// will see the missing ID and become a no-op, preventing the job
// from being re-inserted.
syntheticIds.delete(jobId)
const handler = syntheticCancelHandlers.get(jobId)
if (handler) {
try {
await handler()
} catch {
// Handler errors are non-fatal; still remove the job from the list.
}
}
jobs.value = jobs.value.filter((job) => job.job_id !== jobId)
return
}
const job = await download_job_cancel(jobId)
await reconcileJob(job)
}
async function retry(jobId: string) {
const job = await download_job_retry(jobId)
await reconcileJob(job)
}
async function resume(jobId: string) {
const job = await download_job_resume(jobId)
await reconcileJob(job)
}
async function skipMissingContent(jobId: string) {
const job = await install_job_skip_missing_content(jobId)
await reconcileJob(job)
}
/**
* The job may already have reached a terminal state (or been removed) by
* the time the retry/cancel command returns. Fetch the freshest snapshot so
* the UI never shows a stale queued/running spinner, and drop the row
* entirely when the job no longer exists.
*/
async function reconcileJob(job: InstallJobSnapshot) {
const freshest = await download_job_get(job.job_id).catch(() => null)
if (freshest) {
setJob(freshest)
} else {
jobs.value = jobs.value.filter((candidate) => candidate.job_id !== job.job_id)
}
}
async function remove(jobId: string) {
await download_job_delete(jobId)
jobs.value = jobs.value.filter((job) => job.job_id !== jobId)
}
async function clearHistory() {
await download_history_clear()
jobs.value = jobs.value.filter((job) => activeStatuses.has(job.status))
}
const activeJobs = computed(() => jobs.value.filter((job) => activeStatuses.has(job.status)))
const historyJobs = computed(() => jobs.value.filter((job) => !activeStatuses.has(job.status)))
const syntheticIds = new Set<string>()
const syntheticCancelHandlers = new Map<string, () => void | Promise<void>>()
function addSyntheticJob(job: InstallJobSnapshot) {
syntheticIds.add(job.job_id)
// A server can be installed again after a previous synthetic record has
// moved to history. Replace that record instead of creating duplicate
// job IDs, which would make keyed download cards share a details view.
jobs.value = [job, ...jobs.value.filter((candidate) => candidate.job_id !== job.job_id)].sort(
(a, b) => b.created.localeCompare(a.created),
)
}
function setSyntheticJob(job: InstallJobSnapshot) {
if (!syntheticIds.has(job.job_id)) return
setJob(job)
}
function onSyntheticCancel(jobId: string, handler: () => void | Promise<void>) {
syntheticCancelHandlers.set(jobId, handler)
}
function offSyntheticCancel(jobId: string) {
syntheticCancelHandlers.delete(jobId)
}
return {
jobs,
legacyDownloads,
activeJobs,
historyJobs,
activeCount: computed(() => activeJobs.value.length + legacyDownloads.value.length),
queuedCount: computed(() => jobs.value.filter((job) => job.status === 'queued').length),
start,
refresh,
cancel,
retry,
resume,
skipMissingContent,
remove,
clearHistory,
addSyntheticJob,
setSyntheticJob,
onSyntheticCancel,
offSyntheticCancel,
dispose() {
disposed = true
initializing = false
pendingInitialUpdates.length = 0
pendingRequestUpdatesByJob.clear()
syntheticCancelHandlers.clear()
if (requestFlushTimer !== null) {
clearTimeout(requestFlushTimer)
requestFlushTimer = null
}
if (legacyRefreshTimer !== null) {
clearTimeout(legacyRefreshTimer)
legacyRefreshTimer = null
}
pendingRequestUpdates.length = 0
unlistenJobs?.()
unlistenRequests?.()
unlistenLoading?.()
},
}
}
export const [injectDownloadManager, provideDownloadManager] = createContext<DownloadManager>(
'root',
'downloadManager',
)

View File

@ -0,0 +1,36 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { loading_listener } from '@/helpers/events'
export interface AppDownloadProgressContext {
progress: Ref<number>
version: Ref<string | undefined>
}
/* returns unlisten function */
export async function subscribeToDownloadProgress(
context: AppDownloadProgressContext,
version: string,
) {
return await loading_listener(
(event: {
event: {
type: 'launcher_update'
version: string
}
fraction?: number
}) => {
if (event.event.type === 'launcher_update') {
if (!version || event.event.version === version) {
context.progress.value = event.fraction ?? 1.0
context.version.value = event.event.version
console.log(`Progress: ${context.progress.value} ${context.version.value}`)
}
}
},
)
}
export const [injectAppUpdateDownloadProgress, provideAppUpdateDownloadProgress] =
createContext<AppDownloadProgressContext>('root', 'appUpdateDownloadProgress')

View File

@ -0,0 +1,15 @@
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
export interface InstanceSettingsContext {
instance: ComputedRef<GameInstance>
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
createContext<InstanceSettingsContext>('InstanceSettingsModal', 'instanceSettings')

View File

@ -0,0 +1,385 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AbstractPopupNotificationManager } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
import {
install_create_instance,
install_create_modpack_instance,
install_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import { edit, get, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import { start_join_server } from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error.js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ModalRef<TShow extends (...args: any[]) => void = () => void> {
show: TShow
hide: () => void
}
export interface ServerInstallContext {
installingServerProjects: Ref<string[]>
startInstallingServer: (projectId: string) => void
stopInstallingServer: (projectId: string) => void
isServerInstalling: (projectId: string) => boolean
installServerProject: (serverProjectId: string) => Promise<void>
playServerProject: (projectId: string) => Promise<void>
setInstallToPlayModal: (
ref: ModalRef<
(
project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null,
callback?: () => void,
) => void
>,
) => void
setUpdateToPlayModal: (
ref: ModalRef<
(instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void
>,
) => void
setAddServerToInstanceModal: (
ref: ModalRef<(serverName: string, serverAddress: string) => void>,
) => void
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
symlinkTarget: Ref<string | null | undefined>
}
let _serverInstallSingleton: ServerInstallContext | null = null
const [_rawInjectServerInstall, provideServerInstall] = createContext<ServerInstallContext>(
'root',
'serverInstall',
)
export { provideServerInstall }
export function injectServerInstall(): ServerInstallContext {
try {
return _rawInjectServerInstall()
} catch {
if (_serverInstallSingleton) return _serverInstallSingleton
throw new Error('ServerInstall context not available')
}
}
export function createServerInstall(opts: {
router: Router
handleError: (err: unknown) => void
popupNotificationManager: AbstractPopupNotificationManager
}): ServerInstallContext {
const installingServerProjects = ref<string[]>([])
const symlinkTarget = ref<string | null | undefined>(undefined)
let installToPlayModalRef: ModalRef<
(
project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null,
callback?: () => void,
) => void
> | null = null
let updateToPlayModalRef: ModalRef<
(instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void
> | null = null
let addServerToInstanceModalRef: ModalRef<
(serverName: string, serverAddress: string) => void
> | null = null
function startInstallingServer(projectId: string) {
if (!installingServerProjects.value.includes(projectId)) {
installingServerProjects.value.push(projectId)
}
}
function stopInstallingServer(projectId: string) {
installingServerProjects.value = installingServerProjects.value.filter((id) => id !== projectId)
}
function isServerInstalling(projectId: string) {
return installingServerProjects.value.includes(projectId)
}
async function joinServer(instanceId: string, serverAddress: string | null) {
if (!serverAddress) return
await start_join_server(instanceId, serverAddress)
}
async function findInstalledInstance(projectId: string) {
const packs = await list()
return packs.find((pack) => pack.link?.project_id === projectId) ?? null
}
async function createVanillaInstance(
project: Labrinth.Projects.v2.Project,
gameVersion: string,
serverAddress: string | null,
) {
const job = await install_create_instance({
name: project.title,
gameVersion,
loader: 'vanilla',
loaderVersion: null,
iconPath: project.icon_url ?? null,
link: {
type: 'server_project',
project_id: project.id,
},
})
const instanceId = installJobInstanceId(job)
if (!instanceId) return null
await wait_for_install_job(job.job_id)
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
return instanceId
}
async function updateVanillaGameVersion(instance: GameInstance, targetGameVersion: string) {
if (instance.game_version === targetGameVersion) return
await edit(instance.id, { game_version: targetGameVersion })
const job = await install_existing_instance(instance.id, false)
await wait_for_install_job(job.job_id)
}
function showModpackInstallSuccess(project: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(project.id, serverAddress)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: project.id })
}
},
color: 'brand' as const,
},
]
: []),
{
label: 'Instance',
action: () => opts.router.push(`/instance/${encodeURIComponent(project.id)}`),
},
],
autoCloseMs: null,
})
}
function showUpdateSuccess(instance: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: instance.id })
}
},
color: 'brand' as const,
},
]
: []),
{
label: 'Instance',
action: () => opts.router.push(`/instance/${encodeURIComponent(instance.id)}`),
},
],
autoCloseMs: null,
})
}
/**
* Server projects that use modpack content have link.project_id as
* the server project id and link.version_id as the modpack content version id.
* The modpack content version can be of the same server project, or from a different project.
*/
async function installServerProject(serverProjectId: string) {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'bypass'),
get_project_v3(serverProjectId, 'bypass'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const createJob = await install_create_modpack_instance(
{
type: 'fromVersionId',
project_id: contentProjectId,
version_id: contentVersionId,
title: project.title,
},
{
name: project.title,
iconPath: project.icon_url ?? null,
link: {
type: 'server_project_modpack',
server_project_id: serverProjectId,
content_project_id: contentProjectId,
content_version_id: contentVersionId,
project_id: serverProjectId,
version_id: contentVersionId,
},
},
)
const instanceId = installJobInstanceId(createJob)
if (!instanceId) return
await wait_for_install_job(createJob.job_id)
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
async function playServerProject(projectId: string) {
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
return
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installingServerProjects.value.includes(projectId)) return
startInstallingServer(projectId)
try {
const instanceId = await createVanillaInstance(
project,
recommendedGameVersion,
serverAddress,
)
if (instanceId) {
instance = await get(instanceId)
if (instance) showModpackInstallSuccess(instance, serverAddress)
}
} finally {
stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installToPlayModalRef?.show(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
showModpackInstallSuccess(newInstance, serverAddress)
})
return
}
if (!instance) return
await ensureManagedServerWorldExists(instance.id, project.title, serverAddress)
// Update existing instance if needed
if (isModpack && instance.link?.version_id !== modpackVersionId) {
updateToPlayModalRef?.show(instance, modpackVersionId, () => {
showUpdateSuccess(instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installingServerProjects.value.includes(projectId)) return
startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(instance, serverAddress)
} finally {
stopInstallingServer(projectId)
}
return
}
// Join server
try {
await joinServer(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: instance.id })
}
}
const context: ServerInstallContext = {
installingServerProjects,
symlinkTarget,
startInstallingServer,
stopInstallingServer,
isServerInstalling,
installServerProject,
playServerProject,
setInstallToPlayModal(ref) {
installToPlayModalRef = ref
},
setUpdateToPlayModal(ref) {
updateToPlayModalRef = ref
},
setAddServerToInstanceModal(ref) {
addServerToInstanceModalRef = ref
},
showAddServerToInstanceModal(serverName: string, serverAddress: string) {
addServerToInstanceModalRef?.show(serverName, serverAddress)
},
}
_serverInstallSingleton = context
return context
}

View File

@ -0,0 +1,24 @@
import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui'
import { setupCreationModal } from './setup/creation-modal'
import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupInstanceImportProvider } from './setup/instance-import'
import { setupTagsProvider } from './setup/tags'
export function setupProviders(
notificationManager: AbstractWebNotificationManager,
popupNotificationManager: AbstractPopupNotificationManager,
stateInitialization: Promise<void>,
) {
setupTagsProvider(notificationManager, stateInitialization)
const fileDrop = setupFileDropProvider()
const filePicker = setupFilePickerProvider()
setupInstanceImportProvider(notificationManager)
return {
fileDrop,
...filePicker,
...setupCreationModal(notificationManager, popupNotificationManager),
}
}

View File

@ -0,0 +1,31 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
user?: Labrinth.Users.v2.User | null
}
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
const isReady = computed(() => credentials.value !== undefined)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
isReady,
requestSignIn,
}
watchEffect(() => {
sessionToken.value = credentials.value?.session ?? null
user.value = credentials.value?.user ?? null
})
provideAuth(authProvider)
}

View File

@ -0,0 +1,454 @@
import type {
AbstractPopupNotificationManager,
AbstractWebNotificationManager,
CreationFlowContextValue,
CreationFlowModal,
SymlinkMethodChoice,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { join } from '@tauri-apps/api/path'
import { inject, provide, ref, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { useRouter } from 'vue-router'
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
import { install_job_listener } from '@/helpers/events.js'
import { import_instance } from '@/helpers/import.js'
import {
type CreatePackLocation,
install_create_instance,
install_create_modpack_instance,
install_get_modpack_preview,
type InstallJobSnapshot,
wait_for_install_job,
} from '@/helpers/install'
import { check_symlink_capability, list } from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
import type { InstanceLoader } from '@/helpers/types'
const symlinkMessages = defineMessages({
unsupportedTitle: {
id: 'app.symlink-capability.unsupported.title',
defaultMessage: 'Shared instances are unavailable',
},
unsupportedBody: {
id: 'app.symlink-capability.unsupported',
defaultMessage: 'This system does not support creating symbolic links.',
},
})
const modpackMessages = defineMessages({
installing: {
id: 'app.drop.modpack-installing',
defaultMessage: 'Installing modpack...',
},
installed: {
id: 'app.drop.modpack-installed-success',
defaultMessage: 'Modpack installed successfully',
},
installingFile: {
id: 'app.drop.installing-file',
defaultMessage: 'Installing {name}...',
},
unknownFileType: {
id: 'app.drop.unknown-force-analysis-title',
defaultMessage: 'Unable to identify file type',
},
unknownFileTypeText: {
id: 'app.drop.unknown-force-analysis-text',
defaultMessage:
'This archive needs to be extracted and deeply analyzed to determine its content type. This may take a while. Force analysis?',
},
forceAnalysis: {
id: 'app.drop.unknown-force-analysis-button',
defaultMessage: 'Force analysis',
},
analyzing: {
id: 'app.drop.unknown-force-analyzing',
defaultMessage: 'Force analyzing archive...',
},
couldNotIdentify: {
id: 'app.drop.unknown-force-analysis-failed-title',
defaultMessage: 'Analysis failed',
},
couldNotIdentifyText: {
id: 'app.drop.unknown-force-analysis-failed-text',
defaultMessage: 'Could not identify the file type even after deep analysis.',
},
unexpectedType: {
id: 'app.drop.unexpected-type',
defaultMessage: 'Unexpected type: {type}',
},
})
const OPTIFABRIC_CURSEFORGE_PROJECT_ID = 322385
export function setupCreationModal(
notificationManager: AbstractWebNotificationManager,
_popupNotificationManager: AbstractPopupNotificationManager,
) {
const { formatMessage } = useVIntl()
const { handleError } = notificationManager
const router = useRouter()
const installationModal =
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
const unknownPackWarningModal =
useTemplateRef<InstanceType<typeof UnknownPackWarningModal>>('unknownPackWarningModal')
const modpackAlreadyInstalledModal = ref<InstanceType<typeof ModpackAlreadyInstalledModal>>()
function setModpackAlreadyInstalledModal(
modal: InstanceType<typeof ModpackAlreadyInstalledModal>,
) {
modpackAlreadyInstalledModal.value = modal
}
async function fetchExistingInstanceNames(): Promise<string[]> {
const instances = await list().catch(handleError)
return instances?.map((i) => i.name) ?? []
}
provide('showCreationModal', () => {
installationModal.value?.show()
})
provide(
'showCreationModalWithOptions',
(options?: {
skipSetupType?: boolean
initialMode?: 'custom' | 'import'
onBack?: () => void
}) => {
installationModal.value?.show(options)
},
)
async function proceedWithModpackCreation(
projectId: string,
versionId: string,
name: string,
iconUrl?: string,
) {
await install_create_modpack_instance(
{
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: name,
icon_url: iconUrl,
},
{ name },
).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
}
async function handleCreate(config: CreationFlowContextValue) {
try {
installationModal.value?.hide()
if (config.isImportMode.value) {
// Collect all instances to import
const instanceEntries: Array<{
launcherType: string
launcherName: string
path: string
instanceName: string
instancePath: string
}> = []
for (const [launcherName, instanceSet] of Object.entries(
config.importSelectedInstances.value,
)) {
const launcher = config.importLaunchers.value.find((l) => l.name === launcherName)
if (!launcher || instanceSet.size === 0) continue
for (const name of instanceSet) {
const instanceData = launcher.instances.find((i) => i.name === name)
instanceEntries.push({
launcherType: launcher.launcherType ?? launcher.name,
launcherName: launcher.name,
path: launcher.path,
instanceName: name,
instancePath: instanceData?.path ?? '',
})
}
}
if (instanceEntries.length === 0) return
// Show SymlinkMethodCards for user to choose copy vs symlink
const capability = await check_symlink_capability()
if (capability === 'unsupported') {
notificationManager.addNotification({
type: 'error',
title: formatMessage(symlinkMessages.unsupportedTitle),
text: formatMessage(symlinkMessages.unsupportedBody),
})
return
}
const chooseImportMethod: (options: {
instanceNames: string[]
symlinkCapable: 'supported' | 'requires_admin' | 'unsupported'
}) => Promise<SymlinkMethodChoice[]> = inject('chooseImportMethod')!
const choices = await chooseImportMethod({
instanceNames: instanceEntries.map((e) => e.instanceName),
symlinkCapable: capability,
})
if (choices.length === 0) return
const choiceByInstanceName = new Map(choices.map((choice) => [choice.instanceName, choice]))
for (const entry of instanceEntries) {
const choice = choiceByInstanceName.get(entry.instanceName)
try {
const job = await import_instance(
entry.launcherType,
entry.path,
entry.instanceName,
choice?.symlink ?? false,
entry.instancePath,
undefined,
undefined,
undefined,
choice?.gameDirOverride ?? null,
)
await wait_for_install_job(job.job_id)
} catch (error) {
handleError(error)
}
}
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
return
}
if (config.modpackSelection.value) {
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
return
}
if (config.modpackFilePath.value) {
// Fallback: called when modpack is imported via the creation flow
// (not via onImportFileReceived, which has its own install path).
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
await installModpackFromPath(config.modpackFilePath.value, fileName)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return
}
// Custom/vanilla setup
const loader = config.hideLoaderChips.value
? 'vanilla'
: (config.selectedLoader.value ?? 'vanilla')
const loaderVersion = config.hideLoaderVersion.value
? null
: (config.selectedLoaderVersion.value ?? config.loaderVersionType.value)
const iconPath = config.instanceIconPath.value ?? null
const name = config.instanceName.value.trim() || config.autoInstanceName.value
// Game directory: `gameDirOverride` holds the picked `.minecraft`
// root. Builtin keeps the managed folder (null); external resolves to
// `<root>/versions/<name>` when version-isolated, or the `.minecraft`
// root itself when not.
const mode = config.gameDirOverrideMode.value
const gameRoot = config.gameDirOverride.value ?? null
const gameDirOverride =
mode === 'builtin'
? null
: gameRoot
? mode === 'isolated'
? await join(gameRoot, 'versions', name)
: gameRoot
: null
await install_create_instance({
name,
gameVersion: config.selectedGameVersion.value!,
loader: loader as InstanceLoader,
loaderVersion,
adjuncts: config.selectedAdjuncts.value.map((kind) => ({
instanceId: '',
kind,
version: null,
role: 'adjunct',
})),
iconPath,
gameDirOverride,
}).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModal',
})
} catch (err) {
handleError(err as Error)
}
}
const pendingModpackCreation = ref<{
projectId: string
versionId: string
name: string
iconUrl?: string
} | null>(null)
async function doInstallModpackFile(
location: CreatePackLocation,
options: { autoCloseMs?: number | null; existingNotify?: { id: number } } = {},
) {
const existingNotify = options.existingNotify
const installingNotify =
existingNotify ??
notificationManager.addNotification({
title: formatMessage(modpackMessages.installing),
type: 'info',
autoCloseMs: options.autoCloseMs ?? 1000 * 10,
})
const job = await install_create_modpack_instance(location).catch((e) => {
notificationManager.removeNotification(installingNotify.id)
handleError(e)
return null
})
if (!job) return
// Single-use listener that auto-cleans up when the job reaches a terminal state
const unlisten = await install_job_listener((updatedJob: InstallJobSnapshot) => {
if (updatedJob.job_id !== job.job_id) return
if (updatedJob.status === 'succeeded') {
notificationManager.removeNotification(installingNotify.id)
notificationManager.addNotification({
title: formatMessage(modpackMessages.installed),
type: 'success',
})
unlisten()
} else if (['failed', 'canceled', 'interrupted'].includes(updatedJob.status)) {
notificationManager.removeNotification(installingNotify.id)
unlisten()
}
})
}
async function handleModpackDuplicateCreateAnyway() {
if (!pendingModpackCreation.value) return
const { projectId, versionId, name, iconUrl } = pendingModpackCreation.value
pendingModpackCreation.value = null
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
}
function handleModpackDuplicateGoToInstance(instanceId: string) {
pendingModpackCreation.value = null
router.push(`/instance/${encodeURIComponent(instanceId)}/`)
}
function handleBrowseModpacks() {
installationModal.value?.hide()
router.push('/browse/modpack')
}
async function searchModpacks(query: string, limit: number = 10) {
const params = [`facets=[["project_type:modpack"]]`, `limit=${limit}`]
if (query) {
params.push(`query=${encodeURIComponent(query)}`)
}
const raw = await get_search_results(`?${params.join('&')}`)
if (raw?.result) return raw.result
return { hits: [], offset: 0, limit, total_hits: 0 }
}
async function getProjectVersions(projectId: string) {
const versions = await get_project_versions(projectId, 'must_revalidate')
return versions ?? []
}
async function hasCompatibleOptiFabric(gameVersion: string) {
const response = await getCurseForgeFiles(OPTIFABRIC_CURSEFORGE_PROJECT_ID, {
index: 0,
pageSize: 50,
})
return hasCompatibleCurseForgeFile(response.files, gameVersion)
}
let _currentFlowCtx: CreationFlowContextValue | null = null
/** Install a modpack file with continuous feedback notifications. */
async function installModpackFromPath(
filePath: string,
fileName: string,
options: { persistUntilDone?: boolean } = {},
) {
const persistUntilDone = options.persistUntilDone === true
const currentNotify = notificationManager.addNotification({
title: formatMessage(modpackMessages.installingFile, { name: fileName }),
type: 'info',
autoCloseMs: persistUntilDone ? null : 1000 * 10,
})
const location: CreatePackLocation = { type: 'fromFile', path: filePath }
try {
const isMrpack = fileName?.toLowerCase().endsWith('.mrpack')
if (!isMrpack) {
// .zip needs preview to determine manifest
const preview = await install_get_modpack_preview(location).catch((e) => {
notificationManager.removeNotification(currentNotify.id)
handleError(e)
return null
})
if (!preview) return
if (preview.unknownFile) {
notificationManager.removeNotification(currentNotify.id)
unknownPackWarningModal.value?.show(async () => {
if (persistUntilDone) {
const installingNotify = notificationManager.addNotification({
title: formatMessage(modpackMessages.installing),
type: 'info',
autoCloseMs: null,
})
await doInstallModpackFile(location, { existingNotify: installingNotify })
} else {
await doInstallModpackFile(location)
}
}, fileName)
return
}
}
if (persistUntilDone) {
await doInstallModpackFile(location, { existingNotify: currentNotify })
} else {
await doInstallModpackFile(location)
}
} catch (e) {
notificationManager.removeNotification(currentNotify?.id)
handleError(e as Error)
}
}
provide('setCreationFlowCtx', (ctx: CreationFlowContextValue) => {
_currentFlowCtx = ctx
})
return {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
hasCompatibleOptiFabric,
getLoaderManifest,
installModpackFromPath,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
}
}

View File

@ -0,0 +1,73 @@
import { provideFileDrop } from '@modrinth/ui'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { getCurrentWebview } from '@tauri-apps/api/webview'
function toLogicalPosition(position: { x: number; y: number }) {
const scale = window.devicePixelRatio || 1
return {
x: position.x / scale,
y: position.y / scale,
}
}
export function setupFileDropProvider() {
let nativeFileDropPaths: string[] = []
let nativeFileDropActive = false
let internalDragActive = false
// Tauri emits drag-drop events for HTML drags as well as files from the OS.
// Track document drag sources so moving launcher controls cannot activate the
// global file-import overlay.
window.addEventListener('dragstart', () => {
internalDragActive = true
})
window.addEventListener('dragend', () => {
internalDragActive = false
})
const provider = {
async listenNativeFileDrop(handler) {
return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => {
const payload = event.payload
if (internalDragActive) return
if (payload.type === 'leave') {
if (!nativeFileDropActive) return
nativeFileDropActive = false
nativeFileDropPaths = []
void handler({
type: 'leave',
paths: [],
position: { x: 0, y: 0 },
})
return
}
if (payload.type === 'enter') {
if (!payload.paths?.length) return
nativeFileDropPaths = payload.paths
nativeFileDropActive = true
} else if (payload.type === 'drop' && payload.paths?.length) {
if (!nativeFileDropActive) return
nativeFileDropPaths = payload.paths
} else if (!nativeFileDropActive) {
return
}
void handler({
type: payload.type,
paths: nativeFileDropPaths,
position: toLogicalPosition(payload.position),
})
if (payload.type === 'drop') {
nativeFileDropActive = false
nativeFileDropPaths = []
}
})
},
}
provideFileDrop(provider)
return provider
}

View File

@ -0,0 +1,79 @@
import { type PickedFile, provideFilePicker } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
import { useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { getLoaderInstanceIcon } from '@/helpers/instance-icons'
function getFileName(path: string, fallback: string) {
return path.split(/[\\/]/).pop() || fallback
}
function getDialogPath(result: string | { path?: string } | null | undefined) {
if (!result) return null
return typeof result === 'string' ? result : (result.path ?? null)
}
async function createFileFromPath(path: string, fallbackName: string, type?: string) {
const bytes = await readFile(path)
const name = getFileName(path, fallbackName)
return new File([bytes], name, type ? { type } : undefined)
}
export async function pickImage(): Promise<PickedFile | null> {
const result = await open({
multiple: false,
filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'svg', 'webp', 'gif'] }],
})
if (!result) return null
const path = getDialogPath(result)
if (!path) return null
const file = await createFileFromPath(path, 'icon')
return { file, path, previewUrl: convertFileSrc(path) }
}
export function setupFilePickerProvider() {
const instanceIconPickerModal =
useTemplateRef<ComponentExposed<typeof InstanceIconPickerModal>>('instanceIconPickerModal')
provideFilePicker({
async pickFolder() {
const result = await open({
directory: true,
multiple: false,
})
const path = getDialogPath(result)
if (!path) return null
return { path }
},
pickImage,
pickInstanceIcon: () => instanceIconPickerModal.value?.show() ?? Promise.resolve(null),
getLoaderInstanceIconUrl: (loader) => getLoaderInstanceIcon(loader)?.url ?? null,
async pickModpackFile(options) {
const result = await open({
multiple: false,
filters: [{ name: 'Modpack', extensions: ['mrpack', 'zip'] }],
})
if (!result) return null
const path = getDialogPath(result)
if (!path) return null
if (options?.readFile === false) {
// Instance imports stream from the native path, keeping large packs out of JS memory.
return { path, previewUrl: '' }
}
return {
file: await createFileFromPath(
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
),
path,
previewUrl: '',
}
},
})
return { instanceIconPickerModal }
}

View File

@ -0,0 +1,113 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { provideInstanceImport } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { import_plan_listener } from '@/helpers/events.js'
import {
cancel_import_plan,
get_default_launcher_path,
get_importable_instances,
import_instance,
start_import_plan,
} from '@/helpers/import.js'
import { wait_for_install_job } from '@/helpers/install'
import { get_loader_versions } from '@/helpers/metadata.js'
import { openPath } from '@/helpers/utils.js'
export function setupInstanceImportProvider(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
provideInstanceImport({
async getDetectedLaunchers() {
const launcherNames = [
'ModrinthApp',
'MultiMC',
'PCL2',
'PCL2CE',
'HMCL',
'GDLauncher',
'ATLauncher',
'Curseforge',
'PrismLauncher',
'Generic',
]
const launchers = []
for (const name of launcherNames) {
try {
const path = await get_default_launcher_path(name)
if (!path) continue
const instances = await get_importable_instances(name, path)
if (instances?.length > 0) {
launchers.push({ name, path, instances })
}
} catch {
// Skip launchers that fail detection
}
}
return launchers
},
async getImportableInstances(launcherName: string, path: string) {
return (await get_importable_instances(launcherName, path)) ?? []
},
async getLoaderVersions(loader: string, gameVersion: string) {
const manifest = await get_loader_versions(loader, gameVersion)
const gameVersions = manifest?.gameVersions ?? manifest?.game_versions ?? []
const versionGroups = manifest?.versionGroups ?? manifest?.version_groups ?? []
const entry = gameVersions.find(
(version) =>
(version.id ?? '').replace('${modrinth.gameVersion}', gameVersion) === gameVersion,
)
let loaders: Array<string | { id?: string }> = []
if (entry) {
if (entry.versionGroup) {
loaders = versionGroups.find((group) => group.id === entry.versionGroup)?.loaders ?? []
} else {
loaders = entry.loaders ?? entry.loader_versions ?? []
}
}
const versions = loaders.map((loaderVersion) =>
typeof loaderVersion === 'string' ? loaderVersion : loaderVersion.id,
)
console.debug('[InstanceImport] loader versions', loader, gameVersion, versions.length)
return [...new Set(versions.filter((version): version is string => !!version))]
},
openPath: (path) => openPath(path),
startImportPlan: (request) => start_import_plan(request),
cancelImportPlan: (requestId) => cancel_import_plan(requestId),
listenImportPlan: (callback) => import_plan_listener(callback),
async importInstances(selections) {
for (const sel of selections) {
for (let i = 0; i < sel.instanceNames.length; i++) {
const instanceName = sel.instanceNames[i]
const instancePath = sel.instancePaths?.[i]
try {
const job = await import_instance(
sel.launcherType ?? sel.launcher,
sel.path,
instanceName,
sel.symlink ?? false,
instancePath,
sel.gameVersion,
sel.loader,
sel.loaderVersion,
sel.gameDirOverride ?? null,
)
await wait_for_install_job(job.job_id)
} catch (error) {
handleError(error)
}
}
}
},
async selectDirectory() {
const result = await open({ multiple: false, directory: true })
return result?.toString() ?? null
},
async selectDirectories() {
const result = await open({ multiple: true, directory: true })
if (!result) return null
if (Array.isArray(result)) return result.map((p) => p.toString())
return [result.toString()]
},
})
}

View File

@ -0,0 +1,17 @@
import type { LoadingStateProvider } from '@modrinth/ui'
import { createLoadingStateCore, provideLoadingState } from '@modrinth/ui'
/**
* Source of truth for the desktop app's loading state.
*
* Owns the token-based ref-counter directly (no Pinia store). Consumers
* obtain the same reactive state via `injectLoadingState()` from `@modrinth/ui`.
*
* Returns the provider so the call site (App.vue) can also use it directly
* without a second injection round-trip.
*/
export function setupLoadingStateProvider(): LoadingStateProvider {
const provider = createLoadingStateCore({ barEnabled: false })
provideLoadingState(provider)
return provider
}

View File

@ -0,0 +1,610 @@
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
import {
addPendingServerContentInstalls,
type BrowseInstallPlan,
type BrowseSelectedProject,
createContext,
type CreationFlowContextValue,
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
injectModrinthClient,
injectNotificationManager,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server'
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
title?: string
installing?: boolean
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
export interface ServerModpackSelectionRequest {
projectId: string
versionId: string
name: string
iconUrl?: string
}
interface ServerSetupModalHandle {
show: () => void | Promise<void>
hide: () => void
ctx?: CreationFlowContextValue | null
}
export interface ServerInstallContentContext {
serverIdQuery: ComputedRef<string | null>
worldIdQuery: ComputedRef<string | null>
browseFrom: ComputedRef<string | null>
serverFlowFrom: ComputedRef<ServerFlowFrom | null>
isFromWorlds: ComputedRef<boolean>
isServerContext: ComputedRef<boolean>
isSetupServerContext: ComputedRef<boolean>
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
isInstallingQueuedServerInstalls: Ref<boolean>
queuedInstallProgress: Ref<{ completed: number; total: number }>
serverBackUrl: ComputedRef<string>
serverBackLabel: ComputedRef<string>
serverBrowseHeading: ComputedRef<string>
clearQueuedServerInstalls: () => void
removeQueuedServerInstall: (projectId: string) => void
flushQueuedServerInstalls: () => Promise<boolean>
discardQueuedServerInstallsAndBack: () => Promise<void>
installQueuedServerInstallsAndBack: () => Promise<boolean>
initServerContext: () => Promise<void>
watchServerContextChanges: () => void
searchServerModpacks: (
query: string,
limit?: number,
) => Promise<Labrinth.Projects.v2.SearchResult>
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
getQueuedServerInstallPlans: () => Map<string, BrowseInstallPlan<InstallableSearchResult>>
setQueuedServerInstallPlans: (
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) => void
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
markServerProjectInstalled: (id: string) => void
}
export const [injectServerInstallContent, provideServerInstallContent] =
createContext<ServerInstallContentContext>('Browse', 'serverInstallContent')
function readQueryString(value: unknown): string | null {
if (Array.isArray(value)) return value[0] ?? null
return typeof value === 'string' && value.length > 0 ? value : null
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `https://modrinth.com/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `https://modrinth.com/user/${ownerId}`,
}
}
async function getQueuedInstallOwner(
client: AbstractModrinthClient,
project: InstallableSearchResult,
) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `https://modrinth.com/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `https://modrinth.com/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
const project = plan.project as InstallableSearchResult & { slug?: string | null }
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: project.title ?? project.name ?? 'Project',
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: project.slug ?? plan.projectId,
iconUrl: project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
const { serverSetupModalRef } = opts
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
const browseFrom = computed(() => readQueryString(route.query.from))
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
? browseFrom.value
: null,
)
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
const isServerContext = computed(() => !!serverIdQuery.value)
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
id: plan.projectId,
name: plan.project.title ?? plan.project.name ?? 'Project',
iconUrl: plan.project.icon_url ?? null,
})),
)
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
if (serverFlowFrom.value === 'onboarding') {
return `/hosting/manage/${sid}?resumeModal=setup-type`
}
if (serverFlowFrom.value === 'reset-server') {
return `/hosting/manage/${sid}?openSettings=installation`
}
return `/hosting/manage/${sid}/content`
})
const serverBackLabel = computed(() => {
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
if (serverFlowFrom.value === 'reset-server') return 'Cancel reset'
return 'Back to server'
})
const serverBrowseHeading = computed(() => {
if (serverFlowFrom.value === 'reset-server') {
return 'Selecting modpack to install after reset'
}
return 'Installing content'
})
async function resolveServerContextWorldId(serverId: string) {
try {
const server = await client.archon.servers_v1.get(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
} catch (err) {
handleError(err as Error)
return null
}
}
async function refreshServerInstalledContent(serverId: string, worldId: string) {
try {
const content = await client.archon.content_v1.getAddons(serverId, worldId)
const ids = new Set(
(content.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
const keys = new Set(
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
)
serverContentProjectIds.value = ids
serverContentInstallKeys.value = keys
} catch (err) {
handleError(err as Error)
}
}
async function initServerContext() {
const sid = serverIdQuery.value
if (!sid) return
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
let resolvedWorldId = effectiveServerWorldId.value
if (!resolvedWorldId) {
resolvedWorldId = await resolveServerContextWorldId(sid)
if (resolvedWorldId) {
serverContextWorldId.value = resolvedWorldId
}
}
if (resolvedWorldId) {
queuedServerInstalls.value = readStoredServerInstallQueue(sid, resolvedWorldId)
await refreshServerInstalledContent(sid, resolvedWorldId)
}
}
function watchServerContextChanges() {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
}
if (wid !== prevWid) {
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
}
if (wid && (sid !== prevSid || wid !== prevWid)) {
await refreshServerInstalledContent(sid, wid)
}
})
}
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
router.replace({
path: '/browse/modpack',
query: route.query,
})
}
async function searchServerModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
query: query || undefined,
new_filters:
'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"',
limit,
})
}
async function getServerProjectVersions(projectId: string) {
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
return versions.map((version) => ({ id: version.id }))
}
async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) {
if (!serverIdQuery.value || !effectiveServerWorldId.value) {
throw new Error('Missing server context')
}
const modalInstance = serverSetupModalRef.value
if (!modalInstance) return
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
if (!ctx) return
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: request.projectId,
versionId: request.versionId,
name: request.name,
iconUrl: request.iconUrl,
}
ctx.modal.value?.setStage('final-config')
}
function clearQueuedServerInstalls() {
setQueuedServerInstallPlans(new Map())
}
function removeQueuedServerInstall(projectId: string) {
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.delete(projectId)
setQueuedServerInstallPlans(nextPlans)
}
function setStoredServerInstallPlans(
serverId: string,
worldId: string,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
if (serverId === serverIdQuery.value && worldId === effectiveServerWorldId.value) {
queuedServerInstalls.value = plans
}
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function flushQueuedServerInstalls(
serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value,
) {
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
handleError(new Error('No server world is available for install.'))
return false
}
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
if (queuedPlans.size === 0) return true
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedPlans.size,
}
try {
const result = await flushStoredServerAddonInstallQueue({
serverId,
worldId,
install: (plans) =>
client.archon.content_v1.addAddons(
serverId,
worldId,
plans.map((plan) => ({
project_id: plan.projectId,
version_id: plan.versionId,
})),
),
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
queuedInstallProgress.value = {
completed: result.flushedPlans.length,
total: result.flushedPlans.length,
}
serverContentProjectIds.value = new Set([
...serverContentProjectIds.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
return true
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
}
}
async function discardQueuedServerInstallsAndBack() {
clearQueuedServerInstalls()
await router.push(serverBackUrl.value)
}
async function installQueuedServerInstallsAndBack() {
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
const backUrl = serverBackUrl.value
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await router.push(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true
}
function getQueuedServerInstallPlans() {
return queuedServerInstalls.value
}
function setQueuedServerInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
queuedServerInstalls.value = plans
writeStoredServerInstallQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
}
function onServerFlowBack() {
serverSetupModalRef.value?.hide()
}
async function handleServerModpackFlowCreate(config: CreationFlowContextValue) {
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid || !config.modpackSelection.value) {
config.loading.value = false
return
}
try {
await client.archon.content_v1.installContent(sid, wid, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
serverSetupModalRef.value?.hide()
if (serverFlowFrom.value === 'onboarding') {
await client.archon.servers_v1.endIntro(sid)
await router.push(`/hosting/manage/${sid}/content`)
return
}
await router.push(`/hosting/manage/${sid}?openSettings=installation`)
} catch (err) {
handleError(err as Error)
config.loading.value = false
}
}
function markServerProjectInstalled(id: string) {
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
}
return {
serverIdQuery,
worldIdQuery,
browseFrom,
serverFlowFrom,
isFromWorlds,
isServerContext,
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
isInstallingQueuedServerInstalls,
queuedInstallProgress,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
clearQueuedServerInstalls,
removeQueuedServerInstall,
flushQueuedServerInstalls,
discardQueuedServerInstallsAndBack,
installQueuedServerInstallsAndBack,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
}
}

View File

@ -0,0 +1,31 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { provideTags } from '@modrinth/ui'
import { ref } from 'vue'
import { get_game_versions, get_loaders } from '@/helpers/tags'
export function setupTagsProvider(
notificationManager: AbstractWebNotificationManager,
stateInitialization: Promise<void>,
) {
const { handleError } = notificationManager
const gameVersions = ref([])
const loaders = ref([])
stateInitialization
.then(() => {
get_game_versions()
.then((v) => {
gameVersions.value = v
})
.catch(handleError)
get_loaders()
.then((v) => {
loaders.value = v
})
.catch(handleError)
})
.catch(() => {})
provideTags({ gameVersions, loaders })
}