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
+50 -112
View File
@@ -13,29 +13,10 @@
/>
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
<ConfirmModalWrapper
ref="removeServerModal"
:title="
formatMessage(messages.removeServerTitle, {
name: serverToRemove?.name ?? formatMessage(messages.thisServer),
})
"
:description="
serverToRemove?.address === serverToRemove?.name
? formatMessage(messages.removeServerDescription, { name: serverToRemove?.name })
: formatMessage(messages.removeServerDescriptionWithAddress, {
name: serverToRemove?.name,
address: serverToRemove?.address,
})
"
:markdown="false"
@proceed="proceedRemoveServer"
/>
<ConfirmModalWrapper
ref="deleteWorldModal"
:title="formatMessage(messages.deleteWorldTitle)"
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
@proceed="proceedDeleteWorld"
<ConfirmRemoveWorldModal
ref="removeWorldModal"
:world="worldToRemove"
@confirm="proceedRemoveWorld"
/>
<ReadyTransition :pending="worldsReadyPending">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
@@ -64,7 +45,7 @@
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
@@ -117,6 +98,7 @@
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@@ -129,7 +111,7 @@
: editServerModal?.show(world)
"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.id, world.path)"
/>
</div>
</div>
@@ -150,7 +132,7 @@
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
@@ -182,32 +164,33 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events'
import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import {
delete_world,
get_profile_protocol_version,
get_instance_protocol_version,
getServerDomainKey,
getWorldIdentifier,
handleDefaultProfileUpdateEvent,
handleDefaultInstanceUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
type InstanceEvent,
normalizeServerAddress,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_profile,
remove_server_from_instance,
resolveManagedServerWorld,
type ServerData,
type ServerWorld,
@@ -220,32 +203,8 @@ import {
} from '@/helpers/worlds.ts'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const messages = defineMessages({
removeServerTitle: {
id: 'app.instance.worlds.remove-server-title',
defaultMessage: 'Are you sure you want to remove {name}?',
},
removeServerDescription: {
id: 'app.instance.worlds.remove-server-description',
defaultMessage:
"'{name}' will be removed from your list, including in-game, and there will be no way to recover it.",
},
removeServerDescriptionWithAddress: {
id: 'app.instance.worlds.remove-server-description-with-address',
defaultMessage:
"'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it.",
},
deleteWorldTitle: {
id: 'app.instance.worlds.delete-world-title',
defaultMessage: 'Are you sure you want to permanently delete this world?',
},
deleteWorldDescription: {
id: 'app.instance.worlds.delete-world-description',
defaultMessage:
"'{name}' will be **permanently deleted**, and there will be no way to recover it.",
},
searchWorldsPlaceholder: {
id: 'app.instance.worlds.search-worlds-placeholder',
defaultMessage: 'Search {count} worlds...',
@@ -266,10 +225,6 @@ const messages = defineMessages({
id: 'app.instance.worlds.no-worlds-description',
defaultMessage: 'Add a server or browse to get started',
},
thisServer: {
id: 'app.instance.worlds.this-server',
defaultMessage: 'this server',
},
vanillaFilter: {
id: 'app.instance.worlds.filter-vanilla',
defaultMessage: 'Vanilla',
@@ -297,11 +252,9 @@ const router = useRouter()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
const serverToRemove = ref<ServerWorld>()
const worldToDelete = ref<SingleplayerWorld>()
const worldToRemove = ref<World | null>(null)
const emit = defineEmits<{
(event: 'play', world: World): void
@@ -357,8 +310,8 @@ const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worldsQuery = useQuery({
queryKey: computed(() => ['worlds', instance.value.path]),
queryFn: () => refreshWorlds(instance.value.path),
queryKey: computed(() => ['worlds', instance.value.id]),
queryFn: () => refreshWorlds(instance.value.id),
staleTime: 30_000,
})
@@ -406,12 +359,12 @@ function isManagedServerWorld(world: World): world is ServerWorld {
async function refreshManagedServerMetadata() {
await ensureManagedServerWorldExists(
instance.value.path,
instance.value.id,
managedServerName.value,
managedServerAddress.value,
)
const projectId = instance.value.linked_data?.project_id
const projectId = instance.value.link?.project_id
if (!projectId) {
managedServerName.value = null
managedServerAddress.value = null
@@ -441,7 +394,7 @@ async function refreshManagedServerMetadata() {
managedServerAddress.value = serverAddress
} catch (err) {
console.error(
`Failed to resolve managed server metadata for profile: ${instance.value.path}`,
`Failed to resolve managed server metadata for instance: ${instance.value.id}`,
err,
)
managedServerName.value = null
@@ -450,22 +403,22 @@ async function refreshManagedServerMetadata() {
}
watch(
() => instance.value.linked_data?.project_id,
() => instance.value.link?.project_id,
async () => {
await refreshManagedServerMetadata()
},
{ immediate: true },
)
let unlistenProfile: (() => void) | null = null
let unlistenInstance: (() => void) | null = null
let worldsTabAlive = true
async function initWorldsTab() {
const [_unlistenProfile, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
const [_unlistenInstance, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
instance_listener(async (e: InstanceEvent) => {
if (e.instance_id !== instance.value.id) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
console.info(`Handling instance event '${e.event}' for instance: ${e.instance_id}`)
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
@@ -474,18 +427,18 @@ async function initWorldsTab() {
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, e)
}),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_instance_protocol_version(instance.value.id).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
if (!worldsTabAlive) {
_unlistenProfile()
_unlistenInstance()
return
}
unlistenProfile = _unlistenProfile
unlistenInstance = _unlistenInstance
protocolVersion.value = resolvedProtocolVersion
gameVersions.value = resolvedGameVersions
}
@@ -508,7 +461,7 @@ async function refreshAllWorlds() {
}
refreshingAll.value = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.path] })
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
refreshingAll.value = false
}
@@ -534,7 +487,7 @@ async function editServer(server: ServerWorld) {
}
async function removeServer(server: ServerWorld) {
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
await remove_server_from_instance(instance.value.id, server.index).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
let serverIdx = 0
for (const w of worlds.value) {
@@ -559,12 +512,12 @@ async function editWorld(path: string, name: string, removeIcon: boolean) {
}
async function deleteWorld(world: SingleplayerWorld) {
await delete_world(instance.value.path, world.path).catch(handleError)
await delete_world(instance.value.id, world.path).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
}
function handleJoinError(err: Error) {
handleSevereError(err, { profilePath: instance.value.path })
handleSevereError(err, { instanceId: instance.value.id })
startingInstance.value = false
worldPlaying.value = undefined
}
@@ -574,7 +527,7 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
const managedProjectId = instance.value.linked_data?.project_id
const managedProjectId = instance.value.link?.project_id
if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError)
trackEvent('InstanceStart', {
@@ -585,14 +538,14 @@ async function joinWorld(world: World) {
startingInstance.value = false
return
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
}
play(world)
startingInstance.value = false
@@ -607,7 +560,7 @@ watch(
setTimeout(async () => {
for (const world of worlds.value) {
if (world.type === 'singleplayer' && world.locked) {
await refreshWorld(worlds.value, instance.value.path, world.path)
await refreshWorld(worlds.value, instance.value.id, world.path)
}
}
}, 1000)
@@ -744,37 +697,22 @@ const filteredWorlds = computed(() =>
const highlightedWorld = ref(route.query.highlight)
function promptToRemoveWorld(world: World): boolean {
worldToRemove.value = world
removeWorldModal.value?.show()
return !!removeWorldModal.value
}
async function proceedRemoveWorld(world: World) {
if (world.type === 'server') {
serverToRemove.value = world
removeServerModal.value?.show()
return !!removeServerModal.value
await removeServer(world)
} else {
worldToDelete.value = world
deleteWorldModal.value?.show()
return !!deleteWorldModal.value
await deleteWorld(world)
}
}
async function proceedRemoveServer() {
if (!serverToRemove.value) {
handleError(new Error(`Error removing server, no server marked for removal.`))
return
}
await removeServer(serverToRemove.value)
serverToRemove.value = undefined
}
async function proceedDeleteWorld() {
if (!worldToDelete.value) {
handleError(new Error(`Error deleting world, no world marked for removal.`))
return
}
await deleteWorld(worldToDelete.value)
worldToDelete.value = undefined
worldToRemove.value = null
}
onBeforeUnmount(() => {
worldsTabAlive = false
unlistenProfile?.()
unlistenInstance?.()
})
</script>