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:
Calum H.
2026-08-13 16:33:06 +00:00
committed by GitHub
parent 5f7493491f
commit 579109a478
93 changed files with 2669 additions and 834 deletions
@@ -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 }
}