feat:移除了弹窗,服务器添加sls
This commit is contained in:
102
apps/app-frontend/src/providers/app-notifications.ts
Normal file
102
apps/app-frontend/src/providers/app-notifications.ts
Normal 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
|
||||
}
|
||||
}
|
||||
81
apps/app-frontend/src/providers/app-popup-notifications.ts
Normal file
81
apps/app-frontend/src/providers/app-popup-notifications.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
186
apps/app-frontend/src/providers/app-update.ts
Normal file
186
apps/app-frontend/src/providers/app-update.ts
Normal 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)
|
||||
}
|
||||
2737
apps/app-frontend/src/providers/content-install.ts
Normal file
2737
apps/app-frontend/src/providers/content-install.ts
Normal file
File diff suppressed because it is too large
Load Diff
118
apps/app-frontend/src/providers/content-selection-logic.test.ts
Normal file
118
apps/app-frontend/src/providers/content-selection-logic.test.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
107
apps/app-frontend/src/providers/content-selection-logic.ts
Normal file
107
apps/app-frontend/src/providers/content-selection-logic.ts
Normal 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 }
|
||||
}
|
||||
1083
apps/app-frontend/src/providers/content-selection.ts
Normal file
1083
apps/app-frontend/src/providers/content-selection.ts
Normal file
File diff suppressed because it is too large
Load Diff
438
apps/app-frontend/src/providers/download-manager.ts
Normal file
438
apps/app-frontend/src/providers/download-manager.ts
Normal 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',
|
||||
)
|
||||
36
apps/app-frontend/src/providers/download-progress.ts
Normal file
36
apps/app-frontend/src/providers/download-progress.ts
Normal 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')
|
||||
15
apps/app-frontend/src/providers/instance-settings.ts
Normal file
15
apps/app-frontend/src/providers/instance-settings.ts
Normal 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')
|
||||
385
apps/app-frontend/src/providers/server-install.ts
Normal file
385
apps/app-frontend/src/providers/server-install.ts
Normal 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
|
||||
}
|
||||
24
apps/app-frontend/src/providers/setup.ts
Normal file
24
apps/app-frontend/src/providers/setup.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
31
apps/app-frontend/src/providers/setup/auth.ts
Normal file
31
apps/app-frontend/src/providers/setup/auth.ts
Normal 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)
|
||||
}
|
||||
454
apps/app-frontend/src/providers/setup/creation-modal.ts
Normal file
454
apps/app-frontend/src/providers/setup/creation-modal.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
73
apps/app-frontend/src/providers/setup/file-drop.ts
Normal file
73
apps/app-frontend/src/providers/setup/file-drop.ts
Normal 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
|
||||
}
|
||||
79
apps/app-frontend/src/providers/setup/file-picker.ts
Normal file
79
apps/app-frontend/src/providers/setup/file-picker.ts
Normal 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 }
|
||||
}
|
||||
113
apps/app-frontend/src/providers/setup/instance-import.ts
Normal file
113
apps/app-frontend/src/providers/setup/instance-import.ts
Normal 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()]
|
||||
},
|
||||
})
|
||||
}
|
||||
17
apps/app-frontend/src/providers/setup/loading-state.ts
Normal file
17
apps/app-frontend/src/providers/setup/loading-state.ts
Normal 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
|
||||
}
|
||||
610
apps/app-frontend/src/providers/setup/server-install-content.ts
Normal file
610
apps/app-frontend/src/providers/setup/server-install-content.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
31
apps/app-frontend/src/providers/setup/tags.ts
Normal file
31
apps/app-frontend/src/providers/setup/tags.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user