forked from AxTps/Starlight_Lancher
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
This commit is contained in:
@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
||||
|
||||
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
||||
const state = createHostedDownloadFailures()
|
||||
const failed = (instanceId: string, loader: string, error: string) => ({
|
||||
fraction: null,
|
||||
loader_uuid: loader,
|
||||
event: { type: 'hosted_pack_sync', instance_id: instanceId, instance_name: instanceId, error },
|
||||
})
|
||||
const oldFailure = failed('one', 'old', 'old failure')
|
||||
state.update(oldFailure)
|
||||
state.update(failed('two', 'other', 'other failure'))
|
||||
state.begin('one', state.values())
|
||||
assert.equal(state.has('one'), false)
|
||||
assert.equal(state.has('two'), true)
|
||||
assert.equal(state.isRetired('old'), true)
|
||||
state.update(oldFailure)
|
||||
assert.equal(state.has('one'), false)
|
||||
state.update(failed('one', 'new', 'new failure'))
|
||||
state.update({ ...oldFailure, fraction: 0.5 })
|
||||
assert.equal(
|
||||
state.values().find((bar) => bar.bar_type?.instance_id === 'one')?.message,
|
||||
'new failure',
|
||||
)
|
||||
})
|
||||
|
||||
test('a new native task retires the previous failed task without a frontend retry', () => {
|
||||
const state = createHostedDownloadFailures()
|
||||
const old = {
|
||||
fraction: null,
|
||||
loader_uuid: 'old',
|
||||
event: { type: 'hosted_pack_sync', instance_id: 'instance', error: 'old failure' },
|
||||
}
|
||||
state.update(old)
|
||||
state.update({ fraction: 0, loader_uuid: 'new', event: { ...old.event, error: null } })
|
||||
state.update(old)
|
||||
assert.equal(state.has('instance'), false)
|
||||
})
|
||||
|
||||
test('late failure from an older active task cannot replace the new task', () => {
|
||||
const state = createHostedDownloadFailures()
|
||||
const event = { type: 'hosted_pack_sync', instance_id: 'instance' }
|
||||
state.update({ fraction: 0.5, loader_uuid: 'old', event })
|
||||
state.update({ fraction: 0, loader_uuid: 'new', event })
|
||||
state.update({ fraction: null, loader_uuid: 'old', event: { ...event, error: 'old failure' } })
|
||||
assert.equal(state.has('instance'), false)
|
||||
assert.equal(state.isRetired('old'), true)
|
||||
assert.equal(state.isRetired('new'), false)
|
||||
})
|
||||
53
apps/app-frontend/src/helpers/hosted-download-failures.ts
Normal file
53
apps/app-frontend/src/helpers/hosted-download-failures.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { LoadingBar } from './state.ts'
|
||||
|
||||
export function createHostedDownloadFailures() {
|
||||
const failures = new Map<string, LoadingBar>()
|
||||
const retired = new Set<string>()
|
||||
const currentTasks = new Map<string, string>()
|
||||
return {
|
||||
values: () => [...failures.values()],
|
||||
has: (instanceId: string) => failures.has(instanceId),
|
||||
isRetired: (id: string) => retired.has(id),
|
||||
begin(instanceId: string, bars: LoadingBar[]) {
|
||||
const current = currentTasks.get(instanceId)
|
||||
if (current) retired.add(current)
|
||||
currentTasks.delete(instanceId)
|
||||
const previous = failures.get(instanceId)
|
||||
if (previous) retired.add(String(previous.loading_bar_uuid))
|
||||
for (const bar of bars) {
|
||||
if (bar.bar_type?.type === 'hosted_pack_sync' && bar.bar_type.instance_id === instanceId) {
|
||||
retired.add(String(bar.loading_bar_uuid))
|
||||
}
|
||||
}
|
||||
failures.delete(instanceId)
|
||||
},
|
||||
update(payload: {
|
||||
fraction: number | null
|
||||
loader_uuid: string
|
||||
event: LoadingBar['bar_type']
|
||||
}) {
|
||||
const event = payload.event
|
||||
if (
|
||||
event?.type !== 'hosted_pack_sync' ||
|
||||
!event.instance_id ||
|
||||
retired.has(payload.loader_uuid)
|
||||
)
|
||||
return
|
||||
const current = currentTasks.get(event.instance_id)
|
||||
if (current && current !== payload.loader_uuid) retired.add(current)
|
||||
currentTasks.set(event.instance_id, payload.loader_uuid)
|
||||
if (payload.fraction === null && event.error) {
|
||||
failures.set(event.instance_id, {
|
||||
loading_bar_uuid: payload.loader_uuid,
|
||||
bar_type: event,
|
||||
title: event.instance_name,
|
||||
message: event.error,
|
||||
total: 0,
|
||||
current: 0,
|
||||
})
|
||||
} else {
|
||||
failures.delete(event.instance_id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,79 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
requestSkinSiteDownloadToken,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '../composables/skin-site-session.ts'
|
||||
|
||||
let sessionUpdate: Promise<void> = Promise.resolve()
|
||||
const attemptListeners = new Set<(instanceId: string) => void>()
|
||||
|
||||
export function onHostedPackAttemptStarted(listener: (instanceId: string) => void) {
|
||||
attemptListeners.add(listener)
|
||||
return () => {
|
||||
attemptListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function startHostedPackAttempt(instanceId: string) {
|
||||
for (const listener of attemptListeners) listener(instanceId)
|
||||
}
|
||||
|
||||
export function clearHostedSession(): Promise<void> {
|
||||
const update = sessionUpdate
|
||||
.catch(() => {})
|
||||
.then(() => invokeHosted<void>('plugin:install|hosted_set_session', { token: null }))
|
||||
sessionUpdate = update
|
||||
return update
|
||||
}
|
||||
|
||||
export async function prepareHostedSession(): Promise<void> {
|
||||
const userId = skinSiteUser.value?.uuid
|
||||
const token = await requestSkinSiteDownloadToken()
|
||||
const update = sessionUpdate
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userId) {
|
||||
throw new Error('StarLight 登录状态已变化,请重试。')
|
||||
}
|
||||
await invokeHosted<void>('plugin:install|hosted_set_session', { token })
|
||||
})
|
||||
sessionUpdate = update
|
||||
await update
|
||||
}
|
||||
|
||||
async function invokeWithSession<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
await prepareHostedSession()
|
||||
return invokeHosted<T>(command, args)
|
||||
}
|
||||
|
||||
async function invokeHosted<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await invoke<T>(command, args)
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof Error) &&
|
||||
typeof cause === 'object' &&
|
||||
cause !== null &&
|
||||
'message' in cause &&
|
||||
typeof cause.message === 'string'
|
||||
) {
|
||||
throw new Error(cause.message, { cause })
|
||||
}
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
export type InstanceMode = 'starlight' | 'local'
|
||||
export const getInstanceMode = (instanceId: string) =>
|
||||
invoke<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
|
||||
export const setInstanceMode = (instanceId: string, mode: InstanceMode) =>
|
||||
invoke<void>('plugin:install|hosted_set_instance_mode', { instanceId, mode })
|
||||
invokeHosted<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
|
||||
export const setInstanceMode = (instanceId: string, mode: InstanceMode) => {
|
||||
if (mode === 'starlight') startHostedPackAttempt(instanceId)
|
||||
return (mode === 'starlight' ? invokeWithSession<void> : invokeHosted<void>)(
|
||||
'plugin:install|hosted_set_instance_mode',
|
||||
{ instanceId, mode },
|
||||
)
|
||||
}
|
||||
|
||||
export interface HostedPublication {
|
||||
packId: string
|
||||
@ -26,8 +95,12 @@ export interface HostedSyncResult {
|
||||
changedFiles: number
|
||||
preservedFiles: string[]
|
||||
}
|
||||
export const hostedCatalog = () => invoke<HostedPublication[]>('plugin:install|hosted_catalog')
|
||||
export const hostedDefault = () =>
|
||||
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
|
||||
export const hostedCreate = () => invokeWithSession<string>('plugin:install|hosted_create')
|
||||
export const hostedBinding = (instanceId: string) =>
|
||||
invoke<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||
export const hostedSync = (instanceId: string, packId: string) =>
|
||||
invoke<HostedSyncResult>('plugin:install|hosted_sync', { instanceId, packId })
|
||||
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||
export const hostedSync = (instanceId: string) => {
|
||||
startHostedPackAttempt(instanceId)
|
||||
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import { collectGcContext } from '@/helpers/gc/context'
|
||||
import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
|
||||
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
||||
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
||||
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
||||
import { get as getSettings } from '@/helpers/settings'
|
||||
@ -1073,6 +1074,7 @@ export async function run(
|
||||
instanceId: string,
|
||||
serverAddress: string | null = null,
|
||||
): Promise<InstanceRunResult> {
|
||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
|
||||
instanceId,
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export interface LoadingBarType {
|
||||
batch_id?: string
|
||||
file_name?: string
|
||||
error?: string | null
|
||||
type?: string
|
||||
version?: string
|
||||
instance_id?: string
|
||||
@ -25,12 +28,7 @@ export interface LoadingBar {
|
||||
}
|
||||
|
||||
export type OpeningCommandEvent =
|
||||
| 'RunMRPack'
|
||||
| 'InstallServer'
|
||||
| 'InstallVersion'
|
||||
| 'InstallMod'
|
||||
| 'InstallModpack'
|
||||
| string
|
||||
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
|
||||
|
||||
export interface OpeningCommand {
|
||||
event: OpeningCommandEvent
|
||||
|
||||
75
apps/app-frontend/src/helpers/tagged-mod-progress.test.ts
Normal file
75
apps/app-frontend/src/helpers/tagged-mod-progress.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createTaggedModProgress } from './tagged-mod-progress.ts'
|
||||
|
||||
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
|
||||
loader_uuid: id,
|
||||
fraction,
|
||||
message: 'Applying updates',
|
||||
event: { type: 'hosted_pack_sync', instance_id: 'one', error },
|
||||
})
|
||||
const file = (id: string, fraction: number | null, batch = 'batch', error = '') => ({
|
||||
loader_uuid: id,
|
||||
fraction,
|
||||
total: 100,
|
||||
message: 'Downloading',
|
||||
event: {
|
||||
type: 'hosted_mod_download',
|
||||
instance_id: 'one',
|
||||
instance_name: 'Server',
|
||||
batch_id: batch,
|
||||
file_name: id,
|
||||
error,
|
||||
},
|
||||
})
|
||||
|
||||
test('parallel files keep independent progress and wait for installation to complete', () => {
|
||||
const state = createTaggedModProgress()
|
||||
state.update(parent())
|
||||
assert.equal(state.update(file('first', 0.25)), true)
|
||||
assert.equal(state.update(file('second', 0.6)), false)
|
||||
const group = state.groups.get('batch')!
|
||||
assert.equal(group.files.get('first')!.current, 25)
|
||||
assert.equal(group.files.get('second')!.current, 60)
|
||||
state.update(file('first', null))
|
||||
state.update(file('first', 0.1))
|
||||
assert.equal(group.files.get('first')!.current, 100)
|
||||
state.update(file('second', null))
|
||||
assert.equal(group.done, false)
|
||||
state.update(parent('parent', null))
|
||||
assert.equal(group.done, true)
|
||||
assert.equal(group.error, '')
|
||||
})
|
||||
|
||||
test('failures retain downloaded bytes and retry discards all late events from the old attempt', () => {
|
||||
const state = createTaggedModProgress()
|
||||
state.update(parent())
|
||||
state.update(file('first', 0.4))
|
||||
state.update(file('first', null, 'batch', 'network failed'))
|
||||
state.update(parent('parent', null, 'network failed'))
|
||||
assert.equal(state.groups.get('batch')!.files.get('first')!.current, 40)
|
||||
assert.equal(state.groups.get('batch')!.error, 'network failed')
|
||||
state.reset('one')
|
||||
state.update(parent('new-parent'))
|
||||
state.update(file('new-file', 0.2, 'new-batch'))
|
||||
state.update(parent('parent', 0.9))
|
||||
state.update(parent('parent', null, 'old error'))
|
||||
state.update(file('first', null))
|
||||
assert.deepEqual([...state.groups.keys()], ['new-batch'])
|
||||
assert.equal(state.groups.get('new-batch')!.done, false)
|
||||
})
|
||||
|
||||
test('native retry and resetting one instance leave other instance downloads alone', () => {
|
||||
const state = createTaggedModProgress()
|
||||
state.update(parent())
|
||||
state.update(file('first', 0))
|
||||
const other = file('other', 0.8, 'other-batch')
|
||||
other.event.instance_id = 'two'
|
||||
state.update(other)
|
||||
state.update(parent('new-parent'))
|
||||
state.update(file('new-file', 0.3, 'new-batch'))
|
||||
state.update(parent('parent', 0.5))
|
||||
assert.deepEqual([...state.groups.keys()], ['other-batch', 'new-batch'])
|
||||
state.reset('one')
|
||||
assert.deepEqual([...state.groups.keys()], ['other-batch'])
|
||||
})
|
||||
102
apps/app-frontend/src/helpers/tagged-mod-progress.ts
Normal file
102
apps/app-frontend/src/helpers/tagged-mod-progress.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { LoadingBarType } from './state.ts'
|
||||
|
||||
export interface TaggedProgressEvent {
|
||||
loader_uuid: string
|
||||
event?: LoadingBarType
|
||||
fraction: number | null
|
||||
total?: number | null
|
||||
message: string
|
||||
}
|
||||
export interface TaggedProgressFile {
|
||||
id: string
|
||||
name: string
|
||||
current: number
|
||||
total: number
|
||||
message: string
|
||||
done: boolean
|
||||
error: string
|
||||
}
|
||||
export interface TaggedProgressGroup {
|
||||
id: string
|
||||
instanceId: string
|
||||
name: string
|
||||
message: string
|
||||
error: string
|
||||
done: boolean
|
||||
files: Map<string, TaggedProgressFile>
|
||||
}
|
||||
|
||||
export function createTaggedModProgress() {
|
||||
const groups = new Map<string, TaggedProgressGroup>()
|
||||
const parents = new Map<string, string>()
|
||||
const retiredParents = new Set<string>()
|
||||
const retired = new Set<string>()
|
||||
function reset(instanceId: string) {
|
||||
const parent = parents.get(instanceId)
|
||||
if (parent) retiredParents.add(parent)
|
||||
parents.delete(instanceId)
|
||||
for (const [id, group] of groups) {
|
||||
if (group.instanceId === instanceId) {
|
||||
retired.add(id)
|
||||
groups.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
function update(payload: TaggedProgressEvent): boolean {
|
||||
const event = payload.event
|
||||
if (!event?.instance_id) return false
|
||||
if (event.type === 'hosted_pack_sync') {
|
||||
if (retiredParents.has(payload.loader_uuid)) return false
|
||||
if (payload.fraction !== null && parents.get(event.instance_id) !== payload.loader_uuid) {
|
||||
reset(event.instance_id)
|
||||
parents.set(event.instance_id, payload.loader_uuid)
|
||||
}
|
||||
if (parents.get(event.instance_id) !== payload.loader_uuid) return false
|
||||
for (const group of groups.values()) {
|
||||
if (group.instanceId !== event.instance_id) continue
|
||||
if (group.done) continue
|
||||
group.message = payload.message
|
||||
if (payload.fraction === null) {
|
||||
group.done = true
|
||||
group.error = event.error ?? ''
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (event.type !== 'hosted_mod_download' || !event.batch_id || retired.has(event.batch_id))
|
||||
return false
|
||||
let group = groups.get(event.batch_id)
|
||||
const isNew = !group
|
||||
if (!group) {
|
||||
group = {
|
||||
id: event.batch_id,
|
||||
instanceId: event.instance_id,
|
||||
name: event.instance_name ?? '',
|
||||
message: '',
|
||||
error: '',
|
||||
done: false,
|
||||
files: new Map(),
|
||||
}
|
||||
groups.set(group.id, group)
|
||||
}
|
||||
const previous = group.files.get(payload.loader_uuid)
|
||||
if (previous?.done) return false
|
||||
const total = Math.max(0, payload.total ?? previous?.total ?? 0)
|
||||
group.files.set(payload.loader_uuid, {
|
||||
id: payload.loader_uuid,
|
||||
name: event.file_name ?? '',
|
||||
total,
|
||||
current:
|
||||
payload.fraction === null
|
||||
? event.error
|
||||
? (previous?.current ?? 0)
|
||||
: total
|
||||
: Math.max(0, Math.min(1, payload.fraction)) * total,
|
||||
message: payload.message,
|
||||
done: payload.fraction === null,
|
||||
error: event.error ?? '',
|
||||
})
|
||||
return isNew
|
||||
}
|
||||
return { groups, reset, update }
|
||||
}
|
||||
Reference in New Issue
Block a user