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
@@ -1,10 +1,8 @@
import type {
AbstractPopupNotificationManager,
AbstractWebNotificationManager,
CreationFlowContextValue,
CreationFlowModal,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { provide, ref, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { useRouter } from 'vue-router'
@@ -14,29 +12,21 @@ import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlre
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { import_instance } from '@/helpers/import.js'
import {
type CreatePackLocation,
install_create_instance,
install_create_modpack_instance,
install_get_modpack_preview,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
import { create, list } from '@/helpers/profile.js'
import type { InstanceLoader } from '@/helpers/types'
import { useTheming } from '@/store/state'
export function setupCreationModal(
notificationManager: AbstractWebNotificationManager,
popupNotificationManager: AbstractPopupNotificationManager,
) {
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
const { formatMessage } = useVIntl()
const router = useRouter()
const messages = defineMessages({
installingModpackTitle: {
id: 'app.creation-modal.installing-modpack.title',
defaultMessage: 'Installing modpack...',
},
installingModpackDescription: {
id: 'app.creation-modal.installing-modpack.description',
defaultMessage: '{fileName}',
},
})
const themeStore = useTheming()
const installationModal =
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
@@ -65,7 +55,13 @@ export function setupCreationModal(
name: string,
iconUrl?: string,
) {
await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError)
await install_create_modpack_instance({
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: name,
icon_url: iconUrl,
}).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
}
@@ -75,12 +71,12 @@ export function setupCreationModal(
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
const instances = await list().catch(handleError)
const existingInstance = instances?.find((i) => i.linked_data?.project_id === projectId)
const existingInstance = instances?.find((i) => i.link?.project_id === projectId)
if (existingInstance) {
if (existingInstance && !themeStore.getFeatureFlag('skip_non_essential_warnings')) {
pendingModpackCreation.value = { projectId, versionId, name, iconUrl }
installationModal.value?.hide()
modpackAlreadyInstalledModal.value?.show(existingInstance.name, existingInstance.path)
modpackAlreadyInstalledModal.value?.show(existingInstance.name, existingInstance.id)
return
}
}
@@ -108,24 +104,28 @@ export function setupCreationModal(
}
if (config.modpackFilePath.value) {
const waitingNotification = popupNotificationManager.addPopupNotification({
title: formatMessage(messages.installingModpackTitle),
text: formatMessage(messages.installingModpackDescription, {
fileName: config.modpackFilePath.value.split('/').pop() ?? config.modpackFilePath.value,
}),
type: 'info',
autoCloseMs: null,
waiting: true,
})
const location: CreatePackLocation = {
type: 'fromFile',
path: config.modpackFilePath.value,
}
const preview = await install_get_modpack_preview(location)
await create_profile_and_install_from_file(
config.modpackFilePath.value,
(createProfile, fileName) => {
popupNotificationManager.removeNotification(waitingNotification.id)
unknownPackWarningModal.value?.show(createProfile, fileName)
},
).catch(handleError)
popupNotificationManager.removeNotification(waitingNotification.id)
if (preview.unknownFile) {
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath
? splitPath[splitPath.length - 1]
: config.modpackFilePath.value
if (unknownPackWarningModal.value) {
unknownPackWarningModal.value?.show(
() => install_create_modpack_instance(location).then(() => undefined),
fileName,
)
} else {
await install_create_modpack_instance(location)
}
} else {
await install_create_modpack_instance(location)
}
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return
}
@@ -140,14 +140,13 @@ export function setupCreationModal(
const iconPath = config.instanceIconPath.value ?? null
const name = config.instanceName.value.trim() || config.autoInstanceName.value
await create(
await install_create_instance({
name,
config.selectedGameVersion.value!,
loader as InstanceLoader,
gameVersion: config.selectedGameVersion.value!,
loader: loader as InstanceLoader,
loaderVersion,
iconPath,
false,
).catch(handleError)
}).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModal',
@@ -171,9 +170,9 @@ export function setupCreationModal(
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
}
function handleModpackDuplicateGoToInstance(instancePath: string) {
function handleModpackDuplicateGoToInstance(instanceId: string) {
pendingModpackCreation.value = null
router.push(`/instance/${encodeURIComponent(instancePath)}/`)
router.push(`/instance/${encodeURIComponent(instanceId)}/`)
}
function handleBrowseModpacks() {
@@ -1,5 +1,5 @@
import { provideFilePicker } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
@@ -7,21 +7,50 @@ 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)
}
async function createNativeFileFromPath(path: string, fallbackName: string, type?: string) {
const bytes = await invoke<number[]>('plugin:files|file_read_dragged_file', { path })
const name = getFileName(path, fallbackName)
return new File([new Uint8Array(bytes)], name, type ? { type } : undefined)
}
export function setupFilePickerProvider() {
provideFilePicker({
async pickFiles(options) {
const result = await open({
multiple: options?.multiple ?? true,
})
if (!result) return []
const paths = Array.isArray(result) ? result : [result]
return await Promise.all(
paths
.map(getDialogPath)
.filter((path): path is string => !!path)
.map(async (path) => ({
file: await createNativeFileFromPath(path, 'file'),
path,
previewUrl: convertFileSrc(path),
})),
)
},
async pickImage() {
const result = await open({
multiple: false,
filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'svg', 'webp', 'gif'] }],
})
if (!result) return null
const path = result.path ?? result
const path = getDialogPath(result)
if (!path) return null
const file = await createFileFromPath(path, 'icon')
return { file, path, previewUrl: convertFileSrc(path) }
@@ -32,7 +61,7 @@ export function setupFilePickerProvider() {
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
})
if (!result) return null
const path = result.path ?? result
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.