feat: add approved modpack sync and StarLight instance modes
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled

This commit is contained in:
2026-09-14 22:59:16 +08:00
parent 5d181ed8cc
commit 171a19985b
29 changed files with 1891 additions and 225 deletions

View File

@ -0,0 +1,214 @@
<template>
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
<div class="flex flex-wrap gap-3">
<ButtonStyled
><button type="button" @click="router.push('/browse/modpack')">
{{ formatMessage(messages.browse) }}
</button></ButtonStyled
>
<ButtonStyled
><button type="button" @click="importLocal">
{{ formatMessage(messages.importLocal) }}
</button></ButtonStyled
>
</div>
</div>
<template v-if="modeQuery.data.value === 'starlight'">
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
<p>{{ formatMessage(messages.description) }}</p>
</div>
<ButtonStyled
><button type="button" :disabled="loading || syncing" @click="load">
{{ formatMessage(messages.refresh) }}
</button></ButtonStyled
>
</div>
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
<p v-if="binding">
{{
formatMessage(messages.bound, {
name: binding.publication.manifest.name,
version: binding.publication.manifest.version,
})
}}
</p>
<p v-if="syncing" role="status">{{ formatMessage(messages.syncing) }}</p>
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
formatMessage(messages.complete, {
version: result.version,
count: result.changedFiles,
size: (result.downloadedBytes / 1048576).toFixed(2),
})
}}
</p>
<details v-if="result.preservedFiles.length">
<summary>
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
</summary>
<ul>
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
</ul>
</details>
</div>
<p v-if="!loading && ready && !catalog.length">{{ formatMessage(messages.empty) }}</p>
<article
v-for="pack in catalog"
:key="pack.packId"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
<h3 class="m-0">{{ pack.manifest.name }}</h3>
<p class="mb-0">
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
{{ pack.manifest.runtime.loader }}
</p>
</div>
<ButtonStyled color="brand"
><button
type="button"
:disabled="
!ready ||
syncing ||
loading ||
(!!binding && binding.publication.packId !== pack.packId)
"
@click="sync(pack.packId)"
>
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
</article>
</section>
</template>
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedCatalog,
type HostedPublication,
hostedSync,
type HostedSyncResult,
} from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string }>()
const modeQuery = useInstanceMode(() => props.instanceId)
const router = useRouter()
const showCreation = inject<
(options: { skipSetupType: boolean; initialMode: 'import'; instanceMode: 'local' }) => void
>('showCreationModalWithOptions')
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import', instanceMode: 'local' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
localPacks: {
id: 'app.instance-mode.local-packs',
defaultMessage:
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
},
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Published modpacks' },
description: {
id: 'app.hosted-packs.description',
defaultMessage:
'Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
syncing: {
id: 'app.hosted-packs.syncing',
defaultMessage: 'Comparing files and synchronizing changes…',
},
complete: {
id: 'app.hosted-packs.complete',
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
},
preserved: {
id: 'app.hosted-packs.preserved',
defaultMessage: 'Preserved {count} locally modified or personal files',
},
empty: {
id: 'app.hosted-packs.empty',
defaultMessage: 'No modpacks have been approved for publication yet.',
},
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Install and enable automatic sync' },
})
const catalog = ref<HostedPublication[]>([])
const binding = ref<HostedBinding | null>(null)
const result = ref<HostedSyncResult | null>(null)
const loading = ref(false)
const syncing = ref(false)
const ready = ref(false)
const error = ref('')
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
const current = ++generation
const instanceId = props.instanceId
loading.value = true
ready.value = false
error.value = ''
try {
const [packs, installed] = await Promise.all([hostedCatalog(), hostedBinding(instanceId)])
if (current !== generation) return
catalog.value = packs
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) error.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync(packId: string) {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
const instanceId = props.instanceId
syncing.value = true
error.value = ''
result.value = null
try {
const completed = await hostedSync(instanceId, packId)
if (props.instanceId !== instanceId) return
result.value = completed
await load()
} catch (cause) {
if (props.instanceId === instanceId) error.value = String(cause)
} finally {
syncing.value = false
}
}
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
error.value = ''
catalog.value = []
binding.value = null
result.value = null
void load()
},
{ immediate: true },
)
</script>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { useId } from 'vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
const group = useId()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
starlightDescription: {
id: 'app.instance-mode.starlight-description',
defaultMessage:
'Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch.',
},
localDescription: {
id: 'app.instance-mode.local-description',
defaultMessage:
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
},
})
</script>
<template>
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
<legend class="mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</legend>
<label
v-for="mode in ['starlight', 'local'] as const"
:key="mode"
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
:class="[
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
disabled ? 'opacity-60' : 'cursor-pointer',
]"
>
<input
type="radio"
class="mt-1"
:name="group"
:value="mode"
:checked="modelValue === mode"
@change="emit('update:modelValue', mode)"
/>
<span class="flex flex-col gap-1"
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
><span class="text-sm text-secondary">{{
formatMessage(
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
)
}}</span></span
>
</label>
</fieldset>
</template>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, watch } from 'vue'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
import type { InstanceMode } from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: { id: 'app.instance-mode.saving', defaultMessage: 'Saving instance type…' },
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})
const saving = computed(
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
)
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
const failure = computed(
() =>
query.error.value ??
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
)
function select(mode: InstanceMode) {
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
save.mutate({ instanceId: props.instanceId, mode })
}
function retry() {
if (props.disabled || saving.value) return
if (query.error.value) void query.refetch()
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
}
watch(
() => props.instanceId,
() => save.reset(),
)
</script>
<template>
<div class="flex flex-col gap-3">
<InstanceModeOptions
:model-value="selection"
:disabled="disabled || !query.data.value || query.isPending.value || saving"
@update:model-value="select"
/>
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
{{ formatMessage(saving ? messages.saving : messages.loading) }}
</p>
<div v-if="failure" role="alert" class="flex items-center gap-3">
<span>{{ String(failure) }}</span
><ButtonStyled
><button
type="button"
:disabled="disabled || query.isFetching.value || saving"
@click="retry"
>
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
</div>
</div>
</template>

