feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
@@ -0,0 +1,387 @@
import { UpdatedIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
type PopupNotificationProgressItem,
type PopupNotificationProgressType,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
import { install_job_listener } from '@/helpers/events'
import {
install_job_dismiss,
install_job_list,
install_job_retry,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
const messages = defineMessages({
installs: {
id: 'app.action-bar.installs',
defaultMessage: 'Installs',
},
retry: {
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
},
openInstance: {
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
installFailed: {
id: 'app.action-bar.install.failed',
defaultMessage: 'Install failed',
},
installFailedAppClosed: {
id: 'app.action-bar.install.failed-app-closed',
defaultMessage: 'Installation failed due to app closing.',
},
installFailedNetwork: {
id: 'app.action-bar.install.failed-network',
defaultMessage: 'Installation failed due to a network error.',
},
installFailedUnknown: {
id: 'app.action-bar.install.failed-unknown',
defaultMessage: 'Installation failed due to an unknown error.',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
})
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Preparing instance',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
defaultMessage: 'Resolving content',
},
downloading_pack_file: {
id: 'app.install.phase.downloading_pack_file',
defaultMessage: 'Downloading pack file',
},
reading_pack_manifest: {
id: 'app.install.phase.reading_pack_manifest',
defaultMessage: 'Reading pack manifest',
},
downloading_content: {
id: 'app.install.phase.downloading_content',
defaultMessage: 'Downloading content',
},
extracting_overrides: {
id: 'app.install.phase.extracting_overrides',
defaultMessage: 'Extracting overrides',
},
resolving_minecraft: {
id: 'app.install.phase.resolving_minecraft',
defaultMessage: 'Resolving Minecraft',
},
resolving_loader: {
id: 'app.install.phase.resolving_loader',
defaultMessage: 'Resolving loader',
},
preparing_java: {
id: 'app.install.phase.preparing_java',
defaultMessage: 'Preparing Java',
},
downloading_minecraft: {
id: 'app.install.phase.downloading_minecraft',
defaultMessage: 'Downloading Minecraft',
},
running_loader_processors: {
id: 'app.install.phase.running_loader_processors',
defaultMessage: 'Running loader processors',
},
finalizing: {
id: 'app.install.phase.finalizing',
defaultMessage: 'Finalizing',
},
rolling_back: {
id: 'app.install.phase.rolling_back',
defaultMessage: 'Rolling back',
},
})
const javaStepMessages = defineMessages({
resolving: {
id: 'app.install.phase.preparing_java.resolving',
defaultMessage: 'Preparing Java {version}',
},
fetching_metadata: {
id: 'app.install.phase.preparing_java.fetching-metadata',
defaultMessage: 'Fetching Java {version}',
},
downloading: {
id: 'app.install.phase.preparing_java.downloading',
defaultMessage: 'Downloading Java {version}',
},
extracting: {
id: 'app.install.phase.preparing_java.extracting',
defaultMessage: 'Extracting Java {version}',
},
validating: {
id: 'app.install.phase.preparing_java.validating',
defaultMessage: 'Validating Java {version}',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) return icon
return convertFileSrc(icon)
}
export async function useInstallJobNotifications(opts: {
router: Router
handleError: (err: unknown) => void
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
if (job.details.type === 'instance') return job.details.name
if (job.details.type === 'modpack' && job.details.title) return job.details.title
const instanceId = installJobInstanceId(job)
return (
(instanceId ? instanceNames.value[instanceId] : null) ??
formatMessage(messages.unknownInstance)
)
}
function getText(job: InstallJobSnapshot): string {
if (job.status === 'failed' || job.status === 'interrupted') {
if (job.error?.code === 'interrupted') {
return formatMessage(messages.installFailedAppClosed)
}
if (job.error?.code === 'network_error') {
return formatMessage(messages.installFailedNetwork)
}
return formatMessage(messages.installFailedUnknown)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
version: job.details.major_version,
})
}
return formatMessage(phaseMessages[job.phase])
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!getEffectiveProgress(job)) return undefined
if (
job.phase === 'preparing_java' &&
job.details.type === 'java' &&
job.details.step === 'downloading'
) {
return 'bytes'
}
if (job.phase === 'downloading_content') {
return job.progress?.secondary ? 'bytes' : 'count'
}
if (
job.phase === 'downloading_pack_file' ||
job.phase === 'extracting_overrides' ||
job.phase === 'downloading_minecraft'
) {
return 'bytes'
}
if (job.phase === 'running_loader_processors') {
return 'count'
}
return 'percentage'
}
function getEffectiveProgress(job: InstallJobSnapshot): InstallProgress | null | undefined {
if (job.phase === 'downloading_content' && job.progress?.secondary) {
return job.progress.secondary
}
return job.progress
}
function getProgress(job: InstallJobSnapshot): number {
if (job.status === 'succeeded') return 1
if (job.status === 'failed' || job.status === 'interrupted') return 0
const progress = getEffectiveProgress(job)
if (!progress || progress.total <= 0) return 0
return Math.max(0, Math.min(1, progress.current / progress.total))
}
function isTerminalJob(job: InstallJobSnapshot): boolean {
return job.status === 'failed' || job.status === 'interrupted'
}
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
if (!isTerminalJob(job)) return undefined
return [
{
label: formatMessage(messages.retry),
icon: UpdatedIcon,
color: 'brand',
keepOpen: true,
action: async () => {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
},
},
]
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
jobOrder.set(job.job_id, nextJobOrder++)
}
}
jobs.value = nextJobs
.filter((job) => visibleJobStatuses.has(job.status))
.sort(
(a, b) =>
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
jobs.value.map((job) => {
const progress = getEffectiveProgress(job)
return {
id: job.job_id,
title: getTitle(job),
text: getText(job),
iconUrl: iconUrls.value[job.job_id] ?? null,
progress: getProgress(job),
waiting: !job.progress && ['queued', 'running'].includes(job.status),
showProgress: !isTerminalJob(job),
wrapText: isTerminalJob(job),
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getTerminalButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
}
}),
)
const buttons = computed<PopupNotificationButton[] | undefined>(() => undefined)
async function refreshMetadata(notify = true) {
const request = ++metadataRequest
const sourceJobs = jobs.value
const instanceIds = Array.from(
new Set(
sourceJobs
.map((job) => installJobInstanceId(job))
.filter((instanceId): instanceId is string => !!instanceId),
),
)
const instances = instanceIds.length
? await getInstances(instanceIds).catch((error) => {
opts.handleError(error)
return []
})
: []
if (request !== metadataRequest) {
return
}
const instanceIconUrls = new Map(
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
)
instanceNames.value = Object.fromEntries(
instances.map((instance) => [instance.id, instance.name]),
)
iconUrls.value = Object.fromEntries(
sourceJobs.map((job) => [
job.job_id,
getDisplayIconUrl(job.display?.icon) ??
instanceIconUrls.get(installJobInstanceId(job) ?? '') ??
null,
]),
)
if (notify) {
opts.onChange()
}
}
async function refresh(notify = true) {
const request = ++refreshRequest
const nextJobs = await install_job_list(false).catch((error) => {
opts.handleError(error)
return []
})
if (request !== refreshRequest) {
return
}
setJobs(nextJobs)
await refreshMetadata(false)
if (request !== refreshRequest) {
return
}
if (notify) {
opts.onChange()
}
}
function applyJobUpdate(job: InstallJobSnapshot) {
refreshRequest += 1
const existingJob = jobs.value.find((item) => item.job_id === job.job_id)
if (existingJob && existingJob.modified.localeCompare(job.modified) > 0) {
return
}
setJobs([...jobs.value.filter((item) => item.job_id !== job.job_id), job])
opts.onChange()
void refreshMetadata()
}
await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
return {
active: computed(() => jobs.value.length > 0),
title: computed(() => formatMessage(messages.installs)),
progressItems,
buttons,
refresh,
dispose: () => unlisten(),
}
}
@@ -2,17 +2,17 @@ import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { kill, list as listInstances } from '@/helpers/profile.js'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, getServerLatency } from '@/helpers/worlds'
import { getServerAddress } from '@/store/install.js'
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
interface BrowseServerInstance {
name: string
@@ -65,6 +65,7 @@ const messages = defineMessages({
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
@@ -83,13 +84,11 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
})
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find(
(pack: GameInstance) => pack.linked_data?.project_id === hit.project_id,
)
const inst = packs.find((pack: GameInstance) => pack.link?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
const processes = await get_by_instance_id(inst.id).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
newRunning[hit.project_id] = inst.id
}
}
}
@@ -99,9 +98,9 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
async function handleStopServerProject(projectId: string) {
debugLog('handleStopServerProject', projectId)
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const instanceId = runningServerProjects.value[projectId]
if (!instanceId) return
await kill(instanceId).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
@@ -118,9 +117,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
if (!address) return
if (options.instance.value) {
const instanceId = options.instance.value.id
try {
await add_server_to_profile(
options.instance.value.path,
await add_server_to_instance(
instanceId,
project.name,
address,
'prompt',
@@ -128,6 +128,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
project.minecraft_java_server?.content?.kind,
)
options.newlyInstalled.value.push(project.project_id)
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
} catch (error) {
options.handleError(error)
}
@@ -289,11 +290,11 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
}
process_listener((event: { event: string; profile_path_id: string }) => {
process_listener((event: { event: string; instance_id: string }) => {
debugLog('process event', event)
if (event.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === event.profile_path_id,
([, path]) => path === event.instance_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
@@ -22,8 +22,8 @@ interface InstanceConsoleEntry {
const instances = new Map<string, InstanceConsoleEntry>()
function getOrCreate(profilePathId: string): InstanceConsoleEntry {
let entry = instances.get(profilePathId)
function getOrCreate(instanceId: string): InstanceConsoleEntry {
let entry = instances.get(instanceId)
if (entry) return entry
entry = {
@@ -32,25 +32,25 @@ function getOrCreate(profilePathId: string): InstanceConsoleEntry {
historicalCache: new Map(),
logList: null,
}
instances.set(profilePathId, entry)
instances.set(instanceId, entry)
return entry
}
async function hydrate(profilePathId: string): Promise<void> {
const entry = getOrCreate(profilePathId)
async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
if (entry.liveConsole.output.value.length > 0) return
const buffer = await get_live_log_buffer(profilePathId)
const buffer = await get_live_log_buffer(instanceId)
if (buffer) {
entry.liveConsole.addLegacyLog(buffer)
}
}
async function getHistoricalLogs(profilePathId: string, instancePath: string): Promise<LogEntry[]> {
const entry = getOrCreate(profilePathId)
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
const entry = getOrCreate(instanceId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instancePath, true)
const logs: LogEntry[] = await get_logs(instanceId, true)
entry.logList = logs
for (const log of logs) {
@@ -62,38 +62,38 @@ async function getHistoricalLogs(profilePathId: string, instancePath: string): P
return logs
}
function getHistoricalContent(profilePathId: string, filename: string): string | undefined {
return instances.get(profilePathId)?.historicalCache.get(filename)
function getHistoricalContent(instanceId: string, filename: string): string | undefined {
return instances.get(instanceId)?.historicalCache.get(filename)
}
function invalidate(profilePathId: string): void {
const entry = instances.get(profilePathId)
function invalidate(instanceId: string): void {
const entry = instances.get(instanceId)
if (!entry) return
entry.historicalCache.clear()
entry.logList = null
}
async function clearLive(profilePathId: string): Promise<void> {
const entry = getOrCreate(profilePathId)
async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
entry.liveConsole.clear()
await clear_log_buffer(profilePathId).catch(() => {})
await clear_log_buffer(instanceId).catch(() => {})
}
async function destroy(profilePathId: string): Promise<void> {
instances.delete(profilePathId)
await clear_log_buffer(profilePathId).catch(() => {})
async function destroy(instanceId: string): Promise<void> {
instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
}
export function useInstanceConsole(profilePathId: string) {
const entry = getOrCreate(profilePathId)
export function useInstanceConsole(instanceId: string) {
const entry = getOrCreate(instanceId)
return {
liveConsole: entry.liveConsole,
historicalConsole: entry.historicalConsole,
hydrate: () => hydrate(profilePathId),
getHistoricalLogs: (instancePath: string) => getHistoricalLogs(profilePathId, instancePath),
getHistoricalContent: (filename: string) => getHistoricalContent(profilePathId, filename),
invalidate: () => invalidate(profilePathId),
clearLive: () => clearLive(profilePathId),
destroy: () => destroy(profilePathId),
hydrate: () => hydrate(instanceId),
getHistoricalLogs: () => getHistoricalLogs(instanceId),
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId),
destroy: () => destroy(instanceId),
}
}