feat: 添加获批整合包同步与 StarLight 实例模式
This commit is contained in:
214
apps/app-frontend/src/components/instance/HostedModpacks.vue
Normal file
214
apps/app-frontend/src/components/instance/HostedModpacks.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
27
apps/app-frontend/src/composables/useInstanceMode.ts
Normal file
27
apps/app-frontend/src/composables/useInstanceMode.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
|
||||
import { getInstanceMode, type InstanceMode, setInstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
export const instanceModeKey = (instanceId: string) => ['instance-mode', instanceId] as const
|
||||
|
||||
export function useInstanceMode(instanceId: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => instanceModeKey(toValue(instanceId))),
|
||||
queryFn: () => getInstanceMode(toValue(instanceId)),
|
||||
enabled: computed(() => !!toValue(instanceId)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetInstanceMode() {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ instanceId, mode }: { instanceId: string; mode: InstanceMode }) =>
|
||||
setInstanceMode(instanceId, mode),
|
||||
onMutate: ({ instanceId }) => client.cancelQueries({ queryKey: instanceModeKey(instanceId) }),
|
||||
onSuccess: (_, { instanceId, mode }) => client.setQueryData(instanceModeKey(instanceId), mode),
|
||||
onError: (_, { instanceId }) =>
|
||||
client.invalidateQueries({ queryKey: instanceModeKey(instanceId) }),
|
||||
})
|
||||
}
|
||||
33
apps/app-frontend/src/helpers/hosted-packs.ts
Normal file
33
apps/app-frontend/src/helpers/hosted-packs.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type InstanceMode = 'starlight' | 'local'
|
||||
export const getInstanceMode = (instanceId: string) =>
|
||||
invoke<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
|
||||
export const setInstanceMode = (instanceId: string, mode: InstanceMode) =>
|
||||
invoke<void>('plugin:install|hosted_set_instance_mode', { instanceId, mode })
|
||||
|
||||
export interface HostedPublication {
|
||||
packId: string
|
||||
releaseId: number
|
||||
manifest: {
|
||||
name: string
|
||||
version: string
|
||||
format: string
|
||||
runtime: { gameVersion: string; loader: string; loaderVersion: string | null }
|
||||
files: { path: string; size: number; sha256: string }[]
|
||||
}
|
||||
}
|
||||
export interface HostedBinding {
|
||||
publication: HostedPublication
|
||||
}
|
||||
export interface HostedSyncResult {
|
||||
version: string
|
||||
downloadedBytes: number
|
||||
changedFiles: number
|
||||
preservedFiles: string[]
|
||||
}
|
||||
export const hostedCatalog = () => invoke<HostedPublication[]>('plugin:install|hosted_catalog')
|
||||
export const hostedBinding = (instanceId: string) =>
|
||||
invoke<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||
export const hostedSync = (instanceId: string, packId: string) =>
|
||||
invoke<HostedSyncResult>('plugin:install|hosted_sync', { instanceId, packId })
|
||||
@ -32,6 +32,7 @@ export interface InstallModpackPreview {
|
||||
}
|
||||
|
||||
export interface InstallCreateInstanceRequest {
|
||||
instanceMode?: 'starlight' | 'local'
|
||||
name: string
|
||||
gameVersion: string
|
||||
loader: InstanceLoader
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "Choose your instance type" },
|
||||
"app.onboarding.instance-mode.description": { "message": "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." },
|
||||
"app.instance-mode.title": { "message": "Instance type" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight instance" },
|
||||
"app.instance-mode.local": { "message": "Local instance" },
|
||||
"app.instance-mode.starlight-description": { "message": "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." },
|
||||
"app.instance-mode.local-description": { "message": "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." },
|
||||
"app.instance-mode.saving": { "message": "Saving instance type…" },
|
||||
"app.instance-mode.loading": { "message": "Loading instance type…" },
|
||||
"app.instance-mode.retry": { "message": "Retry" },
|
||||
"app.instance-mode.local-packs-title": { "message": "Local modpacks" },
|
||||
"app.instance-mode.local-packs": { "message": "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." },
|
||||
"app.instance-mode.browse": { "message": "Browse modpacks" },
|
||||
"app.instance-mode.import": { "message": "Import as a local instance" },
|
||||
"app.hosted-packs.title": {"message": "Published modpacks"},
|
||||
"app.hosted-packs.description": {"message": "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."},
|
||||
"app.hosted-packs.refresh": {"message": "Refresh"},
|
||||
"app.hosted-packs.loading": {"message": "Loading published modpacks…"},
|
||||
"app.hosted-packs.bound": {"message": "Installed: {name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "Comparing files and synchronizing changes…"},
|
||||
"app.hosted-packs.complete": {"message": "Synced to {version}. Changed {count} files; downloaded {size} MiB."},
|
||||
"app.hosted-packs.preserved": {"message": "Preserved {count} locally modified or personal files"},
|
||||
"app.hosted-packs.empty": {"message": "No modpacks have been approved for publication yet."},
|
||||
"app.hosted-packs.update": {"message": "Synchronize now"},
|
||||
"app.hosted-packs.install": {"message": "Install and enable automatic sync"},
|
||||
"app.hosted-packs.management": {"message": "Content management"},
|
||||
"app.hosted-packs.mods": {"message": "Manage mods"},
|
||||
"app.hosted-packs.packs": {"message": "Manage modpacks"},
|
||||
"app.home.luck.title": { "message": "Daily luck index" },
|
||||
"app.home.luck.get": { "message": "Get with one click" },
|
||||
"app.home.luck.login-to-get": { "message": "Sign in to get it" },
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
|
||||
"app.onboarding.instance-mode.description": { "message": "游玩 StarLight 服务器请选择 StarLight 实例,它会自动同步管理员批准的更新。本地实例跳过 StarLight 同步,启动更快,可自选整合包,适合第三方服务器与单人游玩。之后也可以在实例设置中切换。" },
|
||||
"app.instance-mode.title": { "message": "实例类型" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight 实例" },
|
||||
"app.instance-mode.local": { "message": "本地实例" },
|
||||
"app.instance-mode.starlight-description": { "message": "自动同步 StarLight 服务器推送的变更,游玩 StarLight 服务器必选。首次启动前,请在 Mod 管理中选择并安装官方整合包。" },
|
||||
"app.instance-mode.local-description": { "message": "不会同步 StarLight 服务器的变更。跳过同步检查,启动更快,可自选整合包安装,更适合第三方服务器与本地个人游玩。" },
|
||||
"app.instance-mode.saving": { "message": "正在保存实例类型…" },
|
||||
"app.instance-mode.loading": { "message": "正在读取实例类型…" },
|
||||
"app.instance-mode.retry": { "message": "重试" },
|
||||
"app.instance-mode.local-packs-title": { "message": "本地整合包" },
|
||||
"app.instance-mode.local-packs": { "message": "可以自选整合包并安装为本地实例。切换为本地后保留现有游戏文件和存档,之后不再同步 StarLight 的变更。" },
|
||||
"app.instance-mode.browse": { "message": "浏览整合包" },
|
||||
"app.instance-mode.import": { "message": "导入为本地实例" },
|
||||
"app.hosted-packs.management": {"message": "内容管理"},
|
||||
"app.hosted-packs.mods": {"message": "管理 Mod"},
|
||||
"app.hosted-packs.packs": {"message": "管理整合包"},
|
||||
"app.hosted-packs.title": {"message": "已发布整合包"},
|
||||
"app.hosted-packs.description": {"message": "将审核通过的整合包安装到当前实例。此后联网启动时自动下载变动文件。每个整合包请使用单独的空实例;存档和个人设置会保留。"},
|
||||
"app.hosted-packs.refresh": {"message": "刷新"},
|
||||
"app.hosted-packs.loading": {"message": "正在获取已发布整合包…"},
|
||||
"app.hosted-packs.bound": {"message": "已安装:{name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "正在比对文件并同步变动…"},
|
||||
"app.hosted-packs.complete": {"message": "已同步至 {version},变更 {count} 个文件,下载 {size} MiB。"},
|
||||
"app.hosted-packs.preserved": {"message": "保留了 {count} 个本地修改或个人文件"},
|
||||
"app.hosted-packs.empty": {"message": "暂无审核通过的整合包。"},
|
||||
"app.hosted-packs.update": {"message": "立即同步"},
|
||||
"app.hosted-packs.install": {"message": "安装并启用自动同步"},
|
||||
"app.home.luck.title": { "message": "每日幸运指数" },
|
||||
"app.home.luck.get": { "message": "一键获取" },
|
||||
"app.home.luck.login-to-get": { "message": "登录后获取" },
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
|
||||
"app.onboarding.instance-mode.description": { "message": "遊玩 StarLight 伺服器請選擇 StarLight 實例,它會自動同步管理員核准的更新。本地實例跳過 StarLight 同步,啟動更快,可自行選擇整合包,適合第三方伺服器與單人遊玩。之後也可以在實例設定中切換。" },
|
||||
"app.instance-mode.title": { "message": "實例類型" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight 實例" },
|
||||
"app.instance-mode.local": { "message": "本機實例" },
|
||||
"app.instance-mode.starlight-description": { "message": "自動同步 StarLight 伺服器推送的變更,遊玩 StarLight 伺服器必選。首次啟動前,請在 Mod 管理中選擇並安裝官方整合包。" },
|
||||
"app.instance-mode.local-description": { "message": "不會同步 StarLight 伺服器的變更。略過同步檢查,啟動更快,可自行選擇整合包安裝,更適合第三方伺服器與本機個人遊玩。" },
|
||||
"app.instance-mode.saving": { "message": "正在儲存實例類型…" },
|
||||
"app.instance-mode.loading": { "message": "正在讀取實例類型…" },
|
||||
"app.instance-mode.retry": { "message": "重試" },
|
||||
"app.instance-mode.local-packs-title": { "message": "本機整合包" },
|
||||
"app.instance-mode.local-packs": { "message": "可以自行選擇整合包並安裝為本機實例。切換為本機後保留現有遊戲檔案和存檔,之後不再同步 StarLight 的變更。" },
|
||||
"app.instance-mode.browse": { "message": "瀏覽整合包" },
|
||||
"app.instance-mode.import": { "message": "匯入為本機實例" },
|
||||
"app.hosted-packs.management": {"message": "內容管理"},
|
||||
"app.hosted-packs.mods": {"message": "管理 Mod"},
|
||||
"app.hosted-packs.packs": {"message": "管理整合包"},
|
||||
"app.hosted-packs.title": {"message": "已發布整合包"},
|
||||
"app.hosted-packs.description": {"message": "將審核通過的整合包安裝到目前實例。之後連線啟動時自動下載變動檔案。每個整合包請使用獨立的空實例;存檔和個人設定會保留。"},
|
||||
"app.hosted-packs.refresh": {"message": "重新整理"},
|
||||
"app.hosted-packs.loading": {"message": "正在取得已發布整合包…"},
|
||||
"app.hosted-packs.bound": {"message": "已安裝:{name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "正在比對檔案並同步變動…"},
|
||||
"app.hosted-packs.complete": {"message": "已同步至 {version},變更 {count} 個檔案,下載 {size} MiB。"},
|
||||
"app.hosted-packs.preserved": {"message": "保留了 {count} 個本機修改或個人檔案"},
|
||||
"app.hosted-packs.empty": {"message": "尚無審核通過的整合包。"},
|
||||
"app.hosted-packs.update": {"message": "立即同步"},
|
||||
"app.hosted-packs.install": {"message": "安裝並啟用自動同步"},
|
||||
"app.home.luck.title": { "message": "每日幸運指數" },
|
||||
"app.home.luck.get": { "message": "一鍵獲取" },
|
||||
"app.home.luck.login-to-get": { "message": "登入後獲取" },
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { inject } from 'vue'
|
||||
import { inject, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const instanceMode = ref<InstanceMode>('local')
|
||||
|
||||
const showModal = inject<
|
||||
(options?: {
|
||||
skipSetupType?: boolean
|
||||
initialMode?: 'custom' | 'import'
|
||||
instanceMode?: InstanceMode
|
||||
onBack?: () => void
|
||||
}) => void
|
||||
>('showCreationModalWithOptions')
|
||||
@ -60,6 +64,7 @@ function handleStartFresh() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'custom',
|
||||
instanceMode: instanceMode.value,
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
@ -68,14 +73,15 @@ function handleImportExisting() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'import',
|
||||
instanceMode: 'local',
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-2xl flex-col gap-6">
|
||||
<div class="flex h-full w-full flex-col items-center overflow-y-auto p-6">
|
||||
<div class="my-auto flex w-full max-w-2xl shrink-0 flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
@ -85,6 +91,7 @@ function handleImportExisting() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InstanceModeOptions v-model="instanceMode" data-onboarding-id="creation-instance-mode" />
|
||||
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-custom"
|
||||
@ -97,6 +104,7 @@ function handleImportExisting() {
|
||||
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-import"
|
||||
v-if="instanceMode === 'local'"
|
||||
:icon="FolderOpenIcon"
|
||||
:title="formatMessage(messages.importTitle)"
|
||||
:description="formatMessage(messages.importDescription)"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<template>
|
||||
<ModManagementSwitch>
|
||||
<template #packs><HostedModpacks :instance-id="instance.id" /></template>
|
||||
<ReadyTransition :pending="loading">
|
||||
<template #pending>
|
||||
<LoadingIndicator class="pt-4" />
|
||||
@ -193,7 +195,9 @@
|
||||
:is-app="true"
|
||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||
:project-icon-url="
|
||||
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
|
||||
updatingModpack
|
||||
? displayedModpackProject?.icon_url
|
||||
: updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
@ -210,6 +214,7 @@
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
</ModManagementSwitch>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -259,6 +264,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DependencyGraphModal from '@/components/instance/dependencies/DependencyGraphModal.vue'
|
||||
import HostedModpacks from '@/components/instance/HostedModpacks.vue'
|
||||
import ModManagementSwitch from '@/components/instance/ModManagementSwitch.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleDependenciesModal.vue'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
@ -1401,8 +1408,7 @@ async function getUpdaterProjectVersions(
|
||||
|
||||
if (!versions) {
|
||||
versions = (await get_project_versions(projectId).catch(() => null)) as
|
||||
| Labrinth.Versions.v2.Version[]
|
||||
| null
|
||||
Labrinth.Versions.v2.Version[] | null
|
||||
}
|
||||
|
||||
if (!versions && fetchError) {
|
||||
@ -1636,7 +1642,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
|
||||
file_name: newFileName,
|
||||
enabled: actualEnabled,
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
|
||||
file_path: originalFilePath,
|
||||
@ -1805,7 +1810,6 @@ async function removeMod(mod: ContentItem) {
|
||||
await remove_content_entry(props.instance.id, contentId)
|
||||
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
@ -1966,7 +1970,6 @@ async function updateProject(mod: ContentItem) {
|
||||
|
||||
try {
|
||||
await update_content_entry(props.instance.id, contentId)
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
throw err
|
||||
@ -1984,7 +1987,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
|
||||
try {
|
||||
await switch_content_entry_version(props.instance.id, contentId, version.id)
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
|
||||
@ -112,16 +112,20 @@ export function setupCreationModal(
|
||||
}
|
||||
|
||||
provide('showCreationModal', () => {
|
||||
creationInstanceMode.value = 'local'
|
||||
installationModal.value?.show()
|
||||
})
|
||||
const creationInstanceMode = ref<'starlight' | 'local'>('local')
|
||||
|
||||
provide(
|
||||
'showCreationModalWithOptions',
|
||||
(options?: {
|
||||
skipSetupType?: boolean
|
||||
initialMode?: 'custom' | 'import'
|
||||
instanceMode?: 'starlight' | 'local'
|
||||
onBack?: () => void
|
||||
}) => {
|
||||
creationInstanceMode.value = options?.instanceMode ?? 'local'
|
||||
installationModal.value?.show(options)
|
||||
},
|
||||
)
|
||||
@ -145,6 +149,7 @@ export function setupCreationModal(
|
||||
}
|
||||
|
||||
async function handleCreate(config: CreationFlowContextValue) {
|
||||
const instanceMode = creationInstanceMode.value
|
||||
try {
|
||||
installationModal.value?.hide()
|
||||
|
||||
@ -262,7 +267,8 @@ export function setupCreationModal(
|
||||
: gameRoot
|
||||
: null
|
||||
|
||||
await install_create_instance({
|
||||
const job = await install_create_instance({
|
||||
instanceMode,
|
||||
name,
|
||||
gameVersion: config.selectedGameVersion.value!,
|
||||
loader: loader as InstanceLoader,
|
||||
@ -276,7 +282,14 @@ export function setupCreationModal(
|
||||
iconPath,
|
||||
gameDirOverride,
|
||||
}).catch(handleError)
|
||||
|
||||
if (instanceMode === 'starlight' && job?.instance_id) {
|
||||
try {
|
||||
localStorage.setItem('starlight:instance:mod-management-tab', 'packs')
|
||||
} catch {
|
||||
/* Optional navigation preference. */
|
||||
}
|
||||
await router.push(`/instance/${encodeURIComponent(job.instance_id)}/`)
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
|
||||
@ -360,6 +360,11 @@ fn main() {
|
||||
"install_cancel_import_plan",
|
||||
"install_duplicate_instance",
|
||||
"install_existing_instance",
|
||||
"hosted_catalog",
|
||||
"hosted_binding",
|
||||
"hosted_sync",
|
||||
"hosted_instance_mode",
|
||||
"hosted_set_instance_mode",
|
||||
"install_pack_to_existing_instance",
|
||||
"install_job_list",
|
||||
"install_job_get",
|
||||
|
||||
@ -14,6 +14,11 @@ use uuid::Uuid;
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("install")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
hosted_catalog,
|
||||
hosted_binding,
|
||||
hosted_sync,
|
||||
hosted_instance_mode,
|
||||
hosted_set_instance_mode,
|
||||
install_get_modpack_preview,
|
||||
install_create_instance,
|
||||
install_create_modpack_instance,
|
||||
@ -48,9 +53,47 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_catalog() -> Result<Vec<theseus::pack::hosted::Publication>>
|
||||
{
|
||||
Ok(theseus::pack::hosted::catalog().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_instance_mode(
|
||||
instance_id: String,
|
||||
) -> Result<theseus::data::InstanceMode> {
|
||||
Ok(theseus::pack::hosted::instance_mode(&instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_set_instance_mode(
|
||||
instance_id: String,
|
||||
mode: theseus::data::InstanceMode,
|
||||
) -> Result<()> {
|
||||
Ok(theseus::pack::hosted::set_instance_mode(&instance_id, mode).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_binding(
|
||||
instance_id: String,
|
||||
) -> Result<Option<theseus::pack::hosted::Binding>> {
|
||||
Ok(theseus::pack::hosted::binding(&instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_sync(
|
||||
instance_id: String,
|
||||
pack_id: String,
|
||||
) -> Result<theseus::pack::hosted::SyncResult> {
|
||||
Ok(theseus::pack::hosted::synchronize(&instance_id, &pack_id).await?)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallCreateInstanceRequest {
|
||||
#[serde(default)]
|
||||
pub instance_mode: theseus::data::InstanceMode,
|
||||
pub name: String,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
@ -97,7 +140,7 @@ pub async fn install_get_modpack_preview(
|
||||
pub async fn install_create_instance(
|
||||
request: InstallCreateInstanceRequest,
|
||||
) -> Result<InstallJobSnapshot> {
|
||||
Ok(theseus::install::create_instance_with_adjuncts(
|
||||
let job = theseus::install::create_instance_with_adjuncts(
|
||||
request.name.trim().to_string(),
|
||||
request.game_version,
|
||||
request.loader,
|
||||
@ -110,7 +153,23 @@ pub async fn install_create_instance(
|
||||
},
|
||||
request.game_dir_override,
|
||||
)
|
||||
.await?)
|
||||
.await?;
|
||||
if let Some(id) = &job.instance_id {
|
||||
theseus::instance::edit(
|
||||
id,
|
||||
theseus::data::EditInstance {
|
||||
launch_overrides: Some(
|
||||
theseus::data::InstanceLaunchOverridesPatch {
|
||||
instance_mode: Some(request.instance_mode),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@ -462,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
|
||||
})
|
||||
.transpose()?,
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
instance_mode: None,
|
||||
java_path: edit_instance.java_path,
|
||||
extra_launch_args: edit_instance.extra_launch_args,
|
||||
custom_env_vars: edit_instance.custom_env_vars,
|
||||
|
||||
@ -75,6 +75,8 @@ async fn run_with_extra_launch_args_inner(
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
gc_intent: Option<GcLaunchIntent>,
|
||||
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
||||
let _hosted_guard =
|
||||
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
|
||||
let state = State::get().await?;
|
||||
let launch_preparation_timeout =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
|
||||
@ -51,7 +51,7 @@ pub mod data {
|
||||
InstanceContentSnapshot, InstanceContentSnapshotItem,
|
||||
InstanceContentWarning, InstanceInstallCandidate,
|
||||
InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink,
|
||||
InstanceMetadata, InstancePostUpgradeNotice,
|
||||
InstanceMetadata, InstanceMode, InstancePostUpgradeNotice,
|
||||
InstancePostUpgradeWarning, InstanceUpgradeAction,
|
||||
InstanceUpgradeDependencyChange, InstanceUpgradeDependencyChangeKind,
|
||||
InstanceUpgradeEnvironment, InstanceUpgradeFixedConstraint,
|
||||
|
||||
884
packages/app-lib/src/api/pack/hosted.rs
Normal file
884
packages/app-lib/src/api/pack/hosted.rs
Normal file
@ -0,0 +1,884 @@
|
||||
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
||||
use crate::{
|
||||
State,
|
||||
state::{
|
||||
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
|
||||
InstanceLaunchOverridesPatch, InstanceMode, ModLoader,
|
||||
},
|
||||
util::fetch::{
|
||||
DownloadRequest, Integrity, ResourceClass, download_to_path, fetch_json,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
use tokio::{
|
||||
io::AsyncReadExt,
|
||||
sync::{Mutex, OwnedMutexGuard},
|
||||
};
|
||||
|
||||
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
|
||||
const BINDING: &str = ".starlight-pack.json";
|
||||
const JOURNAL: &str = ".starlight-pack-pending.json";
|
||||
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
|
||||
fn instance_gate(instance_id: &str) -> Arc<Mutex<()>> {
|
||||
GATES.entry(instance_id.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Runtime {
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
pub loader_version: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackFile {
|
||||
pub path: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
#[serde(default)]
|
||||
pub preserve: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct External {
|
||||
pub project_id: u32,
|
||||
pub file_id: u32,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Manifest {
|
||||
pub schema_version: u32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub format: String,
|
||||
pub runtime: Runtime,
|
||||
pub files: Vec<PackFile>,
|
||||
#[serde(default)]
|
||||
pub external: Vec<External>,
|
||||
#[serde(default)]
|
||||
pub java_arguments: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub game_arguments: Vec<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Publication {
|
||||
pub pack_id: String,
|
||||
pub release_id: u64,
|
||||
pub manifest: Manifest,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Binding {
|
||||
pub publication: Publication,
|
||||
pub files: Vec<PackFile>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Response<T> {
|
||||
payload: T,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncResult {
|
||||
pub instance_id: String,
|
||||
pub version: String,
|
||||
pub downloaded_bytes: u64,
|
||||
pub changed_files: usize,
|
||||
pub preserved_files: Vec<String>,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> crate::Error {
|
||||
crate::ErrorKind::InputError(message.into()).into()
|
||||
}
|
||||
fn safe_path(path: &str) -> crate::Result<()> {
|
||||
if path.is_empty()
|
||||
|| path.len() > 1024
|
||||
|| path.contains('\\')
|
||||
|| path.to_ascii_lowercase().starts_with(".starlight-")
|
||||
|| path.split('/').any(|part| {
|
||||
let stem =
|
||||
part.split('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
part.is_empty()
|
||||
|| part == "."
|
||||
|| part == ".."
|
||||
|| part.ends_with(['.', ' '])
|
||||
|| part
|
||||
.chars()
|
||||
.any(|c| c.is_control() || ":*?\"<>|".contains(c))
|
||||
|| matches!(stem.as_str(), "con" | "prn" | "aux" | "nul")
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("com") || stem.starts_with("lpt"))
|
||||
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
|
||||
})
|
||||
{
|
||||
return Err(invalid(format!("Unsafe modpack path: {path}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate(files: &[PackFile]) -> crate::Result<()> {
|
||||
if files.iter().map(|f| u128::from(f.size)).sum::<u128>()
|
||||
> 16 * 1024 * 1024 * 1024
|
||||
|| files.len() > 100_000
|
||||
{
|
||||
return Err(invalid("Modpack has too many files"));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for f in files {
|
||||
safe_path(&f.path)?;
|
||||
if f.sha256.len() != 64
|
||||
|| !f
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
|| f.size > 2 * 1024 * 1024 * 1024
|
||||
|| !paths.insert(f.path.to_lowercase())
|
||||
{
|
||||
return Err(invalid(
|
||||
"Invalid hash, size or duplicate modpack path",
|
||||
));
|
||||
}
|
||||
}
|
||||
for f in files {
|
||||
let mut p = Path::new(&f.path).parent();
|
||||
while let Some(parent) = p {
|
||||
if paths.contains(
|
||||
&parent.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
) {
|
||||
return Err(invalid("Modpack file/directory conflict"));
|
||||
}
|
||||
p = parent.parent();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
|
||||
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in relative.split('/') {
|
||||
path.push(part);
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if meta.file_attributes() & 0x400 != 0 {
|
||||
return Err(invalid(
|
||||
"Modpack path is a junction or symlink",
|
||||
));
|
||||
}
|
||||
}
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(invalid("Modpack path is a symlink"));
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
async fn hash(path: &Path) -> crate::Result<Option<String>> {
|
||||
let mut input = match tokio::fs::File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = vec![0; 64 * 1024];
|
||||
loop {
|
||||
let n = input.read(&mut buffer).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..n]);
|
||||
}
|
||||
Ok(Some(format!("{:x}", digest.finalize())))
|
||||
}
|
||||
async fn read_json<T: serde::de::DeserializeOwned>(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
) -> crate::Result<Option<T>> {
|
||||
match tokio::fs::read(target(root, name)?).await {
|
||||
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
fn write_json<T: Serialize>(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
value: &T,
|
||||
) -> crate::Result<()> {
|
||||
use std::io::Write;
|
||||
let dest = target(root, name)?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(root)?;
|
||||
file.write_all(&serde_json::to_vec(value)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(dest).map_err(|e| e.error)?;
|
||||
Ok(())
|
||||
}
|
||||
async fn request<T: serde::de::DeserializeOwned>(
|
||||
suffix: &str,
|
||||
) -> crate::Result<T> {
|
||||
let state = State::get().await?;
|
||||
let response: Response<T> = fetch_json(
|
||||
reqwest::Method::GET,
|
||||
&format!("{API}{suffix}"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.payload)
|
||||
}
|
||||
pub async fn catalog() -> crate::Result<Vec<Publication>> {
|
||||
request("").await
|
||||
}
|
||||
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
|
||||
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
|
||||
.await
|
||||
}
|
||||
pub async fn java_arguments(instance_id: &str) -> crate::Result<Vec<String>> {
|
||||
Ok(binding(instance_id)
|
||||
.await?
|
||||
.map(|b| b.publication.manifest.java_arguments)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
pub async fn game_arguments(instance_id: &str) -> crate::Result<Vec<String>> {
|
||||
Ok(binding(instance_id)
|
||||
.await?
|
||||
.map(|b| b.publication.manifest.game_arguments)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Action {
|
||||
path: String,
|
||||
old_hash: Option<String>,
|
||||
next_hash: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Journal {
|
||||
backup: String,
|
||||
actions: Vec<Action>,
|
||||
previous: Option<Binding>,
|
||||
metadata: crate::state::InstanceMetadata,
|
||||
}
|
||||
|
||||
async fn apply_files(
|
||||
root: &Path,
|
||||
cache: &Path,
|
||||
backup_dir: &str,
|
||||
actions: &[Action],
|
||||
) -> crate::Result<()> {
|
||||
for action in actions {
|
||||
let live = target(root, &action.path)?;
|
||||
if hash(&live).await? != action.old_hash {
|
||||
return Err(invalid(format!(
|
||||
"File changed during sync: {}",
|
||||
action.path
|
||||
)));
|
||||
}
|
||||
if action.old_hash.is_some() {
|
||||
let backup =
|
||||
target(root, &format!("{}/{}", backup_dir, action.path))?;
|
||||
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
|
||||
tokio::fs::rename(&live, backup).await?;
|
||||
}
|
||||
if let Some(next) = &action.next_hash {
|
||||
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
|
||||
let staged =
|
||||
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
|
||||
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
|
||||
staged.as_file().sync_all()?;
|
||||
staged.persist(&live).map_err(|e| e.error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_files(
|
||||
root: &Path,
|
||||
backup_dir: &str,
|
||||
actions: &[Action],
|
||||
) -> crate::Result<()> {
|
||||
// A durable journal is written before touching live files; it also recovers interrupted runs.
|
||||
if !backup_dir.starts_with(".starlight-pack-backup-")
|
||||
|| backup_dir.contains(['/', '\\'])
|
||||
{
|
||||
return Err(invalid("Invalid modpack recovery directory"));
|
||||
}
|
||||
for action in actions.iter().rev() {
|
||||
safe_path(&action.path)?;
|
||||
let live = target(root, &action.path)?;
|
||||
let backup = target(root, &format!("{}/{}", backup_dir, action.path))?;
|
||||
if backup.is_file() {
|
||||
if live.exists() {
|
||||
tokio::fs::remove_file(&live).await?;
|
||||
}
|
||||
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
|
||||
tokio::fs::rename(backup, live).await?;
|
||||
} else if action.old_hash.is_none()
|
||||
&& action.next_hash.is_some()
|
||||
&& hash(&live).await? == action.next_hash
|
||||
{
|
||||
tokio::fs::remove_file(live).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore(root: &Path, journal: &Journal) -> crate::Result<()> {
|
||||
restore_files(root, &journal.backup, &journal.actions).await?;
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::restore_instance_metadata(
|
||||
&journal.metadata,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(old) = &journal.previous {
|
||||
write_json(root, BINDING, old)?;
|
||||
} else if target(root, BINDING)?.exists() {
|
||||
tokio::fs::remove_file(target(root, BINDING)?).await?;
|
||||
}
|
||||
tokio::fs::remove_file(target(root, JOURNAL)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn ensure_idle(instance_id: &str) -> crate::Result<()> {
|
||||
if !crate::process::get_by_instance_id(instance_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Err(invalid(
|
||||
"Close Minecraft before synchronizing its modpack",
|
||||
));
|
||||
}
|
||||
if crate::install::list_jobs(false).await?.iter().any(|job| {
|
||||
job.instance_id.as_deref() == Some(instance_id)
|
||||
&& matches!(
|
||||
job.status,
|
||||
crate::install::InstallJobStatus::Running
|
||||
| crate::install::InstallJobStatus::Canceling
|
||||
| crate::install::InstallJobStatus::Queued
|
||||
| crate::install::InstallJobStatus::WaitingForUser
|
||||
)
|
||||
}) {
|
||||
return Err(invalid("An installation is already using this instance"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn recover(instance_id: &str) -> crate::Result<()> {
|
||||
let root = crate::instance::get_full_path(instance_id).await?;
|
||||
if let Some(journal) = read_json::<Journal>(&root, JOURNAL).await? {
|
||||
if journal.metadata.instance.id != instance_id {
|
||||
return Err(invalid(
|
||||
"Recovery journal belongs to another instance",
|
||||
));
|
||||
}
|
||||
ensure_idle(instance_id).await?;
|
||||
restore(&root, &journal).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn synchronize(
|
||||
instance_id: &str,
|
||||
pack_id: &str,
|
||||
) -> crate::Result<SyncResult> {
|
||||
let _guard = instance_gate(instance_id).lock_owned().await;
|
||||
if instance_mode(instance_id).await? != InstanceMode::StarLight {
|
||||
return Err(invalid(
|
||||
"请先将实例类型设为 StarLight 实例,再同步官方整合包",
|
||||
));
|
||||
}
|
||||
synchronize_locked(instance_id, pack_id).await
|
||||
}
|
||||
|
||||
fn effective_mode(
|
||||
explicit: Option<InstanceMode>,
|
||||
has_binding: bool,
|
||||
) -> InstanceMode {
|
||||
explicit.unwrap_or(if has_binding {
|
||||
InstanceMode::StarLight
|
||||
} else {
|
||||
InstanceMode::Local
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn instance_mode(instance_id: &str) -> crate::Result<InstanceMode> {
|
||||
let metadata = crate::instance::get(instance_id)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("Unknown instance"))?;
|
||||
if let Some(mode) = metadata.launch_overrides.instance_mode {
|
||||
return Ok(mode);
|
||||
}
|
||||
Ok(effective_mode(None, binding(instance_id).await?.is_some()))
|
||||
}
|
||||
|
||||
pub async fn set_instance_mode(
|
||||
instance_id: &str,
|
||||
mode: InstanceMode,
|
||||
) -> crate::Result<()> {
|
||||
let _guard = instance_gate(instance_id).lock_owned().await;
|
||||
ensure_idle(instance_id).await?;
|
||||
recover(instance_id).await?;
|
||||
let metadata = crate::instance::get(instance_id)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("Unknown instance"))?;
|
||||
if mode == InstanceMode::StarLight
|
||||
&& (metadata.instance.linked_launcher.is_some()
|
||||
|| metadata.instance.symlink_target.is_some()
|
||||
|| !matches!(
|
||||
metadata.link,
|
||||
crate::state::InstanceLink::Unmanaged
|
||||
| crate::state::InstanceLink::ImportedModpack { .. }
|
||||
))
|
||||
{
|
||||
return Err(invalid(
|
||||
"外部关联、共享目录或由第三方整合包平台管理的实例不能开启 StarLight 自动同步,请创建独立实例",
|
||||
));
|
||||
}
|
||||
crate::instance::edit(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
instance_mode: Some(mode),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
/// Held through process creation so a manual sync cannot race a launch.
|
||||
pub async fn prepare_launch(
|
||||
instance_id: &str,
|
||||
offline: bool,
|
||||
) -> crate::Result<OwnedMutexGuard<()>> {
|
||||
let guard = instance_gate(instance_id).lock_owned().await;
|
||||
recover(instance_id).await?;
|
||||
if instance_mode(instance_id).await? == InstanceMode::StarLight {
|
||||
let b = binding(instance_id).await?.ok_or_else(|| invalid("StarLight 实例尚未配置官方整合包,请在 Mod 管理 → 管理整合包中选择并安装后再启动"))?;
|
||||
if !offline {
|
||||
synchronize_locked(instance_id, &b.publication.pack_id).await?;
|
||||
}
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
async fn synchronize_locked(
|
||||
instance_id: &str,
|
||||
pack_id: &str,
|
||||
) -> crate::Result<SyncResult> {
|
||||
uuid::Uuid::parse_str(pack_id)
|
||||
.map_err(|_| invalid("Invalid modpack ID"))?;
|
||||
ensure_idle(instance_id).await?;
|
||||
recover(instance_id).await?;
|
||||
let metadata = crate::instance::get(instance_id)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("Unknown instance"))?;
|
||||
if metadata.instance.linked_launcher.is_some()
|
||||
|| metadata.instance.symlink_target.is_some()
|
||||
|| !matches!(
|
||||
metadata.link,
|
||||
crate::state::InstanceLink::Unmanaged
|
||||
| crate::state::InstanceLink::ImportedModpack { .. }
|
||||
)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Hosted modpacks require a launcher-managed instance",
|
||||
));
|
||||
}
|
||||
let root = crate::instance::get_full_path(instance_id).await?;
|
||||
let previous: Option<Binding> = read_json(&root, BINDING).await?;
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.publication.pack_id != pack_id)
|
||||
{
|
||||
return Err(invalid(
|
||||
"This instance is bound to another modpack; create a separate instance",
|
||||
));
|
||||
}
|
||||
let publication: Publication = request(&format!("/{pack_id}")).await?;
|
||||
if publication.pack_id != pack_id
|
||||
|| publication.manifest.schema_version != 1
|
||||
{
|
||||
return Err(invalid("Invalid modpack publication"));
|
||||
}
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.publication.release_id > publication.release_id)
|
||||
{
|
||||
return Err(invalid("Server returned an older modpack publication"));
|
||||
}
|
||||
let manifest = &publication.manifest;
|
||||
let loader = ModLoader::try_from_string(&manifest.runtime.loader)?;
|
||||
let resolved_loader = if loader == ModLoader::Vanilla {
|
||||
None
|
||||
} else {
|
||||
Some(crate::launcher::get_loader_version_from_profile(&manifest.runtime.game_version, loader, manifest.runtime.loader_version.as_deref()).await?.ok_or_else(|| invalid("Modpack loader version is unavailable for this Minecraft version"))?.id)
|
||||
};
|
||||
let runtime_changed = metadata.applied_content_set.game_version
|
||||
!= manifest.runtime.game_version
|
||||
|| metadata.applied_content_set.loader != loader
|
||||
|| metadata.applied_content_set.loader_version != resolved_loader;
|
||||
|
||||
let cache = target(&root, ".starlight-pack-cache")?;
|
||||
tokio::fs::create_dir_all(&cache).await?;
|
||||
let state = State::get().await?;
|
||||
let mut files = manifest.files.clone();
|
||||
validate(&files)?;
|
||||
let mut downloaded = 0;
|
||||
let mut sources = BTreeMap::new();
|
||||
for f in &files {
|
||||
sources.insert(
|
||||
f.path.clone(),
|
||||
format!("{API}/files/{}/{}", publication.release_id, f.sha256),
|
||||
);
|
||||
}
|
||||
// Resolve CurseForge references using the launcher's existing API and integrity checks.
|
||||
for external in &manifest.external {
|
||||
let cf = crate::api::curseforge::get_file(
|
||||
external.project_id,
|
||||
external.file_id,
|
||||
)
|
||||
.await?;
|
||||
if cf.mod_id != external.project_id || cf.id != external.file_id {
|
||||
return Err(invalid("CurseForge returned a mismatched file"));
|
||||
}
|
||||
safe_path(&cf.file_name)?;
|
||||
if cf.file_name.contains('/') {
|
||||
return Err(invalid("Invalid CurseForge filename"));
|
||||
}
|
||||
let path = format!("mods/{}", cf.file_name);
|
||||
if files.iter().any(|f| f.path.eq_ignore_ascii_case(&path)) {
|
||||
continue;
|
||||
} // overrides take precedence
|
||||
let sha1 = cf
|
||||
.hashes
|
||||
.iter()
|
||||
.find(|h| h.algo == 1)
|
||||
.map(|h| h.value.clone())
|
||||
.ok_or_else(|| invalid("CurseForge file has no SHA-1"))?;
|
||||
let staged = target(
|
||||
&cache,
|
||||
&format!("cf-{}-{}", external.project_id, external.file_id),
|
||||
)?;
|
||||
let local = target(&root, &path)?;
|
||||
if !staged.exists()
|
||||
&& local.is_file()
|
||||
&& crate::util::fetch::sha1_file_async(&local).await?
|
||||
== (cf.file_length, sha1.clone())
|
||||
{
|
||||
tokio::fs::copy(&local, &staged).await?;
|
||||
}
|
||||
if !staged.exists() {
|
||||
let url = match cf.download_url {
|
||||
Some(url) => url,
|
||||
None => crate::api::curseforge::get_download_url(
|
||||
external.project_id,
|
||||
external.file_id,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
invalid("This CurseForge file requires a manual download")
|
||||
})?,
|
||||
};
|
||||
download_to_path(
|
||||
DownloadRequest::new(url, ResourceClass::CurseForge)
|
||||
.with_integrity(
|
||||
Integrity::sha1(sha1.clone()).with_size(cf.file_length),
|
||||
),
|
||||
&staged,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
downloaded += cf.file_length;
|
||||
} else if crate::util::fetch::sha1_file_async(&staged).await?
|
||||
!= (cf.file_length, sha1)
|
||||
{
|
||||
return Err(invalid("Cached CurseForge file failed verification"));
|
||||
}
|
||||
let sha256 = hash(&staged)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("CurseForge download is missing"))?;
|
||||
let object = target(&cache, &sha256)?;
|
||||
if !object.exists() {
|
||||
tokio::fs::copy(&staged, object).await?;
|
||||
}
|
||||
files.push(PackFile {
|
||||
path,
|
||||
sha256,
|
||||
size: cf.file_length,
|
||||
force: true,
|
||||
preserve: false,
|
||||
});
|
||||
}
|
||||
validate(&files)?;
|
||||
if let Some(old) = &previous {
|
||||
validate(&old.files)?;
|
||||
}
|
||||
let old: BTreeMap<_, _> = previous
|
||||
.as_ref()
|
||||
.map(|b| b.files.iter().map(|f| (f.path.as_str(), f)).collect())
|
||||
.unwrap_or_default();
|
||||
let mut actions = Vec::new();
|
||||
let mut preserved = Vec::new();
|
||||
let next_paths: BTreeMap<_, _> = files
|
||||
.iter()
|
||||
.map(|f| (f.path.to_ascii_lowercase(), f.path.as_str()))
|
||||
.collect();
|
||||
for prior in old.values() {
|
||||
if next_paths
|
||||
.get(&prior.path.to_ascii_lowercase())
|
||||
.is_some_and(|next| **next != prior.path)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Case-only filename changes require a new instance",
|
||||
));
|
||||
}
|
||||
}
|
||||
for f in &files {
|
||||
let live = target(&root, &f.path)?;
|
||||
let local = hash(&live).await?;
|
||||
if local.as_deref() == Some(&f.sha256) {
|
||||
continue;
|
||||
}
|
||||
let prior = old.get(f.path.as_str());
|
||||
if local.is_some()
|
||||
&& (f.preserve
|
||||
|| (!f.force
|
||||
&& prior
|
||||
.is_some_and(|p| local.as_deref() != Some(&p.sha256))))
|
||||
{
|
||||
preserved.push(f.path.clone());
|
||||
continue;
|
||||
}
|
||||
if previous.is_none() && local.is_some() {
|
||||
return Err(invalid(format!(
|
||||
"Existing file conflicts with the modpack: {}. Use an empty instance.",
|
||||
f.path
|
||||
)));
|
||||
}
|
||||
let object = target(&cache, &f.sha256)?;
|
||||
if hash(&object).await?.as_deref() != Some(&f.sha256) {
|
||||
let url = sources
|
||||
.get(&f.path)
|
||||
.ok_or_else(|| invalid("Missing modpack download source"))?;
|
||||
download_to_path(
|
||||
DownloadRequest::new(url, ResourceClass::Modpack)
|
||||
.with_integrity(Integrity {
|
||||
size: Some(f.size),
|
||||
sha256: Some(f.sha256.clone()),
|
||||
..Default::default()
|
||||
}),
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
downloaded += f.size;
|
||||
}
|
||||
actions.push(Action {
|
||||
path: f.path.clone(),
|
||||
old_hash: local,
|
||||
next_hash: Some(f.sha256.clone()),
|
||||
});
|
||||
}
|
||||
for prior in old.values() {
|
||||
if !next_paths.contains_key(&prior.path.to_ascii_lowercase()) {
|
||||
let local = hash(&target(&root, &prior.path)?).await?;
|
||||
if local.is_none() {
|
||||
continue;
|
||||
}
|
||||
if prior.preserve || local.as_deref() != Some(&prior.sha256) {
|
||||
preserved.push(prior.path.clone());
|
||||
continue;
|
||||
}
|
||||
actions.push(Action {
|
||||
path: prior.path.clone(),
|
||||
old_hash: local,
|
||||
next_hash: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
if actions.is_empty()
|
||||
&& !runtime_changed
|
||||
&& metadata.instance.install_stage == InstanceInstallStage::Installed
|
||||
&& previous
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.publication.release_id == publication.release_id)
|
||||
{
|
||||
return Ok(SyncResult {
|
||||
instance_id: instance_id.into(),
|
||||
version: manifest.version.clone(),
|
||||
downloaded_bytes: downloaded,
|
||||
changed_files: 0,
|
||||
preserved_files: preserved,
|
||||
});
|
||||
}
|
||||
let journal = Journal {
|
||||
backup: format!(".starlight-pack-backup-{}", uuid::Uuid::new_v4()),
|
||||
actions,
|
||||
previous,
|
||||
metadata,
|
||||
};
|
||||
write_json(&root, JOURNAL, &journal)?;
|
||||
let result = async {
|
||||
// Stage all content before changing the game/runtime. The journal blocks launch until recovery.
|
||||
crate::instance::edit(instance_id, EditInstance {
|
||||
install_stage: Some(InstanceInstallStage::PackInstalling),
|
||||
content_set_patch: Some(AppliedContentSetPatch { game_version: Some(manifest.runtime.game_version.clone()), loader: Some(loader), loader_version: Some(resolved_loader.clone()), ..Default::default() }),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
if runtime_changed || journal.metadata.instance.install_stage != InstanceInstallStage::Installed {
|
||||
let mut job = crate::install::install_existing_instance(instance_id.to_string(), false).await?;
|
||||
loop {
|
||||
use crate::install::InstallJobStatus::*;
|
||||
match job.status {
|
||||
Succeeded => break,
|
||||
Queued | Running => { tokio::time::sleep(std::time::Duration::from_millis(250)).await; job = crate::install::get_job(job.job_id).await?; }
|
||||
_ => { let _ = crate::install::cancel_job(job.job_id).await; return Err(invalid("Minecraft component installation did not finish; inspect Downloads and retry")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
|
||||
write_json(&root, BINDING, &Binding { publication: publication.clone(), files })?;
|
||||
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
|
||||
crate::instance::sync_content_files(instance_id).await?;
|
||||
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
|
||||
Ok::<_, crate::Error>(())
|
||||
}.await;
|
||||
if let Err(error) = result {
|
||||
ensure_idle(instance_id).await.map_err(|busy| invalid(format!("Sync failed: {error}; recovery is pending until the active installation stops: {busy}")))?;
|
||||
restore(&root, &journal).await.map_err(|recovery| invalid(format!("Sync failed: {error}; recovery failed: {recovery}. Retry before launching.")))?;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(SyncResult {
|
||||
instance_id: instance_id.to_string(),
|
||||
version: publication.manifest.version,
|
||||
downloaded_bytes: downloaded,
|
||||
changed_files: journal.actions.len(),
|
||||
preserved_files: preserved,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn instance_operations_exclude_each_other_without_blocking_other_instances()
|
||||
{
|
||||
let first = instance_gate("mode-test-first");
|
||||
let held = first.lock_owned().await;
|
||||
assert!(instance_gate("mode-test-first").try_lock_owned().is_err());
|
||||
assert!(instance_gate("mode-test-second").try_lock_owned().is_ok());
|
||||
drop(held);
|
||||
assert!(instance_gate("mode-test-first").try_lock_owned().is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn explicit_local_disables_legacy_binding_sync() {
|
||||
assert_eq!(effective_mode(None, false), InstanceMode::Local);
|
||||
assert_eq!(effective_mode(None, true), InstanceMode::StarLight);
|
||||
assert_eq!(
|
||||
effective_mode(Some(InstanceMode::Local), true),
|
||||
InstanceMode::Local
|
||||
);
|
||||
assert_eq!(
|
||||
effective_mode(Some(InstanceMode::StarLight), false),
|
||||
InstanceMode::StarLight
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn partial_update_recovers_modified_added_and_removed_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
let cache = root.join(".starlight-pack-cache");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(root.join("old.jar"), b"old").unwrap();
|
||||
std::fs::write(root.join("removed.jar"), b"removed").unwrap();
|
||||
std::fs::write(cache.join("next"), b"next").unwrap();
|
||||
let next = hash(&cache.join("next")).await.unwrap().unwrap();
|
||||
std::fs::rename(cache.join("next"), cache.join(&next)).unwrap();
|
||||
let actions = vec![
|
||||
Action {
|
||||
path: "old.jar".into(),
|
||||
old_hash: hash(&root.join("old.jar")).await.unwrap(),
|
||||
next_hash: Some(next.clone()),
|
||||
},
|
||||
Action {
|
||||
path: "new.jar".into(),
|
||||
old_hash: None,
|
||||
next_hash: Some(next.clone()),
|
||||
},
|
||||
Action {
|
||||
path: "removed.jar".into(),
|
||||
old_hash: hash(&root.join("removed.jar")).await.unwrap(),
|
||||
next_hash: None,
|
||||
},
|
||||
Action {
|
||||
path: "missing.jar".into(),
|
||||
old_hash: None,
|
||||
next_hash: Some("0".repeat(64)),
|
||||
},
|
||||
];
|
||||
let backup = ".starlight-pack-backup-test";
|
||||
assert!(apply_files(root, &cache, backup, &actions).await.is_err());
|
||||
assert_eq!(hash(&root.join("old.jar")).await.unwrap(), Some(next));
|
||||
assert!(!root.join("removed.jar").exists());
|
||||
restore_files(root, backup, &actions).await.unwrap();
|
||||
assert_eq!(std::fs::read(root.join("old.jar")).unwrap(), b"old");
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("removed.jar")).unwrap(),
|
||||
b"removed"
|
||||
);
|
||||
assert!(!root.join("new.jar").exists());
|
||||
assert!(!root.join("missing.jar").exists());
|
||||
restore_files(root, backup, &actions).await.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn rejects_cross_platform_escape_paths() {
|
||||
for path in [
|
||||
"../mods/a",
|
||||
"/mods/a",
|
||||
"C:/a",
|
||||
"mods\\a",
|
||||
"mods/NUL.jar",
|
||||
"mods/a.",
|
||||
"mods//a",
|
||||
".starlight-pack.json",
|
||||
] {
|
||||
assert!(safe_path(path).is_err(), "{path}");
|
||||
}
|
||||
assert!(safe_path("mods/中文.jar").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_case_and_ancestor_collisions() {
|
||||
let make = |p: &str| PackFile {
|
||||
path: p.into(),
|
||||
sha256: "a".repeat(64),
|
||||
size: 1,
|
||||
force: true,
|
||||
preserve: false,
|
||||
};
|
||||
assert!(validate(&[make("mods/a.jar"), make("mods/A.jar")]).is_err());
|
||||
assert!(validate(&[make("config/a"), make("config/a/b")]).is_err());
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
|
||||
loader_version: Some(config.content_set.loader_version.clone()),
|
||||
}),
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
instance_mode: Some(crate::state::InstanceMode::Local),
|
||||
java_path: Some(config.launch_overrides.java_path.clone()),
|
||||
extra_launch_args: Some(
|
||||
config.launch_overrides.extra_launch_args.clone(),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
pub(crate) mod archive_util;
|
||||
pub mod detect;
|
||||
pub mod import;
|
||||
pub mod hosted;
|
||||
pub mod install_from;
|
||||
pub(crate) mod install_hmcl;
|
||||
pub(crate) mod install_mcbbs;
|
||||
|
||||
@ -1525,6 +1525,10 @@ pub async fn launch_minecraft(
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let instance = &context.instance;
|
||||
let content_set = &context.applied_content_set;
|
||||
let mut combined_java_args =
|
||||
crate::pack::hosted::java_arguments(&instance.id).await?;
|
||||
combined_java_args.extend_from_slice(java_args);
|
||||
let java_args = combined_java_args.as_slice();
|
||||
|
||||
if instance.install_stage == InstanceInstallStage::PackInstalling
|
||||
|| instance.install_stage == InstanceInstallStage::MinecraftInstalling
|
||||
@ -1917,7 +1921,8 @@ pub async fn launch_minecraft(
|
||||
let mut effective_memory = *memory;
|
||||
let mut effective_resolution = *resolution;
|
||||
// Extra game arguments appended after the vanilla game arguments.
|
||||
let mut extra_game_args: Vec<String> = Vec::new();
|
||||
let mut extra_game_args =
|
||||
crate::pack::hosted::game_arguments(&instance.id).await?;
|
||||
if let Some(direct) = direct_launch.as_ref()
|
||||
&& direct.dialect == LinkedLauncherDialect::Hmcl
|
||||
{
|
||||
|
||||
@ -125,8 +125,10 @@ pub(crate) async fn create_instance(
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
let launch_overrides =
|
||||
let mut launch_overrides =
|
||||
InstanceLaunchOverrides::empty(instance_id.clone());
|
||||
launch_overrides.instance_mode =
|
||||
Some(crate::state::InstanceMode::Local);
|
||||
let loader_components = LoaderComponent::from_legacy_projection(
|
||||
instance_id.clone(),
|
||||
input.loader,
|
||||
|
||||
@ -54,6 +54,7 @@ pub struct EditInstance {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
pub instance_mode: Option<crate::state::InstanceMode>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
@ -369,6 +370,9 @@ fn apply_launch_overrides_patch(
|
||||
if let Some(game_resolution) = patch.game_resolution {
|
||||
overrides.game_resolution = game_resolution;
|
||||
}
|
||||
if let Some(mode) = patch.instance_mode {
|
||||
overrides.instance_mode = Some(mode);
|
||||
}
|
||||
if let Some(timeout) = patch.launch_preparation_timeout {
|
||||
overrides.launch_preparation_timeout = timeout;
|
||||
}
|
||||
|
||||
@ -3,8 +3,21 @@ use crate::state::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceMode {
|
||||
#[serde(rename = "starlight")]
|
||||
StarLight,
|
||||
#[default]
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
pub instance_id: String,
|
||||
pub java_path: Option<String>,
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
@ -21,6 +34,7 @@ pub struct InstanceLaunchOverrides {
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
instance_mode: None,
|
||||
instance_id,
|
||||
java_path: None,
|
||||
extra_launch_args: None,
|
||||
@ -41,6 +55,8 @@ impl InstanceLaunchOverrides {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -67,6 +83,7 @@ impl InstanceLaunchOverridesData {
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
instance_mode: self.instance_mode,
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
extra_launch_args: self.extra_launch_args,
|
||||
@ -84,6 +101,7 @@ impl InstanceLaunchOverridesData {
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
instance_mode: overrides.instance_mode,
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
custom_env_vars: overrides.custom_env_vars.clone(),
|
||||
@ -104,3 +122,36 @@ pub struct InstanceLaunchContext {
|
||||
pub link: InstanceLink,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn instance_mode_round_trips_with_saved_launch_configuration() {
|
||||
let mut original = InstanceLaunchOverrides::empty("instance".into());
|
||||
for mode in [InstanceMode::StarLight, InstanceMode::Local] {
|
||||
original.instance_mode = Some(mode);
|
||||
let encoded = serde_json::to_string(
|
||||
&InstanceLaunchOverridesData::from(&original),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded: InstanceLaunchOverridesData =
|
||||
serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(
|
||||
decoded
|
||||
.into_launch_overrides("instance".into())
|
||||
.instance_mode,
|
||||
Some(mode)
|
||||
);
|
||||
}
|
||||
let old: InstanceLaunchOverridesData =
|
||||
serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old.instance_mode, None);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&InstanceMode::StarLight).unwrap(),
|
||||
"\"starlight\""
|
||||
);
|
||||
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,6 +611,7 @@ where
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
instance_mode: None,
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
extra_launch_args: input.extra_launch_args,
|
||||
|
||||
54
standards/hosted-modpacks.md
Normal file
54
standards/hosted-modpacks.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 托管整合包与增量同步
|
||||
|
||||
皮肤站后台的「Mod 管理 → 管理整合包」接收完整 ZIP。上传后产生草稿,先显示新增、修改、删除、覆盖策略、游戏组件和启动参数的变化。管理员选择「认可变更并发布」后,发布状态与审计记录在同一数据库事务提交,用户才可获取该版本。更新时需要选择已有整合包;与当前版本内容相同的 ZIP 不产生新版本。
|
||||
|
||||
启动器支持两种实例类型,可在创建页、实例设置或「Mod → 管理整合包」中选择:
|
||||
|
||||
- **StarLight 实例**:自动同步 StarLight 服务器发布的变更,游玩 StarLight 服务器必选。首次需要在整合包管理中选定并安装官方整合包;未配置完成时阻止启动。之后联网启动前自动同步,离线启动使用已经安装的版本。
|
||||
- **本地实例**:跳过 StarLight 同步检查,启动更快,可自选整合包,适合第三方服务器和本地个人游玩。切换为本地时保留已有文件、存档和官方整合包绑定,停止后续自动同步;重新切回 StarLight 后可继续同步。
|
||||
|
||||
类型保存在实例配置的 `launch_overrides.instance_mode`(`starlight` / `local`),随配置保存、复制和恢复。不需要数据库结构迁移。兼容没有该字段的旧实例:存在官方整合包绑定则按 StarLight 实例处理,否则按本地实例处理。新建实例默认显式保存为本地类型;从其他启动器导入(包括旧 StarLight / Axolotl 配置)也按本地实例处理。外部关联、共享目录和第三方平台管理的实例不能开启 StarLight 自动同步。
|
||||
|
||||
同一实例的类型切换、同步和启动准备互斥,锁保持到游戏进程创建完成。不同实例不互相等待,本地实例启动不请求 StarLight 更新。新手引导在创建方式之前介绍类型选择;创建页、实例设置和 Mod 管理共用类型选择组件。
|
||||
|
||||
两端内容切换栏初始为 Mod,保存各自上次选择;新建 StarLight 实例后直接引导到整合包管理。
|
||||
|
||||
## ZIP 格式依据
|
||||
|
||||
- [HMCL 的 MCBBS 清单实现](https://github.com/HMCL-dev/HMCL/blob/main/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java):优先识别 `mcbbs.packmeta`,兼容带 `addons` 的旧 `manifest.json`;安装 `overrides/` 文件,校验 `addon` 的 SHA-1,保留 `force` 策略,解析 CurseForge 文件引用与启动参数。
|
||||
- [MultiMC 官方导出说明](https://github.com/MultiMC/Launcher/wiki/Export-Instance):识别 `instance.cfg`、`mmc-pack.json` 的标准组件,以及 `.minecraft/` 或旧 `minecraft/` 内容目录。支持单层或多层外包目录,只允许 ZIP 内有一个明确的实例。
|
||||
- 两种来源均支持原版、Forge、NeoForge、Fabric、Quilt 主加载器。MultiMC 的自定义 `patches`、`jarmods`、支持库、启动脚本、覆盖 JVM 参数,以及 MCBBS 自定义支持库和未适配组件会明确拒绝。它们需要另外的运行配置适配,不能被静默忽略。
|
||||
|
||||
## 差异协议
|
||||
|
||||
外部 ZIP 只作为导入格式。服务端生成统一清单:运行组件、启动参数、文件相对路径、SHA-256、字节数、覆盖策略和外部文件引用。对比解压后的内容,不比较 ZIP 二进制、时间戳或压缩率。
|
||||
|
||||
每个新增或变化的文件作为一个完整对象传输,未变化文件不下载。这里的粒度是文件级,不是 JAR 内的二进制补丁。对象按 SHA-256 去重;发布版本可直接从任意旧版本更新,不要求逐个应用中间补丁。CurseForge 引用通过启动器现有接口解析并校验下载,复用已有本地文件或缓存。
|
||||
|
||||
公共路由前缀为 `/starlight/mod/packs`:
|
||||
|
||||
| 请求 | 内容 |
|
||||
| --- | --- |
|
||||
| `GET /` | 每个整合包的最新已发布版本 |
|
||||
| `GET /{packId}` | 指定整合包的最新已发布清单 |
|
||||
| `GET /files/{releaseId}/{sha256}` | 该已发布版本中存在的文件对象 |
|
||||
| `GET /admin` | 管理员的草稿和版本记录 |
|
||||
| `POST /admin/upload?version=...&packId=...` | 上传一个完整 ZIP;新建时省略 `packId` |
|
||||
| `POST /admin/{id}/publish` | 认可变更、写入审计并发布 |
|
||||
| `DELETE /admin/{id}` | 撤销未发布草稿 |
|
||||
|
||||
数据表为 `launcher_modpack_release` 与 `launcher_modpack_audit`。文件位于 `runtimeUpdatePath` 同级的 `launcher-modpacks/objects`,不能直接挂载为公共静态目录。对象下载必须同时满足已发布状态和清单成员校验。重复发布同一草稿不重复写入审计;发布基线已变化的旧草稿不能发布。
|
||||
|
||||
## 本地应用与恢复
|
||||
|
||||
实例保存 `.starlight-pack.json` 作为上次同步清单。先验证并缓存全部变动文件,再通过持久化操作记录替换文件;失败或中断后恢复已替换的旧文件和实例配置。安装任务尚未停止时不开始恢复。运行中的游戏禁止同步。
|
||||
|
||||
删除只作用于上次清单管理且未被本地修改的文件。存档、截图、日志、备份、个人选项和服务器列表保留;`force=false` 的已修改本地文件也保留,并在手动同步结果中列出。其他用户添加的文件不作为删除目标。内部缓存与备份不会被 ZIP 覆盖,路径越界、大小写冲突、符号链接和 Windows 联接路径会被拒绝。跨版本只改变文件名大小写的更新目前需要新实例。
|
||||
|
||||
单次 ZIP 上限 4 GiB,最多 100,000 个条目,单文件最多 2 GiB,总解压大小最多 16 GiB。反向代理如另有限制,应同步配置上传请求上限。对象、历史版本和本地备份目前保留,尚未实现自动回收。
|
||||
|
||||
## 验证入口
|
||||
|
||||
- 服务端:`gradlew test --tests '*Modpack*'`,覆盖格式识别、内容校验、路径限制、文件差异、策略预览、审核前隔离、重复审核和过期草稿。
|
||||
- 启动器:`cargo test -p theseus --lib api::pack::hosted::tests`,覆盖路径限制、冲突检测和修改/新增/删除文件的中断恢复。
|
||||
- 桌面命令与权限:`cargo check -p theseus_gui`。
|
||||
Reference in New Issue
Block a user