View File

@ -0,0 +1,73 @@
<template>
<div
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
:data-packs="selected === 'packs'"
role="group"
:aria-label="formatMessage(messages.label)"
>
<span
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
aria-hidden="true"
/>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'mods'"
@click="select('mods')"
>
{{ formatMessage(messages.mods) }}
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'packs'"
@click="select('packs')"
>
{{ formatMessage(messages.packs) }}
</button>
</div>
<div v-show="selected === 'mods'"><slot /></div>
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
})
const key = 'starlight:instance:mod-management-tab'
function initial(): 'mods' | 'packs' {
try {
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
} catch {
return 'mods'
}
}
const selected = ref(initial())
const openedPacks = ref(selected.value === 'packs')
function select(value: 'mods' | 'packs') {
selected.value = value
if (value === 'packs') openedPacks.value = true
try {
localStorage.setItem(key, value)
} catch {
/* Selection remains usable without storage. */
}
}
</script>
<style scoped>
.selection {
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-packs='true'] .selection {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.selection {
transition: none;
}
}
</style>

View File

@ -17,6 +17,7 @@ import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
@ -379,6 +380,7 @@ const messages = defineMessages({
</script>
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"

View File

@ -207,7 +207,8 @@ export const onboardingMessages = defineMessages({
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
defaultMessage:
'Choose when Starlight checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
@ -239,6 +240,15 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
instanceModeTitle: {
id: 'app.onboarding.instance-mode.title',
defaultMessage: 'Choose your instance type',
},
instanceModeDescription: {
id: 'app.onboarding.instance-mode.description',
defaultMessage:
'Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings.',
},
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
@ -681,6 +691,12 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('create-instance', '/create'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
step(
'creation-flow',
'activate',