mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 18:45:15 +00:00
refactor: app event bus (#6985)
* refactor: app event bus * fix: use messagepack + cleanup * fix: use postcard * fix: lint * fix: import gate * fix: prettierignore + lint * fix: lint * fix: rev
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
|
||||
import type { AppEvent } from '@/generated/app-events/AppEvent'
|
||||
|
||||
export type AppEventType = AppEvent['type']
|
||||
export type AppEventPayload<Type extends AppEventType> = Extract<
|
||||
AppEvent,
|
||||
{ type: Type }
|
||||
>['payload']
|
||||
export type AppEventHandler<Type extends AppEventType> = (
|
||||
payload: AppEventPayload<Type>,
|
||||
) => void | Promise<void>
|
||||
|
||||
export interface AppEvents {
|
||||
on<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>): () => void
|
||||
once<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>): () => void
|
||||
}
|
||||
|
||||
export const [injectAppEvents, provideAppEvents] = createContext<AppEvents>('root', 'appEvents')
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
get_team,
|
||||
get_version_many,
|
||||
} from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
install_create_instance,
|
||||
install_create_modpack_instance,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
} from '@/helpers/instance'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance, InstanceLoader } from '@/helpers/types'
|
||||
import type { AppEvents } from '@/providers/app-events'
|
||||
import { useTheming } from '@/store/state'
|
||||
interface ModalRef {
|
||||
show: (initialVersionId?: string) => void
|
||||
@@ -60,13 +60,6 @@ type InstallingProjectDisplay = {
|
||||
organization?: string | null
|
||||
team?: string
|
||||
}
|
||||
type ContentInstallInstanceEvent = {
|
||||
event: string
|
||||
instance_id: string
|
||||
project_ids?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
|
||||
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
|
||||
@@ -191,6 +184,7 @@ export const [injectContentInstall, provideContentInstall] = createContext<Conte
|
||||
export function createContentInstall(opts: {
|
||||
router: Router
|
||||
handleError: (err: unknown) => void
|
||||
appEvents: AppEvents
|
||||
}): ContentInstallContext {
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
@@ -394,17 +388,17 @@ export function createContentInstall(opts: {
|
||||
installFailureRevisionByInstance.value = next
|
||||
}
|
||||
|
||||
void instance_listener((event: ContentInstallInstanceEvent) => {
|
||||
opts.appEvents.on('instance', (event) => {
|
||||
if (event.event === 'content_install_finished') {
|
||||
markInstanceContentChanged(event.instance_id)
|
||||
removeInstallingItems(event.instance_id, event.project_ids ?? [])
|
||||
removeInstallingItems(event.instance_id, event.project_ids)
|
||||
} else if (event.event === 'content_install_failed') {
|
||||
removeInstallingItems(event.instance_id, event.project_ids ?? [])
|
||||
removeInstallingItems(event.instance_id, event.project_ids)
|
||||
markInstanceContentInstallFailed(event.instance_id)
|
||||
markInstanceContentChanged(event.instance_id)
|
||||
opts.handleError(event.message ?? 'Failed to install content')
|
||||
opts.handleError(event.message)
|
||||
}
|
||||
}).catch(opts.handleError)
|
||||
})
|
||||
|
||||
let modalRef: ModalRef | null = null
|
||||
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import type { AppEvents } from '@/providers/app-events'
|
||||
|
||||
export interface AppDownloadProgressContext {
|
||||
progress: Ref<number>
|
||||
@@ -10,26 +10,19 @@ export interface AppDownloadProgressContext {
|
||||
|
||||
/* returns unlisten function */
|
||||
export async function subscribeToDownloadProgress(
|
||||
events: AppEvents,
|
||||
context: AppDownloadProgressContext,
|
||||
version: string,
|
||||
) {
|
||||
return await loading_listener(
|
||||
(event: {
|
||||
event: {
|
||||
type: 'launcher_update'
|
||||
version: string
|
||||
return events.on('loading', (event) => {
|
||||
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}`)
|
||||
}
|
||||
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] =
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 type { AppEvents } from '@/providers/app-events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -74,6 +75,7 @@ export function createServerInstall(opts: {
|
||||
router: Router
|
||||
handleError: (err: unknown) => void
|
||||
popupNotificationManager: AbstractPopupNotificationManager
|
||||
appEvents: AppEvents
|
||||
}): ServerInstallContext {
|
||||
const installingServerProjects = ref<string[]>([])
|
||||
|
||||
@@ -134,7 +136,7 @@ export function createServerInstall(opts: {
|
||||
const instanceId = installJobInstanceId(job)
|
||||
if (!instanceId) return null
|
||||
|
||||
await wait_for_install_job(job.job_id)
|
||||
await wait_for_install_job(opts.appEvents, job.job_id)
|
||||
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
|
||||
|
||||
return instanceId
|
||||
@@ -145,7 +147,7 @@ export function createServerInstall(opts: {
|
||||
|
||||
await edit(instance.id, { game_version: targetGameVersion })
|
||||
const job = await install_existing_instance(instance.id, false)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await wait_for_install_job(opts.appEvents, job.job_id)
|
||||
}
|
||||
|
||||
function showModpackInstallSuccess(project: GameInstance, serverAddress: string | null) {
|
||||
@@ -261,7 +263,7 @@ export function createServerInstall(opts: {
|
||||
const instanceId = installJobInstanceId(createJob)
|
||||
if (!instanceId) return
|
||||
|
||||
await wait_for_install_job(createJob.job_id)
|
||||
await wait_for_install_job(opts.appEvents, createJob.job_id)
|
||||
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { AppEvent } from '@/generated/app-events/AppEvent'
|
||||
import { deserialize } from '@/generated/app-events/postcard'
|
||||
|
||||
type WireObject = Record<string, unknown>
|
||||
|
||||
interface WireEnum {
|
||||
tag: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
function wireObject(value: unknown): WireObject {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Invalid Postcard app event object')
|
||||
}
|
||||
return value as WireObject
|
||||
}
|
||||
|
||||
function wireEnum(value: unknown): WireEnum {
|
||||
const event = wireObject(value)
|
||||
if (typeof event.tag !== 'string') {
|
||||
throw new TypeError('Invalid Postcard app event enum')
|
||||
}
|
||||
return event as unknown as WireEnum
|
||||
}
|
||||
|
||||
function taggedObject(value: unknown, tag: string): WireObject {
|
||||
const event = wireEnum(value)
|
||||
return {
|
||||
[tag]: event.tag,
|
||||
...(event.value === undefined ? {} : wireObject(event.value)),
|
||||
}
|
||||
}
|
||||
|
||||
function unitVariant(value: unknown): string {
|
||||
return wireEnum(value).tag
|
||||
}
|
||||
|
||||
function nullable(value: unknown): unknown {
|
||||
return value === undefined ? null : value
|
||||
}
|
||||
|
||||
function number(value: unknown): unknown {
|
||||
return typeof value === 'bigint' ? Number(value) : value
|
||||
}
|
||||
|
||||
function normalizeLoadingPayload(value: unknown): WireObject {
|
||||
const payload = wireObject(value)
|
||||
const event = taggedObject(payload.event, 'type')
|
||||
|
||||
for (const field of ['icon', 'pack_id', 'pack_version']) {
|
||||
if (field in event) event[field] = nullable(event[field])
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
event,
|
||||
fraction: nullable(payload.fraction),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProcessPayload(value: unknown): WireObject {
|
||||
const payload = wireObject(value)
|
||||
return { ...payload, event: unitVariant(payload.event) }
|
||||
}
|
||||
|
||||
function normalizeInstancePayload(value: unknown): WireObject {
|
||||
const payload = wireObject(value)
|
||||
const event = wireEnum(payload.event)
|
||||
return {
|
||||
instance_id: payload.instance_id,
|
||||
event: event.tag,
|
||||
...(event.value === undefined ? {} : wireObject(event.value)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBulkUpdatePayload(value: unknown): WireObject {
|
||||
const payload = wireObject(value)
|
||||
return {
|
||||
...payload,
|
||||
stage: unitVariant(payload.stage),
|
||||
current: number(payload.current),
|
||||
total: number(payload.total),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommandPayload(value: unknown): WireObject {
|
||||
const command = taggedObject(value, 'event')
|
||||
if (command.event === 'LaunchInstance') {
|
||||
command.server = nullable(command.server)
|
||||
command.singleplayer_world = nullable(command.singleplayer_world)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function normalizeFriendPayload(value: unknown): WireObject {
|
||||
const event = taggedObject(value, 'event')
|
||||
if (event.event === 'status_update') {
|
||||
const status = wireObject(event.user_status)
|
||||
event.user_status = {
|
||||
...status,
|
||||
profile_name: nullable(status.profile_name),
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function normalizeLogPayload(value: unknown): WireObject {
|
||||
const payload = wireObject(value)
|
||||
const event = taggedObject(payload.event, 'type')
|
||||
|
||||
if (event.type === 'log4j') {
|
||||
for (const field of [
|
||||
'timestamp_millis',
|
||||
'logger_name',
|
||||
'level',
|
||||
'thread_name',
|
||||
'message',
|
||||
'throwable',
|
||||
]) {
|
||||
event[field] = nullable(event[field])
|
||||
}
|
||||
event.timestamp_millis = number(event.timestamp_millis)
|
||||
}
|
||||
|
||||
return { instance_id: payload.instance_id, ...event }
|
||||
}
|
||||
|
||||
function normalizeInstallProgress(value: unknown): WireObject {
|
||||
const progress = wireObject(value)
|
||||
const secondary = progress.secondary
|
||||
|
||||
return {
|
||||
...progress,
|
||||
current: number(progress.current),
|
||||
total: number(progress.total),
|
||||
secondary:
|
||||
secondary === undefined
|
||||
? undefined
|
||||
: {
|
||||
...wireObject(secondary),
|
||||
current: number(wireObject(secondary).current),
|
||||
total: number(wireObject(secondary).total),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstallDetails(value: unknown): WireObject {
|
||||
const details = taggedObject(value, 'type')
|
||||
|
||||
if (details.type === 'minecraft') details.loader = unitVariant(details.loader)
|
||||
if (details.type === 'java') details.step = unitVariant(details.step)
|
||||
if (details.type === 'import') details.launcher_type = unitVariant(details.launcher_type)
|
||||
if (details.type === 'modpack') {
|
||||
details.project_id = nullable(details.project_id)
|
||||
details.version_id = nullable(details.version_id)
|
||||
details.title = nullable(details.title)
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
function normalizeInstallContext(value: unknown): WireObject {
|
||||
const context = wireObject(value)
|
||||
return {
|
||||
...context,
|
||||
expected_size: context.expected_size === undefined ? undefined : number(context.expected_size),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstallError(value: unknown): WireObject {
|
||||
const error = wireObject(value)
|
||||
return {
|
||||
...error,
|
||||
phase: error.phase === undefined ? undefined : unitVariant(error.phase),
|
||||
reason: error.reason === undefined ? undefined : unitVariant(error.reason),
|
||||
api: error.api === undefined ? undefined : wireObject(error.api),
|
||||
context: error.context === undefined ? undefined : normalizeInstallContext(error.context),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstallJob(value: unknown): WireObject {
|
||||
const job = wireObject(value)
|
||||
const display = job.display === undefined ? null : wireObject(job.display)
|
||||
|
||||
if (display !== null) display.icon = nullable(display.icon)
|
||||
|
||||
return {
|
||||
...job,
|
||||
instance_id: nullable(job.instance_id),
|
||||
kind: unitVariant(job.kind),
|
||||
status: unitVariant(job.status),
|
||||
target: (() => {
|
||||
const target = taggedObject(job.target, 'type')
|
||||
if ('instance_id' in target) target.instance_id = nullable(target.instance_id)
|
||||
return target
|
||||
})(),
|
||||
phase: unitVariant(job.phase),
|
||||
progress: job.progress === undefined ? null : normalizeInstallProgress(job.progress),
|
||||
details: normalizeInstallDetails(job.details),
|
||||
display,
|
||||
error: job.error === undefined ? null : normalizeInstallError(job.error),
|
||||
rollback_error:
|
||||
job.rollback_error === undefined ? null : normalizeInstallError(job.rollback_error),
|
||||
finished: nullable(job.finished),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAppEvent(payload: ArrayBuffer): AppEvent {
|
||||
const result = deserialize('AppEvent', new Uint8Array(payload))
|
||||
if (result.bytes.length !== 0) {
|
||||
throw new TypeError('Postcard app event contained trailing bytes')
|
||||
}
|
||||
const event = result.value as WireEnum
|
||||
|
||||
const decoded = (() => {
|
||||
switch (event.tag) {
|
||||
case 'loading':
|
||||
return { type: event.tag, payload: normalizeLoadingPayload(event.value) }
|
||||
case 'process':
|
||||
return { type: event.tag, payload: normalizeProcessPayload(event.value) }
|
||||
case 'instance':
|
||||
return { type: event.tag, payload: normalizeInstancePayload(event.value) }
|
||||
case 'instance_bulk_update_progress':
|
||||
return { type: event.tag, payload: normalizeBulkUpdatePayload(event.value) }
|
||||
case 'install_job':
|
||||
return { type: event.tag, payload: normalizeInstallJob(event.value) }
|
||||
case 'command':
|
||||
return { type: event.tag, payload: normalizeCommandPayload(event.value) }
|
||||
case 'warning':
|
||||
return { type: event.tag, payload: wireObject(event.value) }
|
||||
case 'friend':
|
||||
return { type: event.tag, payload: normalizeFriendPayload(event.value) }
|
||||
case 'notification':
|
||||
return { type: event.tag, payload: JSON.parse(String(event.value)) as unknown }
|
||||
case 'log':
|
||||
return { type: event.tag, payload: normalizeLogPayload(event.value) }
|
||||
case 'ads_consent_required':
|
||||
return { type: event.tag, payload: event.value }
|
||||
default:
|
||||
throw new TypeError(`Unknown Postcard app event: ${event.tag}`)
|
||||
}
|
||||
})()
|
||||
|
||||
return decoded as AppEvent
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Channel } from '@tauri-apps/api/core'
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import {
|
||||
type AppEventHandler,
|
||||
type AppEvents,
|
||||
type AppEventType,
|
||||
provideAppEvents,
|
||||
} from '@/providers/app-events'
|
||||
import { decodeAppEvent } from '@/providers/setup/app-event-codec'
|
||||
|
||||
type UntypedAppEventHandler = (payload: unknown) => void | Promise<void>
|
||||
|
||||
export function setupAppEventsProvider() {
|
||||
const handlers = new Map<AppEventType, Set<UntypedAppEventHandler>>()
|
||||
|
||||
function on<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>) {
|
||||
let eventHandlers = handlers.get(type)
|
||||
if (!eventHandlers) {
|
||||
eventHandlers = new Set()
|
||||
handlers.set(type, eventHandlers)
|
||||
}
|
||||
|
||||
const untypedHandler = handler as unknown as UntypedAppEventHandler
|
||||
eventHandlers.add(untypedHandler)
|
||||
return () => {
|
||||
eventHandlers.delete(untypedHandler)
|
||||
if (eventHandlers.size === 0) handlers.delete(type)
|
||||
}
|
||||
}
|
||||
|
||||
function once<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>) {
|
||||
let unsubscribe = () => {}
|
||||
unsubscribe = on(type, async (payload) => {
|
||||
unsubscribe()
|
||||
await handler(payload)
|
||||
})
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
const events: AppEvents = { on, once }
|
||||
const channel = new Channel<ArrayBuffer>((payload) => {
|
||||
const event = decodeAppEvent(payload)
|
||||
const eventHandlers = handlers.get(event.type)
|
||||
if (!eventHandlers) return
|
||||
|
||||
for (const handler of [...eventHandlers]) {
|
||||
void Promise.resolve()
|
||||
.then(() => handler(event.payload))
|
||||
.catch((error) => {
|
||||
console.error(`Unhandled ${event.type} app event`, error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
provideAppEvents(events)
|
||||
|
||||
function dispose() {
|
||||
handlers.clear()
|
||||
}
|
||||
|
||||
onScopeDispose(dispose)
|
||||
|
||||
return { channel, events, dispose }
|
||||
}
|
||||
Reference in New Issue
Block a user