feat: complete hosted mod sync and launcher interface updates
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

Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
This commit is contained in:
2026-09-15 19:06:56 +08:00
parent 5d473ebfbc
commit bc904065c3
63 changed files with 3516 additions and 1059 deletions

View File

@ -79,6 +79,7 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
@ -2569,6 +2570,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel"
/>
<TaggedModDownloadsModal />
<CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -0,0 +1,31 @@
# 凋灵局部清理素材
## 横梁删除修正2026-09-15
- 素材:[wither-beams-removed.png](./wither-beams-removed.png)2172 × 724。
- 方式:内置 imagegen局部对象删除输入为上一版清理图与用户标注的两处横梁。
- 仅使用左右横梁及其侧面的轮廓区域,补回露出的背景;其他位置仍使用之前的清晰素材。对应的横梁护盾面同时删除。
- 不重编码既有母图,不使用整张新生成图替换画面,不增加模糊或羽化。
最终提示词:
Use case: precise-object-edit.
Image 1 is the edit target: the full 3:1 Minecraft night scene. Image 2 is a location guide only, with red rectangles identifying two unwanted horizontal shoulder beams. Do NOT copy the UI, red marks, sword, star, clouds or alternate appearance from image 2.
Remove the TWO long thin horizontal brown/black bars running out sideways from the fallen Wither's upper chest to its left and right side skulls. In image 1 at normalized 2048x683 coordinates these are approximately left x9781140 y467527 and right x12801405 y487535. ERASE these bars completely, including their top face and side face; show the existing dark blue-gray ground/background visible through the new open gaps. The left and right skulls must stay in their exact positions on the ground, separated visually from the chest in these gaps. Do not invent substitute bars, short stumps, connecting rods, upright shoulder bones, debris, ribs or decorations in these two gaps.
Preserve all other pixels/geometry/composition: the three blocky skulls, central neck, broken rib cage, spine, player's exact pose and clothes, moon, landscape, frame edges, lighting and colors. In particular do not remove or redraw the existing curved/segmented chest ribs below the two bars. Precisely restore the small exposed background behind removed bars, with crisp square voxel texture. No blur, smoothing, feathering, depth of field, re-rendering of the whole image, glow or haze. Keep the original full 3:1 frame and exact object registration, at 2172x724 if possible. This is a tiny local object removal, not a restyle.
## 上一版肩部凸出结构清理
- 素材:[wither-shoulder-cleanup.png](./wither-shoulder-cleanup.png)2172 × 724。
- 生成方式:内置 imagegen 图像编辑工具。
- 编辑目标:`victory-clean.jpg`;辅助参考为用户标红的两处肩部异常结构及 Minecraft 凋灵截图。
- 实际使用:仅将两处肩部异常结构的清理结果合成到清晰母图。整张生成图不作为背景使用,三个头、肋骨、角色和环境保留原有素材。
- 动画在独立图层中绘制;护盾几何记录在 `wither-shield.ts`,坐标以 2048 × 682⅔ 的场景空间表示。
## 生成时使用的提示词
Use case: precise-object-edit. Asset type: a sharp replacement Wither plate for an existing Minecraft cinematic animated banner. Image 1 is the EDIT TARGET: retain its exact 3:1 panoramic framing and camera, keep every other object and the character unchanged. Image 2 is ONLY an annotation reference: its red rectangles identify two erroneous upright slabs/extra structures on the Wither's shoulders which must be removed; do not draw any red marks. Image 3 is ONLY a vanilla Minecraft Wither anatomy reference; use its simple three-headed skeletal structure, not its standing pose or blue shield.
Regenerate ONLY the fallen Wither in Image 1 cleanly, with crisp straight voxel edges, dark charcoal/black nether bone material and clean low-resolution Minecraft pixel textures. Exactly three cuboid skulls: near-left skull under the player's raised boot, central skull farther back facing upward, right skull at the right, preserving their existing centers, scale, screen orientation and silhouette as closely as possible. Retain the existing supine defeated pose, clean horizontal shoulder crossbar linking the three heads, a compact rib cage with separated squared ribs, and the central spine receding into the foreground. Remove the two upright paddle-like shoulder projections indicated by the red boxes, all extra heads, all spikes/horns, all gold mechanisms, odd rubble embedded in the body, noisy invented decorations and duplicate bones. The chest has one coherent small broken sternum opening centered exactly where the animated sword will enter (about 56% across and 72% down the full panorama). Preserve visible negative space between ribs, without random detached bits. This is a defeated non-human Minecraft boss, no blood or gore.
Keep the player's existing foot resting on the near-left skull at precisely the same height and location. Keep the character, face, volumetric brown hair, yellow clothing, armor, hands and boots absolutely unchanged. Keep the sky, moon, trees, terrain and all other regions unchanged and sharp. Do NOT add a sword or nether star: these already exist as separate animated layers. Do NOT paint any blue shielding, electricity, glow halos or particles onto the Wither; those will be separate precisely aligned animated layers. Match existing cool night illumination and subtle warm gold light on the top-facing bone planes. Reconstruct only the small pieces of ground/sky revealed by removing the two wrong shoulder slabs. No blur, no painterly smoothing, no depth-of-field blur, no sharpening halos. Output a single full panoramic 3:1 edited plate, same framing as Image 1, at the highest available resolution.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { loading_listener } from '@/helpers/events'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
const { formatMessage } = useVIntl()
@ -15,6 +15,7 @@ const messages = defineMessages({
})
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@ -22,6 +23,7 @@ interface LoadingEventPayload {
}
interface LaunchProgressItem {
waiting: boolean
key: string
message: string
fraction: number | null
@ -29,6 +31,7 @@ interface LaunchProgressItem {
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
const LAUNCH_BAR_TYPES = new Set([
'hosted_pack_sync',
'minecraft_download',
'instance_update',
'zip_extract',
@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
if (!isVisible(payload.event)) return
activeMap.set(payload.loader_uuid, {
waiting: payload.total === 0,
key: payload.loader_uuid,
message: payload.message,
fraction: payload.fraction,
@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
const hasProgress = computed(() => progressItems.value.length > 0)
function percent(item: LaunchProgressItem): string {
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
}
let initializing = true
const buffered: LoadingEventPayload[] = []
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
applyEvent(payload)
if (initializing) buffered.push(payload)
else applyEvent(payload)
})
const bars = await progress_bars_list().catch(() => ({}))
for (const bar of Object.values(bars)) {
applyEvent({
event: bar.bar_type,
loader_uuid: String(bar.loading_bar_uuid),
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
}
initializing = false
for (const payload of buffered) applyEvent(payload)
onUnmounted(() => {
unlistenLoading?.()
@ -96,12 +115,7 @@ onUnmounted(() => {
{{ percent(item) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
<div
class="h-full rounded-full bg-brand transition-[width] duration-200"
:style="{ width: percent(item) || '100%' }"
/>
</div>
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
</li>
</ul>
</section>

View File

@ -39,7 +39,7 @@
})
}}
</p>
<p v-if="syncing" role="status">{{ formatMessage(messages.syncing) }}</p>
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
@ -59,10 +59,9 @@
</ul>
</details>
</div>
<p v-if="!loading && ready && !catalog.length">{{ formatMessage(messages.empty) }}</p>
<article
v-for="pack in catalog"
:key="pack.packId"
v-if="pack"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
@ -73,16 +72,7 @@
</p>
</div>
<ButtonStyled color="brand"
><button
type="button"
:disabled="
!ready ||
syncing ||
loading ||
(!!binding && binding.publication.packId !== pack.packId)
"
@click="sync(pack.packId)"
>
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
@ -92,28 +82,29 @@
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { useHostedSync } from '@/composables/useHostedSync'
import { injectDownloadManager } from '@/providers/download-manager'
import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedCatalog,
hostedDefault,
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')
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
'showCreationModalWithOptions',
)
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import', instanceMode: 'local' })
showCreation?.({ skipSetupType: true, initialMode: 'import' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
@ -125,11 +116,11 @@ const messages = defineMessages({
},
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' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
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.',
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
@ -151,15 +142,27 @@ const messages = defineMessages({
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' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
})
const catalog = ref<HostedPublication[]>([])
const pack = ref<HostedPublication | null>(null)
const binding = ref<HostedBinding | null>(null)
const result = ref<HostedSyncResult | null>(null)
const task = useHostedSync(() => props.instanceId)
const result = task.result
const manager = injectDownloadManager()
const loading = ref(false)
const syncing = ref(false)
const syncing = computed(
() =>
task.busy.value ||
manager.legacyDownloads.value.some(
(bar) =>
bar.bar_type?.type === 'hosted_pack_sync' &&
bar.bar_type.instance_id === props.instanceId &&
!bar.bar_type.error,
),
)
const ready = ref(false)
const error = ref('')
const loadError = ref('')
const error = computed(() => loadError.value || task.error.value)
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
@ -167,46 +170,36 @@ async function load() {
const instanceId = props.instanceId
loading.value = true
ready.value = false
error.value = ''
loadError.value = ''
try {
const [packs, installed] = await Promise.all([hostedCatalog(), hostedBinding(instanceId)])
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
if (current !== generation) return
catalog.value = packs
pack.value = official
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) error.value = String(cause)
if (current === generation) loadError.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync(packId: string) {
async function sync() {
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
}
loadError.value = ''
await task.sync()
}
watch(syncing, (busy, wasBusy) => {
if (!busy && wasBusy) void load()
})
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
error.value = ''
catalog.value = []
loadError.value = ''
pack.value = null
binding.value = null
result.value = null
void load()
},
{ immediate: true },

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>()
const manager = injectDownloadManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
preparing: {
id: 'app.hosted-packs.progress.preparing',
defaultMessage: 'Preparing modpack installation…',
},
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
})
const bar = computed(() =>
manager.legacyDownloads.value.find(
(item) =>
props.instanceId &&
item.bar_type?.type === 'hosted_pack_sync' &&
!item.bar_type.error &&
item.bar_type.instance_id === props.instanceId,
),
)
const waiting = computed(() => !bar.value?.total)
const current = computed(() =>
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
)
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
</script>
<template>
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
<ProgressBar
:progress="current"
:max="bar?.total || 1"
:waiting="waiting"
:label="message"
label-class="min-w-0 break-all text-sm text-secondary"
:show-progress="!waiting"
full-width
>
<template #progress-icon />
</ProgressBar>
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
{{ formatMessage(messages.downloads) }}
</RouterLink>
</div>
</template>

View File

@ -15,7 +15,7 @@ const messages = defineMessages({
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.',
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
},
localDescription: {
id: 'app.instance-mode.local-description',

View File

@ -11,7 +11,10 @@ const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: { id: 'app.instance-mode.saving', defaultMessage: 'Saving instance type…' },
saving: {
id: 'app.instance-mode.saving',
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
},
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})

View File

@ -0,0 +1,146 @@
<script setup lang="ts">
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { loading_listener } from '@/helpers/events'
import { progress_bars_list } from '@/helpers/state'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import {
createTaggedModProgress,
type TaggedProgressEvent,
type TaggedProgressGroup,
} from '@/helpers/tagged-mod-progress'
const modal = ref<InstanceType<typeof NewModal>>()
const router = useRouter()
const { formatMessage } = useVIntl()
const state = createTaggedModProgress()
const groups = shallowRef<TaggedProgressGroup[]>([])
const messages = defineMessages({
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
description: {
id: 'app.hosted-mods.description',
defaultMessage: 'The game starts after all required updates have been installed.',
},
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
})
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
let disposed = false
let unlisten: (() => void) | undefined
let stopAttempts: (() => void) | undefined
function refresh() {
groups.value = [...state.groups.values()]
}
function consume(payload: TaggedProgressEvent) {
const opened = state.update(payload)
refresh()
if (opened)
void nextTick(() => {
const group = state.groups.get(payload.event?.batch_id ?? '')
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
})
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
modal.value?.hide()
}
onMounted(async () => {
stopAttempts = onHostedPackAttemptStarted((id) => {
state.reset(id)
refresh()
if (!groups.value.length) modal.value?.hide()
})
let initializing = true
const buffered: TaggedProgressEvent[] = []
const stop = await loading_listener((payload: TaggedProgressEvent) => {
if (initializing) buffered.push(payload)
else consume(payload)
})
if (disposed) {
stop()
return
}
unlisten = stop
const bars = await progress_bars_list().catch(() => ({}))
if (disposed) return
const ordered = Object.values(bars).sort(
(a, b) =>
Number(b.bar_type?.type === 'hosted_pack_sync') -
Number(a.bar_type?.type === 'hosted_pack_sync'),
)
for (const bar of ordered)
consume({
loader_uuid: String(bar.loading_bar_uuid),
event: bar.bar_type,
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
initializing = false
for (const payload of buffered) consume(payload)
})
onUnmounted(() => {
disposed = true
unlisten?.()
stopAttempts?.()
})
function openDownloads() {
modal.value?.hide()
void router.push('/downloads')
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(38rem, calc(100vw - 2rem))"
scrollable
max-content-height="60vh"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
{{ group.message }}
</p>
<div
v-for="file in group.files.values()"
:key="file.id"
class="flex min-w-0 flex-col gap-1"
>
<div class="flex flex-wrap justify-between gap-2 text-sm">
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
<span class="text-secondary tabular-nums"
>{{ size(file.current) }} / {{ size(file.total) }}</span
>
</div>
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
<ProgressBar
v-else
:progress="file.current"
:max="file.total || 1"
:waiting="!file.total"
:show-progress="!file.done"
:label="file.done ? formatMessage(messages.complete) : file.message"
full-width
><template #progress-icon
/></ProgressBar>
</div>
</section>
</div>
<template #actions>
<ButtonStyled
><button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button></ButtonStyled
>
<ButtonStyled
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
>
</template>
</NewModal>
</template>

View File

@ -312,6 +312,7 @@ interface RunningProcess {
}
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@ -435,7 +436,6 @@ const unlistenProcess = await process_listener(async () => {
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
} catch (e) {
console.error(e)
}
@ -629,6 +629,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return (
loadingBar.bar_type?.type !== 'launcher_update' &&
[
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
@ -655,8 +656,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid,
message: payload.message,
current: payload.fraction,
total: 1,
current: payload.fraction * (payload.total ?? 1),
total: payload.total ?? 1,
bar_type: payload.event,
})
if (!isVisibleLoadingBar(loadingBar)) return false

View File

@ -1,18 +1,31 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { clearHostedSession } from '@/helpers/hosted-packs'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
@ -35,6 +48,7 @@ onUnmounted(() => {
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>

View File

@ -0,0 +1,274 @@
type Point = [number, number]
type Quad = [Point, Point, Point, Point]
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
// Coordinates follow the sharp master with its localized shoulder cleanup,
// normalized to the scene's 2048 × 682⅔ plate.
// Each visible bone has its own face: the air between ribs must never receive armor.
export const witherShieldSurfaces: ShieldSurface[] = [
// Only the upper chest remains; the removed side beams have no shield surfaces.
{
plane: [
[1194, 479],
[1294, 489],
[1283, 516],
[1183, 505],
],
},
// Far central skull: face, jaw wall and right side share the same edges.
{
plane: [
[1162, 355],
[1319, 352],
[1267, 446],
[1108, 442],
],
},
{
plane: [
[1108, 442],
[1267, 446],
[1280, 489],
[1119, 470],
],
shade: 0.68,
},
{
plane: [
[1319, 352],
[1356, 443],
[1319, 492],
[1267, 446],
],
shade: 0.6,
},
// Near-left skull, underneath the boot.
{
plane: [
[794, 477],
[968, 454],
[1003, 549],
[833, 602],
],
},
{
plane: [
[794, 477],
[833, 602],
[821, 625],
[781, 539],
],
shade: 0.55,
},
{
plane: [
[833, 602],
[1003, 549],
[983, 587],
[821, 625],
],
shade: 0.65,
},
// Right skull.
{
plane: [
[1482, 399],
[1639, 469],
[1568, 582],
[1402, 507],
],
},
{
plane: [
[1639, 469],
[1650, 557],
[1597, 631],
[1568, 582],
],
shade: 0.57,
},
{
plane: [
[1402, 507],
[1568, 582],
[1597, 631],
[1414, 583],
],
shade: 0.66,
outline: [
[1402, 507],
[1568, 582],
[1597, 631],
[1561, 637],
[1444, 621],
[1414, 583],
],
},
// Rear rib, left and right of the chest opening.
{
plane: [
[1021, 505],
[1129, 484],
[1118, 501],
[1009, 520],
],
},
{
plane: [
[1009, 520],
[1118, 501],
[1112, 515],
[1006, 535],
],
shade: 0.65,
},
{
plane: [
[1191, 486],
[1300, 510],
[1289, 530],
[1181, 510],
],
},
{
plane: [
[1289, 530],
[1300, 510],
[1310, 611],
[1296, 621],
],
shade: 0.6,
},
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
{
plane: [
[1052, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
outline: [
[1015, 535],
[1052, 520],
[1094, 522],
[1110, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
},
{
plane: [
[992, 548],
[1129, 553],
[1137, 575],
[987, 567],
],
shade: 0.6,
},
{
plane: [
[1159, 519],
[1170, 560],
[1137, 575],
[1129, 553],
],
shade: 0.55,
},
{
plane: [
[1180, 513],
[1289, 551],
[1274, 575],
[1159, 538],
],
},
{
plane: [
[1274, 575],
[1289, 551],
[1298, 617],
[1279, 632],
],
shade: 0.62,
},
// Closest right rib; its downturned end stops above the ground.
{
plane: [
[1157, 542],
[1261, 581],
[1243, 602],
[1141, 564],
],
},
{
plane: [
[1141, 564],
[1243, 602],
[1255, 641],
[1148, 605],
],
shade: 0.7,
},
{
plane: [
[1243, 602],
[1261, 581],
[1270, 627],
[1255, 641],
],
shade: 0.55,
},
// Foreground rib and spine.
{
plane: [
[1000, 566],
[1140, 581],
[1107, 604],
[960, 589],
],
},
{
plane: [
[960, 589],
[1107, 604],
[1112, 680],
[968, 622],
],
shade: 0.6,
outline: [
[960, 589],
[1107, 604],
[1112, 680],
[1070, 681],
[1066, 619],
[968, 611],
],
},
{
plane: [
[1107, 604],
[1140, 581],
[1160, 669],
[1112, 680],
],
shade: 0.5,
},
{
plane: [
[986, 611],
[1070, 617],
[967, 684],
[874, 671],
],
},
{
plane: [
[1070, 617],
[1074, 668],
[1060, 684],
[967, 684],
],
shade: 0.55,
},
]

View File

@ -4,6 +4,10 @@ import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
import originalUrl from '@/assets/about-scene/victory-master.jpg'
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
import { witherShieldSurfaces } from './wither-shield'
type Point = [number, number]
type Vertex = [number, number, number]
@ -17,11 +21,16 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
const original = new Image()
const clean = new Image()
const correctedPose = new Image()
const rebuiltWither = new Image()
const removedBeams = new Image()
const swordTexture = new Image()
const cloudTexture = new Image()
const moonTexture = new Image()
const W = 2048,
H = W / 3,
artworkWidth = 2172,
artworkHeight = 724,
artworkScale = artworkWidth / W,
TAU = Math.PI * 2
let time = 0,
last = 0,
@ -36,7 +45,8 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
layer.height = Math.ceil(h)
return layer
}
const base = makeLayer()
const base = makeLayer(artworkWidth, artworkHeight)
const shield = makeLayer(artworkWidth, artworkHeight)
const star = makeLayer(180, 180)
const starFace = makeLayer(180, 180)
const swordFace = makeLayer(128, 128)
@ -87,86 +97,6 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
I: '#fff6da',
S: '#fffef1',
}
const shieldSurfaces: Point[][] = [
[
[986, 442],
[1124, 466],
[1096, 541],
[982, 532],
],
[
[1110, 462],
[1304, 482],
[1258, 625],
[1000, 605],
],
[
[1304, 482],
[1379, 504],
[1390, 614],
[1258, 625],
],
[
[1328, 481],
[1428, 424],
[1472, 446],
[1390, 523],
],
[
[1014, 603],
[1170, 626],
[1136, 683],
[869, 683],
],
[
[792, 481],
[950, 451],
[987, 550],
[835, 604],
],
[
[792, 481],
[835, 604],
[821, 625],
[780, 533],
],
[
[835, 604],
[987, 550],
[974, 589],
[821, 625],
],
[
[1165, 357],
[1301, 355],
[1247, 471],
[1108, 441],
],
[
[1301, 355],
[1358, 419],
[1311, 500],
[1247, 471],
],
[
[1483, 398],
[1626, 470],
[1558, 590],
[1404, 514],
],
[
[1626, 470],
[1655, 549],
[1606, 637],
[1558, 590],
],
[
[1404, 514],
[1558, 590],
[1606, 637],
[1449, 592],
],
]
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
const shieldEvents = [
[0, 0.68, 0.48],
@ -259,10 +189,12 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
function prepare() {
// Restore the sharp master for all unaffected materials, anatomy and terrain.
const b = base.getContext('2d')!
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
b.imageSmoothingEnabled = false
b.drawImage(original, 0, 0, W, H)
const sky = makeLayer(),
const sky = makeLayer(artworkWidth, artworkHeight),
skyCtx = sky.getContext('2d')!
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
skyCtx.drawImage(clean, 0, 0, W, H)
skyCtx.globalCompositeOperation = 'destination-in'
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
@ -270,7 +202,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
skyFade.addColorStop(1, 'transparent')
skyCtx.fillStyle = skyFade
skyCtx.fillRect(0, 0, W, 442)
b.drawImage(sky, 0, 0)
b.drawImage(sky, 0, 0, W, H)
const restore = (source: HTMLImageElement, polygon: Point[]) => {
b.save()
b.beginPath()
@ -341,7 +273,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
ms.fillRect(-140, -135, 280, 270)
ms.restore()
b.drawImage(moonSky, 193, -20)
// Use original sharp head faces, preserving their exact silhouette without a sky border.
// Restore the sharp skull planes covered by the sky plate.
restore(original, [
[1159, 355],
[1318, 352],
@ -350,7 +282,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
[1246, 474],
[1107, 443],
])
// Local broken-sternum and old-blade removal; the unaffected ribs stay from the master.
// Remove the old painted blade without resampling the rest of the creature.
restore(clean, [
[975, 339],
[1002, 334],
@ -365,7 +297,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
[1053, 502],
[1006, 476],
])
// Remove the two baked blue lightning remnants without replacing the whole Wither.
// Retain the previously approved removal of baked lightning below the skulls.
restore(clean, [
[900, 595],
[948, 572],
@ -386,6 +318,49 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
[1318, 525],
[1320, 470],
])
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
// ribs, character and background retain their original sharp source pixels.
restore(rebuiltWither, [
[963, 423],
[1014, 417],
[1049, 431],
[1074, 479],
[1066, 495],
[1017, 511],
[985, 524],
[963, 466],
])
restore(rebuiltWither, [
[1360, 415],
[1392, 414],
[1429, 427],
[1448, 445],
[1405, 506],
[1398, 532],
[1355, 528],
[1319, 514],
[1324, 494],
])
// Replace only the removed beam silhouettes with the newly exposed background.
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
restore(removedBeams, [
[976, 480],
[1146, 462],
[1152, 488],
[1124, 508],
[999, 530],
[990, 542],
])
restore(removedBeams, [
[1294, 489],
[1404, 498],
[1445, 548],
[1452, 629],
[1402, 621],
[1305, 615],
[1299, 549],
[1283, 521],
])
restore(correctedPose, [
[811, 377],
[951, 377],
@ -673,57 +648,70 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
const level = shieldLevel(t)
canvas.dataset.shieldIntensity = level.toFixed(3)
if (level < 0.003) return
ctx.save()
ctx.globalCompositeOperation = 'screen'
const surface = shield.getContext('2d')!
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
surface.clearRect(0, 0, W, H + 1)
surface.save()
surface.globalCompositeOperation = 'source-over'
const trace = (points: Point[]) => {
surface.beginPath()
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
surface.closePath()
}
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
ctx.beginPath()
ctx.rect(0, 0, W, H)
wound.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
shieldSurfaces.forEach((quad, index) => {
surface.beginPath()
surface.rect(0, 0, W, H)
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
surface.closePath()
surface.clip('evenodd')
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
const map = (u: number, v: number): Point => [
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
]
const alpha = level * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
path(quad)
ctx.fillStyle = `rgba(55,126,203,${alpha * 0.57})`
ctx.fill()
ctx.save()
path(quad)
ctx.clip()
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
trace(outline)
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
surface.fill()
surface.save()
trace(outline)
surface.clip()
for (let row = 0; row < 8; row++)
for (let col = 0; col < 8; col++) {
const grain = rand(row * 17 + col * 5 + index * 139)
if (grain < 0.56) continue
path([
trace([
map(col / 8, row / 8),
map((col + 1) / 8, row / 8),
map((col + 1) / 8, (row + 1) / 8),
map(col / 8, (row + 1) / 8),
])
ctx.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
ctx.fill()
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
surface.fill()
}
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
for (let band = -1; band < 3; band++)
for (let col = 0; col < 8; col++) {
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
const height = 0.034 + rand(col * 3 + band + index) * 0.022
path([
trace([
map(col / 8, v),
map((col + 1) / 8, v),
map((col + 1) / 8, v + height),
map(col / 8, v + height),
])
ctx.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
ctx.fill()
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
surface.fill()
}
ctx.restore()
surface.restore()
})
surface.restore()
// Composite the fitted faces once, without bright overlaps at shared bone edges.
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.drawImage(shield, 0, 0, W, H)
ctx.restore()
}
function drawClouds(elapsed: number) {
@ -773,9 +761,10 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
ctx.clearRect(0, 0, W, H)
const cycle = (t * TAU) / 24
ctx.save()
ctx.translate(W / 2, H / 2)
ctx.scale(1.008, 1.008)
ctx.translate(-W / 2 + Math.sin(cycle) * 1.6, -H / 2 + Math.sin(cycle * 2) * 0.7)
// Keep the static plate pixel-stable; animate only the independent effects.
// The full-resolution master is sampled once, without a floating camera zoom.
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(base, 0, 0, W, H)
drawMoon()
drawClouds(elapsed)
@ -883,7 +872,10 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
if (destroyed) return
const width = canvas.getBoundingClientRect().width
if (width <= 0) return
canvas.width = Math.min(W, Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))))
canvas.width = Math.min(
artworkWidth,
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
)
canvas.height = Math.round(canvas.width / 3)
cloudField?.resize(canvas.width, canvas.height)
if (ready) draw(time)
@ -903,7 +895,16 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
img.src = url
})
const imageAssets = [original, clean, swordTexture, correctedPose, cloudTexture, moonTexture]
const imageAssets = [
original,
clean,
swordTexture,
correctedPose,
cloudTexture,
moonTexture,
rebuiltWither,
removedBeams,
]
const loading = Promise.all([
loaded(original, originalUrl),
loaded(clean, cleanUrl),
@ -911,6 +912,8 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
loaded(correctedPose, correctedPoseUrl),
loaded(cloudTexture, cloudTextureUrl),
loaded(moonTexture, moonTextureUrl),
loaded(rebuiltWither, rebuiltWitherUrl),
loaded(removedBeams, removedBeamsUrl),
])
.then(() => {
if (destroyed) return
@ -941,7 +944,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
reducedMotion.removeEventListener('change', updatePlayback)
cloudField?.dispose()
cloudField = undefined
for (const layer of [base, star, starFace, swordFace, grippingFingers]) {
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
layer.width = 1
layer.height = 1
}

View File

@ -1,93 +0,0 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { onScopeDispose, ref } from 'vue'
defineProps<{ src: string; name: string; href?: string }>()
const emit = defineEmits<{ activate: [] }>()
const holding = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let origin = { x: 0, y: 0 }
let suppressClick = false
function cancel() {
clearTimeout(timer)
timer = undefined
holding.value = false
}
function begin() {
cancel()
suppressClick = false
holding.value = true
timer = setTimeout(() => {
cancel()
suppressClick = true
emit('activate')
}, 800)
}
function pointerDown(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
origin = { x: event.clientX, y: event.clientY }
begin()
}
function pointerMove(event: PointerEvent) {
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
}
function click(event: MouseEvent) {
if (!suppressClick) return
event.preventDefault()
event.stopPropagation()
suppressClick = false
}
function keydown(event: KeyboardEvent) {
if (event.code === 'Space') {
event.preventDefault()
if (!event.repeat) begin()
}
}
window.addEventListener('blur', cancel)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
})
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
class="mine-avatar"
:class="{ holding }"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="cancel"
@pointercancel="cancel"
@pointerleave="cancel"
@blur="cancel"
@click="click"
@contextmenu.prevent
@dragstart.prevent
@keydown="keydown"
@keyup.space.prevent="cancel"
>
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
</a>
</template>
<style scoped>
.mine-avatar {
display: inline-flex;
flex-shrink: 0;
border-radius: 50%;
user-select: none;
touch-action: manipulation;
}
.mine-avatar.holding {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.mine-avatar:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: 3px;
}
</style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
@ -20,7 +20,9 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const grid = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const brushCursor = ref({ x: 0, y: 0, color: '', visible: false })
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
@ -85,6 +87,38 @@ function onWheel(event: WheelEvent) {
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
function moveBrushCursor(event: PointerEvent) {
if (event.pointerType === 'touch') {
hideBrushCursor()
return
}
const cell = (event.target as Element | null)?.closest<HTMLButtonElement>('.mine-cell')
const visible = Boolean(
props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'),
)
brushCursor.value = {
x: event.clientX,
y: event.clientY,
color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '',
visible,
}
}
function hideBrushCursor() {
brushCursor.value.visible = false
}
watch(
() => [props.playing, props.selected] as const,
([playing, selected]) => {
if (!playing || selected < 0) hideBrushCursor()
else if (brushCursor.value.visible && grid.value) {
brushCursor.value.color = getComputedStyle(grid.value).color
}
},
{ flush: 'post' },
)
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
@ -116,10 +150,18 @@ defineExpose({
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
ref="grid"
class="mine-grid"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointermove="moveBrushCursor"
@pointerleave="hideBrushCursor"
@pointercancel="hideBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@ -148,6 +190,24 @@ defineExpose({
</button>
</div>
</div>
<Teleport to="body">
<svg
v-show="brushCursor.visible"
class="mine-brush-cursor"
:style="{
left: `${brushCursor.x}px`,
top: `${brushCursor.y}px`,
color: brushCursor.color,
}"
viewBox="0 0 1024 1024"
aria-hidden="true"
>
<path
fill="currentColor"
d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"
/>
</svg>
</Teleport>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
@ -244,10 +304,22 @@ defineExpose({
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: crosshair;
cursor: pointer;
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: none;
}
.mine-brush-cursor {
position: fixed;
z-index: 2147483647;
width: 2rem;
height: 2rem;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%));
pointer-events: none;
transform: translate(-16%, -84%);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);
@ -320,4 +392,12 @@ defineExpose({
width: 4.5rem;
}
}
@media (pointer: coarse) {
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: pointer;
}
.mine-brush-cursor {
display: none;
}
}
</style>

View File

@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Your last next launcher.',
},
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: {
id: 'app.onboarding.discover.description',
@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
},
homeLayoutTitle: {
id: 'app.onboarding.home-layout.title',
defaultMessage: 'Change the amount of detail',
defaultMessage: 'Switch your home',
},
homeLayoutDescription: {
id: 'app.onboarding.home-layout.description',
defaultMessage:
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
},
continueArea: {
id: 'app.onboarding.action.continue-area',
@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
skinsDescription: {
id: 'app.onboarding.skins.description',
defaultMessage:
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
},
clickSkins: {
id: 'app.onboarding.action.click-skins',
@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: {
id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage:
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
},
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: {
@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
},
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Starlight way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: {
id: 'app.onboarding.defaults.description',
@ -204,12 +149,6 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
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.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: {
@ -228,7 +167,7 @@ export const onboardingMessages = defineMessages({
libraryPageDescription: {
id: 'app.onboarding.library-page.description',
defaultMessage:
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
},
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: {
@ -247,7 +186,7 @@ export const onboardingMessages = defineMessages({
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.',
'StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player.',
},
creationDescription: {
id: 'app.onboarding.creation.description',
@ -320,7 +259,7 @@ export const onboardingMessages = defineMessages({
labDescription: {
id: 'app.onboarding.lab.description',
defaultMessage:
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
},
clickLab: {
id: 'app.onboarding.action.click-lab',
@ -333,63 +272,7 @@ export const onboardingMessages = defineMessages({
labToolsDescription: {
id: 'app.onboarding.lab-tools.description',
defaultMessage:
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
@ -457,18 +340,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.start,
),
),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
@ -523,16 +394,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'account',
'inspect',
copy(
onboardingMessages.accountTitle,
onboardingMessages.accountDescription,
onboardingMessages.continueArea,
),
control('account-entry'),
),
step(
'lab-navigation',
'navigate',
@ -549,90 +410,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription,
),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step(
'downloads-navigation',
'navigate',
@ -708,6 +485,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-starlight': { next: 'complete' },
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},

View File

@ -5,7 +5,6 @@ import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
@ -17,7 +16,7 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
const { formatMessage } = useVIntl()
const version = await getVersion()
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
const pressingMemberName = ref<string>()
let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 }
@ -41,9 +40,13 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
if (experience.kind === 'color-mine') {
openColorMine()
return
}
activeMemberExperience.value = experience
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
@ -300,41 +303,23 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<template v-for="member in teamMembers" :key="member.name">
<div
v-if="member.name === 'Disy920'"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
>
<ColorMineAvatar
:src="member.avatarUrl"
:name="member.name"
:href="member.url"
@activate="openColorMine"
/>
<a
:href="member.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 flex-1 items-center gap-3"
>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
member.name
}}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
<a
v-else
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
:class="{ 'member-card-holding': pressingMemberName === member.name }"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@blur="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
@dragstart.prevent
>
<Avatar
:src="member.avatarUrl"
@ -349,7 +334,6 @@ const messages = defineMessages({
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</template>
</div>
</section>
<details class="group pt-4 about-settings-details">
@ -417,6 +401,11 @@ const messages = defineMessages({
padding: var(--gap-lg);
}
.member-card-holding {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));

View File

@ -1,21 +1,31 @@
import type { Component } from 'vue'
import AboutEasterEgg from '../AboutEasterEgg.vue'
export type AboutMemberExperience = {
export type AboutMemberExperience =
| {
kind: 'scene'
component: Component
longPressDuration: number
}
}
| {
kind: 'color-mine'
longPressDuration: number
}
// 长按「关于」页成员名字触发的彩蛋体验。
// 长按「关于」页成员卡片触发的彩蛋体验。
// 下界之星合成彩蛋AboutEasterEgg.vue由长按成员名 / 暗号 / Konami 秘技触发。
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
const memberExperiences: Record<string, AboutMemberExperience> = {
'easter-egg': {
kind: 'scene',
component: AboutEasterEgg,
longPressDuration: 800,
},
'color-mine': {
kind: 'color-mine',
longPressDuration: 800,
},
}
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {

View File

@ -1,10 +1,21 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useHostedSync } from './useHostedSync.ts'
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
import {
clearHostedSession,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSiteDownloadToken,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
@ -17,6 +28,233 @@ import {
skinSiteUser,
} from './skin-site-session.ts'
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let syncResponse: (() => Promise<unknown>) | undefined
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls.map(({ command }) => command),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
const unauthenticatedRetry = hostedSync('failed-instance')
assert.deepEqual(attempts, ['failed-instance'])
await assert.rejects(unauthenticatedRetry)
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => {
const sent: Array<{ type: string; requestId: string }> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
})
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>

View File

@ -24,6 +24,49 @@ let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
@ -96,6 +139,7 @@ function rejectPendingSkinUpdateRequests(message: string) {
export function setSkinSiteFrame(frame: Window | null) {
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
@ -115,6 +159,7 @@ export function openSkinSiteLogin() {
}
export function resetSkinSiteSession() {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
rejectPendingSkinUpdateRequests('The skin site session ended.')
@ -224,6 +269,22 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
@ -331,6 +392,7 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
@ -343,6 +405,7 @@ export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')

View File

@ -0,0 +1,56 @@
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { hostedCreate, hostedSync } from '../helpers/hosted-packs.ts'
const installing = ref(false)
const installError = ref('')
const createdInstance = ref<string>()
const completed = ref(false)
let generation = 0
export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return
generation++
createdInstance.value = undefined
completed.value = false
installError.value = ''
}
export function useHostedCreation() {
function acknowledge(instanceId: string) {
if (completed.value && createdInstance.value === instanceId) {
createdInstance.value = undefined
completed.value = false
}
}
async function install() {
if (installing.value) return
installing.value = true
installError.value = ''
const attempt = generation
try {
if (createdInstance.value) {
const instance = await invoke<unknown | null>('plugin:instance|instance_get', {
instanceId: createdInstance.value,
})
if (attempt !== generation) return
if (!instance) {
createdInstance.value = undefined
completed.value = false
}
}
if (completed.value) return createdInstance.value
createdInstance.value ??= await hostedCreate()
const instanceId = createdInstance.value
await hostedSync(instanceId)
if (attempt !== generation) return
completed.value = true
return instanceId
} catch (cause) {
if (attempt === generation) installError.value = String(cause)
} finally {
installing.value = false
}
}
return { installing, installError, createdInstance, completed, acknowledge, install }
}

View File

@ -0,0 +1,37 @@
import { computed, reactive } from 'vue'
import { hostedSync, type HostedSyncResult } from '../helpers/hosted-packs.ts'
const tasks = reactive(
new Map<string, { busy: boolean; result: HostedSyncResult | null; error: string }>(),
)
export function useHostedSync(instanceId: () => string) {
const task = computed(() => {
const id = instanceId()
if (!tasks.has(id)) tasks.set(id, { busy: false, result: null, error: '' })
return tasks.get(id)!
})
async function sync() {
const id = instanceId()
const current = task.value
if (current.busy) return
current.busy = true
current.error = ''
current.result = null
try {
current.result = await hostedSync(id)
} catch (error) {
current.error = String(error)
} finally {
current.busy = false
}
}
return {
busy: computed(() => task.value.busy),
result: computed(() => task.value.result),
error: computed(() => task.value.error),
sync,
}
}

View File

@ -1,4 +1,3 @@
[
{
"name": "Ax_Tps",
@ -9,6 +8,7 @@
{
"name": "Disy920",
"avatar": "Disy920.png",
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0"
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0",
"experience": "color-mine"
}
]

View File

@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
const state = createHostedDownloadFailures()
const failed = (instanceId: string, loader: string, error: string) => ({
fraction: null,
loader_uuid: loader,
event: { type: 'hosted_pack_sync', instance_id: instanceId, instance_name: instanceId, error },
})
const oldFailure = failed('one', 'old', 'old failure')
state.update(oldFailure)
state.update(failed('two', 'other', 'other failure'))
state.begin('one', state.values())
assert.equal(state.has('one'), false)
assert.equal(state.has('two'), true)
assert.equal(state.isRetired('old'), true)
state.update(oldFailure)
assert.equal(state.has('one'), false)
state.update(failed('one', 'new', 'new failure'))
state.update({ ...oldFailure, fraction: 0.5 })
assert.equal(
state.values().find((bar) => bar.bar_type?.instance_id === 'one')?.message,
'new failure',
)
})
test('a new native task retires the previous failed task without a frontend retry', () => {
const state = createHostedDownloadFailures()
const old = {
fraction: null,
loader_uuid: 'old',
event: { type: 'hosted_pack_sync', instance_id: 'instance', error: 'old failure' },
}
state.update(old)
state.update({ fraction: 0, loader_uuid: 'new', event: { ...old.event, error: null } })
state.update(old)
assert.equal(state.has('instance'), false)
})
test('late failure from an older active task cannot replace the new task', () => {
const state = createHostedDownloadFailures()
const event = { type: 'hosted_pack_sync', instance_id: 'instance' }
state.update({ fraction: 0.5, loader_uuid: 'old', event })
state.update({ fraction: 0, loader_uuid: 'new', event })
state.update({ fraction: null, loader_uuid: 'old', event: { ...event, error: 'old failure' } })
assert.equal(state.has('instance'), false)
assert.equal(state.isRetired('old'), true)
assert.equal(state.isRetired('new'), false)
})

View File

@ -0,0 +1,53 @@
import type { LoadingBar } from './state.ts'
export function createHostedDownloadFailures() {
const failures = new Map<string, LoadingBar>()
const retired = new Set<string>()
const currentTasks = new Map<string, string>()
return {
values: () => [...failures.values()],
has: (instanceId: string) => failures.has(instanceId),
isRetired: (id: string) => retired.has(id),
begin(instanceId: string, bars: LoadingBar[]) {
const current = currentTasks.get(instanceId)
if (current) retired.add(current)
currentTasks.delete(instanceId)
const previous = failures.get(instanceId)
if (previous) retired.add(String(previous.loading_bar_uuid))
for (const bar of bars) {
if (bar.bar_type?.type === 'hosted_pack_sync' && bar.bar_type.instance_id === instanceId) {
retired.add(String(bar.loading_bar_uuid))
}
}
failures.delete(instanceId)
},
update(payload: {
fraction: number | null
loader_uuid: string
event: LoadingBar['bar_type']
}) {
const event = payload.event
if (
event?.type !== 'hosted_pack_sync' ||
!event.instance_id ||
retired.has(payload.loader_uuid)
)
return
const current = currentTasks.get(event.instance_id)
if (current && current !== payload.loader_uuid) retired.add(current)
currentTasks.set(event.instance_id, payload.loader_uuid)
if (payload.fraction === null && event.error) {
failures.set(event.instance_id, {
loading_bar_uuid: payload.loader_uuid,
bar_type: event,
title: event.instance_name,
message: event.error,
total: 0,
current: 0,
})
} else {
failures.delete(event.instance_id)
}
},
}
}

View File

@ -1,10 +1,79 @@
import { invoke } from '@tauri-apps/api/core'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
} from '../composables/skin-site-session.ts'
let sessionUpdate: Promise<void> = Promise.resolve()
const attemptListeners = new Set<(instanceId: string) => void>()
export function onHostedPackAttemptStarted(listener: (instanceId: string) => void) {
attemptListeners.add(listener)
return () => {
attemptListeners.delete(listener)
}
}
function startHostedPackAttempt(instanceId: string) {
for (const listener of attemptListeners) listener(instanceId)
}
export function clearHostedSession(): Promise<void> {
const update = sessionUpdate
.catch(() => {})
.then(() => invokeHosted<void>('plugin:install|hosted_set_session', { token: null }))
sessionUpdate = update
return update
}
export async function prepareHostedSession(): Promise<void> {
const userId = skinSiteUser.value?.uuid
const token = await requestSkinSiteDownloadToken()
const update = sessionUpdate
.catch(() => {})
.then(async () => {
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userId) {
throw new Error('StarLight 登录状态已变化,请重试。')
}
await invokeHosted<void>('plugin:install|hosted_set_session', { token })
})
sessionUpdate = update
await update
}
async function invokeWithSession<T>(command: string, args?: Record<string, unknown>): Promise<T> {
await prepareHostedSession()
return invokeHosted<T>(command, args)
}
async function invokeHosted<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args)
} catch (cause) {
if (
!(cause instanceof Error) &&
typeof cause === 'object' &&
cause !== null &&
'message' in cause &&
typeof cause.message === 'string'
) {
throw new Error(cause.message, { cause })
}
throw cause
}
}
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 })
invokeHosted<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
export const setInstanceMode = (instanceId: string, mode: InstanceMode) => {
if (mode === 'starlight') startHostedPackAttempt(instanceId)
return (mode === 'starlight' ? invokeWithSession<void> : invokeHosted<void>)(
'plugin:install|hosted_set_instance_mode',
{ instanceId, mode },
)
}
export interface HostedPublication {
packId: string
@ -26,8 +95,12 @@ export interface HostedSyncResult {
changedFiles: number
preservedFiles: string[]
}
export const hostedCatalog = () => invoke<HostedPublication[]>('plugin:install|hosted_catalog')
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = () => invokeWithSession<string>('plugin:install|hosted_create')
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 })
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string) => {
startHostedPackAttempt(instanceId)
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
}

View File

@ -17,6 +17,7 @@ import { collectGcContext } from '@/helpers/gc/context'
import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings'
@ -1073,6 +1074,7 @@ export async function run(
instanceId: string,
serverAddress: string | null = null,
): Promise<InstanceRunResult> {
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
instanceId,

View File

@ -6,6 +6,9 @@
import { invoke } from '@tauri-apps/api/core'
export interface LoadingBarType {
batch_id?: string
file_name?: string
error?: string | null
type?: string
version?: string
instance_id?: string
@ -25,12 +28,7 @@ export interface LoadingBar {
}
export type OpeningCommandEvent =
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
export interface OpeningCommand {
event: OpeningCommandEvent

View File

@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createTaggedModProgress } from './tagged-mod-progress.ts'
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
loader_uuid: id,
fraction,
message: 'Applying updates',
event: { type: 'hosted_pack_sync', instance_id: 'one', error },
})
const file = (id: string, fraction: number | null, batch = 'batch', error = '') => ({
loader_uuid: id,
fraction,
total: 100,
message: 'Downloading',
event: {
type: 'hosted_mod_download',
instance_id: 'one',
instance_name: 'Server',
batch_id: batch,
file_name: id,
error,
},
})
test('parallel files keep independent progress and wait for installation to complete', () => {
const state = createTaggedModProgress()
state.update(parent())
assert.equal(state.update(file('first', 0.25)), true)
assert.equal(state.update(file('second', 0.6)), false)
const group = state.groups.get('batch')!
assert.equal(group.files.get('first')!.current, 25)
assert.equal(group.files.get('second')!.current, 60)
state.update(file('first', null))
state.update(file('first', 0.1))
assert.equal(group.files.get('first')!.current, 100)
state.update(file('second', null))
assert.equal(group.done, false)
state.update(parent('parent', null))
assert.equal(group.done, true)
assert.equal(group.error, '')
})
test('failures retain downloaded bytes and retry discards all late events from the old attempt', () => {
const state = createTaggedModProgress()
state.update(parent())
state.update(file('first', 0.4))
state.update(file('first', null, 'batch', 'network failed'))
state.update(parent('parent', null, 'network failed'))
assert.equal(state.groups.get('batch')!.files.get('first')!.current, 40)
assert.equal(state.groups.get('batch')!.error, 'network failed')
state.reset('one')
state.update(parent('new-parent'))
state.update(file('new-file', 0.2, 'new-batch'))
state.update(parent('parent', 0.9))
state.update(parent('parent', null, 'old error'))
state.update(file('first', null))
assert.deepEqual([...state.groups.keys()], ['new-batch'])
assert.equal(state.groups.get('new-batch')!.done, false)
})
test('native retry and resetting one instance leave other instance downloads alone', () => {
const state = createTaggedModProgress()
state.update(parent())
state.update(file('first', 0))
const other = file('other', 0.8, 'other-batch')
other.event.instance_id = 'two'
state.update(other)
state.update(parent('new-parent'))
state.update(file('new-file', 0.3, 'new-batch'))
state.update(parent('parent', 0.5))
assert.deepEqual([...state.groups.keys()], ['other-batch', 'new-batch'])
state.reset('one')
assert.deepEqual([...state.groups.keys()], ['other-batch'])
})

View File

@ -0,0 +1,102 @@
import type { LoadingBarType } from './state.ts'
export interface TaggedProgressEvent {
loader_uuid: string
event?: LoadingBarType
fraction: number | null
total?: number | null
message: string
}
export interface TaggedProgressFile {
id: string
name: string
current: number
total: number
message: string
done: boolean
error: string
}
export interface TaggedProgressGroup {
id: string
instanceId: string
name: string
message: string
error: string
done: boolean
files: Map<string, TaggedProgressFile>
}
export function createTaggedModProgress() {
const groups = new Map<string, TaggedProgressGroup>()
const parents = new Map<string, string>()
const retiredParents = new Set<string>()
const retired = new Set<string>()
function reset(instanceId: string) {
const parent = parents.get(instanceId)
if (parent) retiredParents.add(parent)
parents.delete(instanceId)
for (const [id, group] of groups) {
if (group.instanceId === instanceId) {
retired.add(id)
groups.delete(id)
}
}
}
function update(payload: TaggedProgressEvent): boolean {
const event = payload.event
if (!event?.instance_id) return false
if (event.type === 'hosted_pack_sync') {
if (retiredParents.has(payload.loader_uuid)) return false
if (payload.fraction !== null && parents.get(event.instance_id) !== payload.loader_uuid) {
reset(event.instance_id)
parents.set(event.instance_id, payload.loader_uuid)
}
if (parents.get(event.instance_id) !== payload.loader_uuid) return false
for (const group of groups.values()) {
if (group.instanceId !== event.instance_id) continue
if (group.done) continue
group.message = payload.message
if (payload.fraction === null) {
group.done = true
group.error = event.error ?? ''
}
}
return false
}
if (event.type !== 'hosted_mod_download' || !event.batch_id || retired.has(event.batch_id))
return false
let group = groups.get(event.batch_id)
const isNew = !group
if (!group) {
group = {
id: event.batch_id,
instanceId: event.instance_id,
name: event.instance_name ?? '',
message: '',
error: '',
done: false,
files: new Map(),
}
groups.set(group.id, group)
}
const previous = group.files.get(payload.loader_uuid)
if (previous?.done) return false
const total = Math.max(0, payload.total ?? previous?.total ?? 0)
group.files.set(payload.loader_uuid, {
id: payload.loader_uuid,
name: event.file_name ?? '',
total,
current:
payload.fraction === null
? event.error
? (previous?.current ?? 0)
: total
: Math.max(0, Math.min(1, payload.fraction)) * total,
message: payload.message,
done: payload.fraction === null,
error: event.error ?? '',
})
return isNew
}
return { groups, reset, update }
}

View File

@ -1,20 +1,33 @@
{
"app.hosted-mods.title": { "message": "Updating server Mods" },
"app.hosted-mods.description": { "message": "The game starts after all required updates have been installed." },
"app.hosted-mods.complete": { "message": "Downloaded" },
"app.hosted-mods.downloads": { "message": "View downloads" },
"app.hosted-mods.close": { "message": "Close" },
"app.hosted-packs.progress.retry": { "message": "Return to the instance to retry" },
"app.hosted-packs.progress.preparing": { "message": "Preparing modpack installation…" },
"app.hosted-packs.progress.downloads": { "message": "View downloads" },
"app.hosted-packs.open-instance": { "message": "Open installed instance" },
"app.hosted-packs.retry-install": { "message": "Retry installation" },
"app.hosted-packs.auto-installing": { "message": "Downloading and installing the server modpack…" },
"app.hosted-packs.auto-description": { "message": "Download and automatically install the modpack from StarLight to play on the StarLight server with one click." },
"app.hosted-packs.auto-install": { "message": "Install the StarLight modpack" },
"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.onboarding.instance-mode.description": { "message": "StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player." },
"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.starlight-description": { "message": "Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting." },
"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.saving": { "message": "Applying instance type and installing the server modpack when needed…" },
"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.title": { "message": "Server-managed modpack" },
"app.hosted-packs.description": { "message": "The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required." },
"app.hosted-packs.refresh": {"message": "Refresh"},
"app.hosted-packs.loading": {"message": "Loading published modpacks…"},
"app.hosted-packs.bound": {"message": "Installed: {name} · {version}"},
@ -23,7 +36,7 @@
"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.install": { "message": "Retry automatic installation" },
"app.hosted-packs.management": {"message": "Content management"},
"app.hosted-packs.mods": {"message": "Manage mods"},
"app.hosted-packs.packs": {"message": "Manage modpacks"},
@ -5721,12 +5734,6 @@
"app.notification.warning": {
"message": "Warning"
},
"app.onboarding.account.description": {
"message": "When you are ready, sign in, switch accounts, or open your profile here. No deadline."
},
"app.onboarding.account.title": {
"message": "Accounts, on your schedule"
},
"app.onboarding.action.click-create": {
"message": "Click Create new instance to continue"
},
@ -5763,33 +5770,12 @@
"app.onboarding.action.finish-area": {
"message": "Click anywhere and you are all set"
},
"app.onboarding.action.open-gradient-text": {
"message": "Open Gradient text generator to continue"
},
"app.onboarding.action.open-recipe-generator": {
"message": "Open Recipe generator to continue"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "Open Schematic workshop to continue"
},
"app.onboarding.action.open-seed-map": {
"message": "Open Seed map to continue"
},
"app.onboarding.action.return-lab": {
"message": "Click Lab to continue"
},
"app.onboarding.action.skip": {
"message": "Leave the tour"
},
"app.onboarding.action.start": {
"message": "Take the tour"
},
"app.onboarding.ai.description": {
"message": "Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place."
},
"app.onboarding.ai.title": {
"message": "Bring your own AI provider"
},
"app.onboarding.appearance.description": {
"message": "Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar."
},
@ -5868,23 +5854,11 @@
"app.onboarding.favorites.title": {
"message": "Keep a short list"
},
"app.onboarding.home-customize.description": {
"message": "Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells."
},
"app.onboarding.home-customize.title": {
"message": "Arrange it your way"
},
"app.onboarding.home-layout.description": {
"message": "Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home."
"message": "Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher."
},
"app.onboarding.home-layout.title": {
"message": "Change the amount of detail"
},
"app.onboarding.home-widgets.description": {
"message": "Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes."
},
"app.onboarding.home-widgets.title": {
"message": "Your Home, your layout"
"message": "Switch your home"
},
"app.onboarding.instance-actions.description": {
"message": "Launch, stop, repair, configure, export, or open the instance from its header."
@ -5898,56 +5872,20 @@
"app.onboarding.instance-tabs.title": {
"message": "The rest of the workshop"
},
"app.onboarding.java.description": {
"message": "The Java runtimes that start Minecraft live here. Technical, but well-behaved."
},
"app.onboarding.java.title": {
"message": "Java, under the hood"
},
"app.onboarding.lab-editor.description": {
"message": "Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects."
},
"app.onboarding.lab-editor.title": {
"message": "Build and copy in one place"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "Pick a Java version, fill the recipe slots, and copy or export the JSON locally."
},
"app.onboarding.lab-recipe-generator.title": {
"message": "Craft data pack recipes"
},
"app.onboarding.lab-schematic.description": {
"message": "Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together."
},
"app.onboarding.lab-schematic.title": {
"message": "Inspect a build before placing it"
},
"app.onboarding.lab-seed-map.description": {
"message": "Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map."
},
"app.onboarding.lab-seed-map.title": {
"message": "Find a world before you load it"
},
"app.onboarding.lab-tools.description": {
"message": "Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher."
"message": "Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally."
},
"app.onboarding.lab-tools.title": {
"message": "Local tools for Minecraft"
},
"app.onboarding.lab.description": {
"message": "The Lab keeps local Minecraft tools inside the launcher, without another website or account."
"message": "The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher."
},
"app.onboarding.lab.title": {
"message": "Useful tools, built in"
},
"app.onboarding.language.description": {
"message": "Pick the launcher language and manage translations. No decoder ring required."
},
"app.onboarding.language.title": {
"message": "Speak your language"
},
"app.onboarding.library-page.description": {
"message": "Filter by modpack, server, or custom setup, then open any instance to manage it."
"message": "Switch between all instances, modpacks, and custom setups, then open any instance to manage it."
},
"app.onboarding.library-page.title": {
"message": "Everything, in its place"
@ -5974,29 +5912,17 @@
"message": "Make it yours"
},
"app.onboarding.skins-page.description": {
"message": "Add, preview, sort, and apply skins here. No pressure to sign in just yet."
"message": "Select a profile, preview its available skins, and apply changes when that account supports skin management."
},
"app.onboarding.skins-page.title": {
"message": "Your skin drawer"
},
"app.onboarding.skins.description": {
"message": "Keep your Minecraft skins together. Signing in can wait until you feel like it."
"message": "Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile."
},
"app.onboarding.skins.title": {
"message": "A new look, maybe"
},
"app.onboarding.translation.description": {
"message": "Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character."
},
"app.onboarding.translation.title": {
"message": "Translation, the Starlight way"
},
"app.onboarding.updates.description": {
"message": "Choose when Starlight checks for updates and whether it installs them for you."
},
"app.onboarding.updates.title": {
"message": "Stay in the loop"
},
"app.onboarding.welcome.description": {
"message": "Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in."
},

View File

@ -1,12 +1,25 @@
{
"app.hosted-mods.title": { "message": "正在更新服务器 Mod" },
"app.hosted-mods.description": { "message": "所有更新安装完成后将继续启动游戏。" },
"app.hosted-mods.complete": { "message": "下载完成" },
"app.hosted-mods.downloads": { "message": "查看下载" },
"app.hosted-mods.close": { "message": "关闭" },
"app.hosted-packs.progress.retry": { "message": "返回实例重试" },
"app.hosted-packs.progress.preparing": { "message": "正在准备整合包安装…" },
"app.hosted-packs.progress.downloads": { "message": "查看下载" },
"app.hosted-packs.open-instance": { "message": "打开已安装实例" },
"app.hosted-packs.retry-install": { "message": "重试安装" },
"app.hosted-packs.auto-installing": { "message": "正在下载并安装服务器整合包…" },
"app.hosted-packs.auto-description": { "message": "从StarLight服务器获取整合包并自动安装可一键游玩StarLight服务器" },
"app.hosted-packs.auto-install": { "message": "安装 StarLight 官方整合包" },
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
"app.onboarding.instance-mode.description": { "message": "游玩 StarLight 服务器请选择 StarLight 实例,它会自动同步管理员批准的更新。本地实例跳过 StarLight 同步,启动更快,可自选整合包,适合第三方服务器与单人游玩。之后也可以在实例设置中切换。" },
"app.onboarding.instance-mode.description": { "message": "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.starlight-description": { "message": "游玩 StarLight 服务器必选。自动安装服务器指定的整合包、游戏版本和加载器。每次启动需要登录 StarLight检查并完成更新后再进入游戏。" },
"app.instance-mode.local-description": { "message": "不会同步 StarLight 服务器的变更。跳过同步检查,启动更快,可自选整合包安装,更适合第三方服务器与本地个人游玩。" },
"app.instance-mode.saving": { "message": "正在保存实例类型…" },
"app.instance-mode.saving": { "message": "正在应用实例类型,并按需安装服务器整合包…" },
"app.instance-mode.loading": { "message": "正在读取实例类型…" },
"app.instance-mode.retry": { "message": "重试" },
"app.instance-mode.local-packs-title": { "message": "本地整合包" },
@ -16,8 +29,8 @@
"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.title": { "message": "服务器管理的整合包" },
"app.hosted-packs.description": { "message": "整合包和版本由管理员指定。每次启动都会检查服务器更新,有变更时先下载并安装,再启动游戏。需要有效的 StarLight 登录状态和网络连接。" },
"app.hosted-packs.refresh": {"message": "刷新"},
"app.hosted-packs.loading": {"message": "正在获取已发布整合包…"},
"app.hosted-packs.bound": {"message": "已安装:{name} · {version}"},
@ -26,7 +39,7 @@
"app.hosted-packs.preserved": {"message": "保留了 {count} 个本地修改或个人文件"},
"app.hosted-packs.empty": {"message": "暂无审核通过的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": {"message": "安装并启用自动同步"},
"app.hosted-packs.install": { "message": "重试自动安装" },
"app.home.luck.title": { "message": "每日幸运指数" },
"app.home.luck.get": { "message": "一键获取" },
"app.home.luck.login-to-get": { "message": "登录后获取" },
@ -5807,12 +5820,6 @@
"app.notification.warning": {
"message": "警告"
},
"app.onboarding.account.description": {
"message": "想登录、切换账号或看看个人资料,随时来这里。没人打卡。"
},
"app.onboarding.account.title": {
"message": "账号,随你安排"
},
"app.onboarding.action.click-create": {
"message": "点击创建新实例,继续"
},
@ -5849,33 +5856,12 @@
"app.onboarding.action.finish-area": {
"message": "点击任意位置,收工"
},
"app.onboarding.action.open-gradient-text": {
"message": "打开渐变文字生成器,继续"
},
"app.onboarding.action.open-recipe-generator": {
"message": "打开配方生成器,继续"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "打开投影工坊,继续"
},
"app.onboarding.action.open-seed-map": {
"message": "打开种子地图,继续"
},
"app.onboarding.action.return-lab": {
"message": "点击实验室,继续"
},
"app.onboarding.action.skip": {
"message": "先不逛了"
},
"app.onboarding.action.start": {
"message": "带我逛逛"
},
"app.onboarding.ai.description": {
"message": "统一连接文本模型供应商、选择要使用的模型,也可在这里一键关闭所有 AI 功能。"
},
"app.onboarding.ai.title": {
"message": "连接你的 AI 供应商"
},
"app.onboarding.appearance.description": {
"message": "主题、强调色、背景和窗口效果都在这里。把启动器调成你熟悉的样子。"
},
@ -5954,23 +5940,11 @@
"app.onboarding.favorites.title": {
"message": "留个清单"
},
"app.onboarding.home-customize.description": {
"message": "使用右下角的编辑控件添加、调整和配置小组件。编辑时可在自动排满的网格与保留空格的自由网格之间切换。"
},
"app.onboarding.home-customize.title": {
"message": "按你的方式排列"
},
"app.onboarding.home-layout.description": {
"message": "使用右下角控件切换信息主页和极简主页;小组件编辑仅在信息主页中提供。"
"message": "使用右下角控件在 StarLight 皮肤站主页与专注的实例启动页之间切换。"
},
"app.onboarding.home-layout.title": {
"message": "切换信息密度"
},
"app.onboarding.home-widgets.description": {
"message": "信息主页由最近活动、游玩时间、实例、世界和服务器小组件组成。窗口或账号侧边栏变化时,网格会自动重排。"
},
"app.onboarding.home-widgets.title": {
"message": "你的主页,你的布局"
"message": "切换主页样式"
},
"app.onboarding.instance-actions.description": {
"message": "从实例页头部启动、停止、修复、配置、导出或打开这个实例。"
@ -5984,56 +5958,20 @@
"app.onboarding.instance-tabs.title": {
"message": "剩下的工作台"
},
"app.onboarding.java.description": {
"message": "启动 Minecraft 的 Java 运行环境在这。技术活,但它很听话。"
},
"app.onboarding.java.title": {
"message": "Java在后台"
},
"app.onboarding.lab-editor.description": {
"message": "编辑文本、选择颜色、实时预览,并复制适用于 Minecraft 配置的格式。"
},
"app.onboarding.lab-editor.title": {
"message": "在同一处编辑和复制"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "选择 Java 版本、填充配方槽位,然后在本地复制或导出 JSON。"
},
"app.onboarding.lab-recipe-generator.title": {
"message": "制作数据包配方"
},
"app.onboarding.lab-schematic.description": {
"message": "打开本地 .litematic 或 .schem 文件也可以从已安装实例中选择。3D 工作区集中提供查看、测量、图层控制、材料统计和本地编辑。"
},
"app.onboarding.lab-schematic.title": {
"message": "放置前先检查建筑"
},
"app.onboarding.lab-seed-map.description": {
"message": "输入种子或从实例载入,然后在本地地图中查看群系、结构与矿物图层。"
},
"app.onboarding.lab-seed-map.title": {
"message": "加载世界前先找到它"
},
"app.onboarding.lab-tools.description": {
"message": "生成格式文字、探索 Java 世界,并直接在启动器中检查投影建筑。"
"message": "在本地编辑皮肤、生成格式文字与配方、探索种子、检查投影并翻译模组。"
},
"app.onboarding.lab-tools.title": {
"message": "Minecraft 本地工具"
},
"app.onboarding.lab.description": {
"message": "实验室将本地 Minecraft 工具放进启动器,无需打开其他网站或注册额外账号。"
"message": "实验室 Minecraft 创作、世界与维护工具集中放在启动器里。"
},
"app.onboarding.lab.title": {
"message": "内置的实用工具"
},
"app.onboarding.language.description": {
"message": "在这里选择启动器语言和管理翻译。不需要翻译腔。"
},
"app.onboarding.language.title": {
"message": "说你的语言"
},
"app.onboarding.library-page.description": {
"message": "按整合包、服务器或自定义配置筛选,再打开任意实例管理。"
"message": "在全部实例、整合包和自定义配置之间切换,再打开任意实例进行管理。"
},
"app.onboarding.library-page.title": {
"message": "各就各位"
@ -6060,29 +5998,17 @@
"message": "按你的方式使用"
},
"app.onboarding.skins-page.description": {
"message": "在这里添加、预览、排序和应用皮肤。现在不登录也完全没关系。"
"message": "选择玩家资料,预览可用皮肤;账号支持皮肤管理时,还可以直接应用更改。"
},
"app.onboarding.skins-page.title": {
"message": "你的皮肤抽屉"
},
"app.onboarding.skins.description": {
"message": " Minecraft 皮肤放在一起管理。想登录的时候再登录。"
"message": "选择皮肤站玩家或 Minecraft 账号,然后预览并应用该玩家可用的皮肤。"
},
"app.onboarding.skins.title": {
"message": "换个造型吧"
},
"app.onboarding.translation.description": {
"message": "浏览时直接翻译 Modrinth 项目的标题、摘要和介绍。保留原文、双语同屏,或者让译文站 C 位,都由你决定。"
},
"app.onboarding.translation.title": {
"message": "翻译,是 Starlight 的拿手活"
},
"app.onboarding.updates.description": {
"message": "决定 Starlight 何时检查更新,以及是否替你安装。"
},
"app.onboarding.updates.title": {
"message": "保持在线"
},
"app.onboarding.welcome.description": {
"message": "实例、内容、世界和下载,现在都有了同一个家。正式开玩之前,先快速逛一圈。"
},

View File

@ -1,12 +1,25 @@
{
"app.hosted-mods.title": { "message": "正在更新伺服器 Mod" },
"app.hosted-mods.description": { "message": "所有更新安裝完成後將繼續啟動遊戲。" },
"app.hosted-mods.complete": { "message": "下載完成" },
"app.hosted-mods.downloads": { "message": "查看下載" },
"app.hosted-mods.close": { "message": "關閉" },
"app.hosted-packs.progress.retry": { "message": "返回實例重試" },
"app.hosted-packs.progress.preparing": { "message": "正在準備整合包安裝…" },
"app.hosted-packs.progress.downloads": { "message": "查看下載" },
"app.hosted-packs.open-instance": { "message": "開啟已安裝實例" },
"app.hosted-packs.retry-install": { "message": "重試安裝" },
"app.hosted-packs.auto-installing": { "message": "正在下載並安裝伺服器整合包…" },
"app.hosted-packs.auto-description": { "message": "從StarLight伺服器取得整合包並自動安裝可一鍵遊玩StarLight伺服器" },
"app.hosted-packs.auto-install": { "message": "安裝 StarLight 官方整合包" },
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
"app.onboarding.instance-mode.description": { "message": "遊玩 StarLight 伺服器請選擇 StarLight 實例,它會自動同步管理員核准的更新。本地實例跳過 StarLight 同步,啟動更快,可自行選擇整合包,適合第三方伺服器與單人遊玩。之後也可以在實例設定中切換。" },
"app.onboarding.instance-mode.description": { "message": "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.starlight-description": { "message": "遊玩 StarLight 伺服器必選。自動安裝伺服器指定的整合包、遊戲版本和載入器。每次啟動需要登入 StarLight檢查並完成更新後再進入遊戲。" },
"app.instance-mode.local-description": { "message": "不會同步 StarLight 伺服器的變更。略過同步檢查,啟動更快,可自行選擇整合包安裝,更適合第三方伺服器與本機個人遊玩。" },
"app.instance-mode.saving": { "message": "正在儲存實例類型…" },
"app.instance-mode.saving": { "message": "正在套用實例類型,並視需要安裝伺服器整合包…" },
"app.instance-mode.loading": { "message": "正在讀取實例類型…" },
"app.instance-mode.retry": { "message": "重試" },
"app.instance-mode.local-packs-title": { "message": "本機整合包" },
@ -16,8 +29,8 @@
"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.title": { "message": "伺服器管理的整合包" },
"app.hosted-packs.description": { "message": "整合包和版本由管理員指定。每次啟動都會檢查伺服器更新,有變更時先下載並安裝,再啟動遊戲。需要有效的 StarLight 登入狀態和網路連線。" },
"app.hosted-packs.refresh": {"message": "重新整理"},
"app.hosted-packs.loading": {"message": "正在取得已發布整合包…"},
"app.hosted-packs.bound": {"message": "已安裝:{name} · {version}"},
@ -26,7 +39,7 @@
"app.hosted-packs.preserved": {"message": "保留了 {count} 個本機修改或個人檔案"},
"app.hosted-packs.empty": {"message": "尚無審核通過的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": {"message": "安裝並啟用自動同步"},
"app.hosted-packs.install": { "message": "重試自動安裝" },
"app.home.luck.title": { "message": "每日幸運指數" },
"app.home.luck.get": { "message": "一鍵獲取" },
"app.home.luck.login-to-get": { "message": "登入後獲取" },
@ -5528,12 +5541,6 @@
"app.notification.warning": {
"message": "警告"
},
"app.onboarding.account.description": {
"message": "想登錄、切換帳號或看看個人資料,隨時來這裡。沒人打卡。"
},
"app.onboarding.account.title": {
"message": "帳號,隨你安排"
},
"app.onboarding.action.click-create": {
"message": "點擊創建新實例,繼續"
},
@ -5570,33 +5577,12 @@
"app.onboarding.action.finish-area": {
"message": "點擊任意位置,收工"
},
"app.onboarding.action.open-gradient-text": {
"message": "打開漸變文字生成器,繼續"
},
"app.onboarding.action.open-recipe-generator": {
"message": "開啟配方生成器,繼續"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "開啟投影工坊,繼續"
},
"app.onboarding.action.open-seed-map": {
"message": "打開種子地圖,繼續"
},
"app.onboarding.action.return-lab": {
"message": "點擊實驗室,查看另一個項目"
},
"app.onboarding.action.skip": {
"message": "先不逛了"
},
"app.onboarding.action.start": {
"message": "帶我逛逛"
},
"app.onboarding.ai.description": {
"message": "統一連線文字模型供應商、選擇要使用的模型,也可在這裡一鍵關閉所有 AI 功能。"
},
"app.onboarding.ai.title": {
"message": "連線你的 AI 供應商"
},
"app.onboarding.appearance.description": {
"message": "Theme, accent, background, and window behavior. Make this launcher look familiar."
},
@ -5675,23 +5661,11 @@
"app.onboarding.favorites.title": {
"message": "留個清單"
},
"app.onboarding.home-customize.description": {
"message": "使用右下角的編輯控制元件新增、調整和配置小元件。編輯時可在自動排滿的網格與保留空格的自由網格之間切換。"
},
"app.onboarding.home-customize.title": {
"message": "按你的方式排列"
},
"app.onboarding.home-layout.description": {
"message": "隨時使用這個懸浮按鈕,在資訊首頁和極簡首頁之間切換。"
"message": "使用右下角控制項,在 StarLight 皮膚站首頁與專注的實例啟動頁之間切換。"
},
"app.onboarding.home-layout.title": {
"message": "切換資訊密度"
},
"app.onboarding.home-widgets.description": {
"message": "資訊主頁由最近活動、遊玩時間、例項、世界和伺服器小元件組成。視窗或賬號側邊欄變化時,網格會自動重排。"
},
"app.onboarding.home-widgets.title": {
"message": "你的主頁,你的佈局"
"message": "切換首頁樣式"
},
"app.onboarding.instance-actions.description": {
"message": "從實例頁頭部啟動、停止、修復、配置、導出或打開這個實例。"
@ -5705,56 +5679,20 @@
"app.onboarding.instance-tabs.title": {
"message": "剩下的工作台"
},
"app.onboarding.java.description": {
"message": "啟動 Minecraft 的 Java 運行環境在這。技術活,但它很聽話。"
},
"app.onboarding.java.title": {
"message": "Java在後台"
},
"app.onboarding.lab-editor.description": {
"message": "編輯文本、選擇顏色、即時預覽,並複製適用於 Minecraft 配置的格式。"
},
"app.onboarding.lab-editor.title": {
"message": "在同一處編輯和複製"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "選擇 Java 版本、填充配方槽位,然後在本地複製或匯出 JSON。"
},
"app.onboarding.lab-recipe-generator.title": {
"message": "製作資料包配方"
},
"app.onboarding.lab-schematic.description": {
"message": "開啟本地 .litematic 或 .schem 檔案也可以從已安裝例項中選擇。3D 工作區集中提供檢視、測量、圖層控制、材料統計和本地編輯。"
},
"app.onboarding.lab-schematic.title": {
"message": "放置前先檢查建築"
},
"app.onboarding.lab-seed-map.description": {
"message": "輸入種子或從實例載入,然後在本地地圖中查看群系、結構與礦物圖層。"
},
"app.onboarding.lab-seed-map.title": {
"message": "載入世界前先找到它"
},
"app.onboarding.lab-tools.description": {
"message": "生成格式的漸變文字,或用本地種子地圖探索 Java 世界。"
"message": "在本機編輯皮膚、生成格式文字與配方、探索種子、檢查投影並翻譯模組。"
},
"app.onboarding.lab-tools.title": {
"message": "首批兩個項目"
"message": "Minecraft 本機工具"
},
"app.onboarding.lab.description": {
"message": "實驗室將本地 Minecraft 工具放進啟動器,無需打開其他網站或註冊額外帳號。"
"message": "實驗室 Minecraft 創作、世界與維護工具集中放在啟動器裡。"
},
"app.onboarding.lab.title": {
"message": "內建的實用工具"
},
"app.onboarding.language.description": {
"message": "在這裡選擇啟動器語言和管理翻譯。不需要翻譯腔。"
},
"app.onboarding.language.title": {
"message": "說你的語言"
},
"app.onboarding.library-page.description": {
"message": "按整合包、伺服器或自訂配置篩選,再打開任意實例管理。"
"message": "在全部實例、整合包與自訂配置之間切換,再開啟任意實例進行管理。"
},
"app.onboarding.library-page.title": {
"message": "各就各位"
@ -5781,29 +5719,17 @@
"message": "按你的方式使用"
},
"app.onboarding.skins-page.description": {
"message": "在這裡添加、預覽、排序和應用皮膚。現在不登錄也完全沒關係。"
"message": "選擇玩家資料並預覽可用皮膚;帳號支援皮膚管理時,還可以直接套用變更。"
},
"app.onboarding.skins-page.title": {
"message": "你的皮膚抽屜"
},
"app.onboarding.skins.description": {
"message": " Minecraft 皮膚放在一起管理。想登錄的時候再登錄。"
"message": "選擇皮膚站玩家或 Minecraft 帳號,然後預覽並套用該玩家可用的皮膚。"
},
"app.onboarding.skins.title": {
"message": "換個造型吧"
},
"app.onboarding.translation.description": {
"message": "瀏覽時直接翻譯 Modrinth 項目的標題、摘要和介紹。保留原文、雙語同屏,或者讓譯文站 C 位,都由你決定。"
},
"app.onboarding.translation.title": {
"message": "翻譯,是 Starlight 的拿手活"
},
"app.onboarding.updates.description": {
"message": "決定 Starlight 何時檢查更新,以及是否替你安裝。"
},
"app.onboarding.updates.title": {
"message": "保持在線"
},
"app.onboarding.welcome.description": {
"message": "實例、內容、世界和下載,現在都有了同一個家。正式開玩之前,先快速逛一圈。"
},

View File

@ -1,25 +1,61 @@
<script setup lang="ts">
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref } from 'vue'
import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
import { useHostedCreation } from '@/composables/useHostedCreation'
const { formatMessage } = useVIntl()
const router = useRouter()
const instanceMode = ref<InstanceMode>('local')
const { installing, installError, createdInstance, completed, acknowledge, install } =
useHostedCreation()
const instanceMode = ref<InstanceMode>(
installing.value || createdInstance.value ? 'starlight' : 'local',
)
async function openCompleted(instanceId: string) {
try {
const failure = await router.push(`/instance/${encodeURIComponent(instanceId)}/`)
if (!failure) acknowledge(instanceId)
else installError.value = failure.message
} catch (cause) {
installError.value = String(cause)
}
}
watch(
() => (completed.value ? createdInstance.value : undefined),
(instanceId) => {
if (instanceId) void openCompleted(instanceId)
},
{ immediate: true },
)
const showModal = inject<
(options?: {
skipSetupType?: boolean
initialMode?: 'custom' | 'import'
instanceMode?: InstanceMode
onBack?: () => void
}) => void
>('showCreationModalWithOptions')
const messages = defineMessages({
openInstance: { id: 'app.hosted-packs.open-instance', defaultMessage: 'Open installed instance' },
autoInstall: {
id: 'app.hosted-packs.auto-install',
defaultMessage: 'Install the StarLight modpack',
},
autoDescription: {
id: 'app.hosted-packs.auto-description',
defaultMessage:
'Download and automatically install the modpack from StarLight to play on the StarLight server with one click.',
},
autoInstalling: {
id: 'app.hosted-packs.auto-installing',
defaultMessage: 'Downloading and installing the server modpack…',
},
retryInstall: { id: 'app.hosted-packs.retry-install', defaultMessage: 'Retry installation' },
title: {
id: 'create.title',
defaultMessage: 'Create Instance',
@ -60,11 +96,19 @@ const messages = defineMessages({
const navigateBack = () => router.push('/library')
function handleStartFresh() {
async function handleStartFresh() {
if (installing.value) return
if (instanceMode.value === 'starlight') {
if (completed.value && createdInstance.value) {
await openCompleted(createdInstance.value)
return
}
await install()
return
}
showModal?.({
skipSetupType: true,
initialMode: 'custom',
instanceMode: instanceMode.value,
onBack: () => router.push('/create'),
})
}
@ -73,7 +117,6 @@ function handleImportExisting() {
showModal?.({
skipSetupType: true,
initialMode: 'import',
instanceMode: 'local',
onBack: () => router.push('/create'),
})
}
@ -91,13 +134,36 @@ function handleImportExisting() {
</p>
</div>
<InstanceModeOptions v-model="instanceMode" data-onboarding-id="creation-instance-mode" />
<InstanceModeOptions
v-model="instanceMode"
:disabled="installing"
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"
:data-onboarding-id="
instanceMode === 'starlight' ? 'creation-method-starlight' : 'creation-method-custom'
"
:disabled="installing"
:icon="SparklesIcon"
:title="formatMessage(messages.newTitle)"
:description="formatMessage(messages.newDescription)"
:title="
formatMessage(
instanceMode === 'starlight'
? installing
? messages.autoInstalling
: completed
? messages.openInstance
: createdInstance
? messages.retryInstall
: messages.autoInstall
: messages.newTitle,
)
"
:description="
formatMessage(
instanceMode === 'starlight' ? messages.autoDescription : messages.newDescription,
)
"
no-icon-box
@click="handleStartFresh"
/>
@ -113,7 +179,9 @@ function handleImportExisting() {
/>
</div>
<p class="m-0 text-sm text-secondary">
<HostedPackProgress :instance-id="createdInstance" :active="installing" />
<p v-if="installError" class="m-0 text-red" role="alert">{{ installError }}</p>
<p v-if="instanceMode === 'local'" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.pclHmclHint) }}
{{ ' ' }}
<RouterLink

View File

@ -72,22 +72,38 @@
</div>
<div class="min-w-0 flex-grow">
<div class="truncate font-semibold text-contrast">{{ bar.title || bar.message }}</div>
<div class="truncate text-sm text-secondary">{{ bar.message }}</div>
<div class="break-words text-sm text-secondary">{{ bar.message }}</div>
</div>
<TagItem>
<TagItem
v-if="['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? '')"
>StarLight</TagItem
>
<TagItem v-else>
<component :is="providerIcon(legacyProvider(bar))" />
{{ providerLabel(legacyProvider(bar)) }}
</TagItem>
<Badge color="orange" :type="statusLabel('running')" />
<Badge
:color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
/>
</div>
<ProgressBar
v-if="!bar.bar_type?.error"
class="mt-4"
full-width
:progress="legacyPercent(bar)"
:max="100"
:label="formatMessage(messages.progress)"
show-progress
:waiting="!bar.total"
:show-progress="Boolean(bar.total)"
/>
<RouterLink
v-else
:to="`/instance/${encodeURIComponent(bar.bar_type?.instance_id ?? '')}/mods`"
class="mt-3 inline-block text-brand hover:underline"
>
{{ formatMessage(messages.hostedRetry) }}
</RouterLink>
</Card>
<Card
@ -140,10 +156,7 @@
v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
>
<template
v-for="(metric, index) in downloadDetails(job)"
:key="`${index}-${metric}`"
>
<template v-for="(metric, index) in downloadDetails(job)" :key="`${index}-${metric}`">
<BulletDivider v-if="index > 0" />
<span>{{ metric }}</span>
</template>
@ -389,10 +402,10 @@
<Card v-else class="flex flex-1">
<EmptyState
class="my-auto"
:type="query ? 'no-search-result' : 'no-tasks'"
:heading="formatMessage(query ? messages.noResultsTitle : messages.emptyTitle)"
:type="hasFilters ? 'no-search-result' : 'no-tasks'"
:heading="formatMessage(hasFilters ? messages.noResultsTitle : messages.emptyTitle)"
:description="
formatMessage(query ? messages.noResultsDescription : messages.emptyDescription)
formatMessage(hasFilters ? messages.noResultsDescription : messages.emptyDescription)
"
/>
</Card>
@ -503,6 +516,10 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' },
@ -727,10 +744,22 @@ const phaseMessages = defineMessages({
rolling_back: { id: 'app.downloads.phase.rolling-back', defaultMessage: 'Rolling back changes' },
} satisfies Record<InstallPhaseId, MessageDescriptor>)
const legacyDownloads = manager.legacyDownloads
const legacyDownloads = computed(() => {
const normalized = query.value.trim().toLowerCase()
return manager.legacyDownloads.value.filter((bar) => {
if (provider.value !== 'all' && legacyProvider(bar) !== provider.value) return false
return (
!normalized ||
[bar.title, bar.message, bar.bar_type?.instance_name, bar.bar_type?.file_name].some((value) =>
value?.toLowerCase().includes(normalized),
)
)
})
})
const historyJobs = manager.historyJobs
const providerOptions = [
'all',
'starlight',
'modrinth',
'curse_forge',
'minecraft',
@ -739,6 +768,12 @@ const providerOptions = [
'local',
]
const historyStatusOptions = ['all', 'succeeded', 'failed', 'interrupted', 'canceled']
const hasFilters = computed(
() =>
Boolean(query.value.trim()) ||
provider.value !== 'all' ||
(tab.value === 'history' && historyStatus.value !== 'all'),
)
const downloadTabs = computed(() => [
{
href: 'active',
@ -786,8 +821,9 @@ function displayIcon(icon: string) {
return /^(https?:|data:|blob:|asset:|tauri:)/.test(icon) ? icon : convertFileSrc(icon)
}
function providerLabel(value: InstallJobSnapshot['provider']) {
function providerLabel(value: InstallJobSnapshot['provider'] | 'starlight') {
return {
starlight: 'StarLight',
modrinth: 'Modrinth',
curse_forge: 'CurseForge',
minecraft: 'Minecraft',
@ -800,10 +836,10 @@ function providerLabel(value: InstallJobSnapshot['provider']) {
function providerFilterLabel(value: string) {
return value === 'all'
? formatMessage(messages.allSources)
: providerLabel(value as InstallJobSnapshot['provider'])
: providerLabel(value as InstallJobSnapshot['provider'] | 'starlight')
}
function providerIcon(value: InstallJobSnapshot['provider']) {
function providerIcon(value: InstallJobSnapshot['provider'] | 'starlight') {
return value === 'curse_forge'
? CurseForgeIcon
: value === 'modrinth'
@ -821,7 +857,9 @@ function jobTypeIcon(job: InstallJobSnapshot) {
return job.kind === 'upgrade_unmanaged_instance' ? RefreshCwIcon : providerIcon(job.provider)
}
function legacyProvider(bar: LoadingBar): InstallJobSnapshot['provider'] {
function legacyProvider(bar: LoadingBar): InstallJobSnapshot['provider'] | 'starlight' {
if (['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? ''))
return 'starlight'
if (bar.bar_type?.type === 'pack_download' || bar.bar_type?.type === 'pack_file_download')
return 'curse_forge'
if (bar.bar_type?.type === 'minecraft_download') return 'minecraft'
@ -1254,14 +1292,12 @@ async function resolveMissing(job: InstallJobSnapshot) {
(item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
)
.map(
(item): CurseForgeManualDownloadItem => ({
.map((item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}),
)
}))
const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {

View File

@ -417,6 +417,7 @@ import {
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useInstanceMode } from '@/composables/useInstanceMode'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { get_project_v3 } from '@/helpers/cache.js'
@ -514,6 +515,7 @@ const { offline } = useNetworkStatus()
const instance = ref<GameInstance>()
const instanceId = computed(() => instance.value?.id)
const instanceModeQuery = useInstanceMode(() => instance.value?.id ?? props.id)
const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ?? props.id)
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
@ -532,7 +534,9 @@ const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const canUpgradeInstance = computed(() =>
instance.value ? isUnmanagedUpgradeEligible(instance.value) : false,
instance.value && instanceModeQuery.data.value === 'local'
? isUnmanagedUpgradeEligible(instance.value)
: false,
)
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
@ -774,7 +778,6 @@ const startInstance = async (context: string) => {
launchElapsedTimer = undefined
loading.value = false
}
}
const stopInstance = async (context: string) => {

View File

@ -2,7 +2,15 @@ import { createContext } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref } from 'vue'
import { setCurseForgeManualDownloads } from '@/helpers/curseforge-manual'
import { download_request_listener, install_job_listener, loading_listener } from '@/helpers/events'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { createHostedDownloadFailures } from '@/helpers/hosted-download-failures'
import { forgetHostedCreation } from '@/composables/useHostedCreation'
import {
download_request_listener,
install_job_listener,
loading_listener,
instance_listener,
} from '@/helpers/events'
import {
download_history_clear,
download_job_cancel,
@ -21,6 +29,8 @@ import { progress_bars_list } from '@/helpers/state'
const activeStatuses = new Set(['queued', 'running', 'canceling', 'waiting_for_user'])
export const downloadBarTypes = new Set([
'hosted_mod_download',
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
@ -73,11 +83,14 @@ export interface DownloadManager {
export function createDownloadManager(handleError: (error: unknown) => void): DownloadManager {
const jobs = ref<InstallJobSnapshot[]>([])
const legacyDownloads = ref<LoadingBar[]>([])
const hostedFailures = createHostedDownloadFailures()
let started = false
let disposed = false
let unlistenJobs: (() => void) | null = null
let unlistenRequests: (() => void) | null = null
let unlistenLoading: (() => void) | null = null
let unlistenHostedAttempts: (() => void) | null = null
let unlistenInstances: (() => void) | null = null
let initializing = false
const pendingInitialUpdates: Array<
{ kind: 'job'; job: InstallJobSnapshot } | { kind: 'request'; update: DownloadRequestUpdate }
@ -86,6 +99,7 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
const pendingRequestUpdates: DownloadRequestUpdate[] = []
let requestFlushTimer: ReturnType<typeof setTimeout> | null = null
let legacyRefreshTimer: ReturnType<typeof setTimeout> | null = null
let legacyRefreshGeneration = 0
function persistManualDownloadsFromJob(job: InstallJobSnapshot) {
if (job.status !== 'waiting_for_user' && job.status !== 'succeeded') return
@ -263,16 +277,32 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
}
async function refreshLegacyDownloads() {
const generation = ++legacyRefreshGeneration
const bars = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
if (disposed || generation !== legacyRefreshGeneration) return
legacyDownloads.value = Object.values(bars)
.filter((bar) => !hostedFailures.isRetired(String(bar.loading_bar_uuid)))
.filter((bar) => downloadBarTypes.has(bar.bar_type?.type ?? ''))
.filter(
(bar) =>
!(
bar.bar_type?.type === 'hosted_pack_sync' &&
hostedFailures.has(bar.bar_type.instance_id ?? '')
),
)
.map((bar) => ({
...bar,
title: bar.title ?? bar.bar_type?.pack_name ?? bar.bar_type?.instance_name ?? bar.message,
title:
bar.title ??
bar.bar_type?.file_name ??
bar.bar_type?.pack_name ??
bar.bar_type?.instance_name ??
bar.message,
}))
legacyDownloads.value.push(...hostedFailures.values())
}
function scheduleLegacyRefresh() {
@ -283,15 +313,38 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
}, 300)
}
function clearHostedAttempt(instanceId: string) {
hostedFailures.begin(instanceId, legacyDownloads.value)
legacyRefreshGeneration++
legacyDownloads.value = legacyDownloads.value.filter(
(bar) => !hostedFailures.isRetired(String(bar.loading_bar_uuid)),
)
}
async function start() {
if (started || disposed) return
started = true
unlistenHostedAttempts = onHostedPackAttemptStarted(clearHostedAttempt)
unlistenInstances = await instance_listener((event: { event: string; instance_id: string }) => {
if (event.event !== 'removed') return
forgetHostedCreation(event.instance_id)
clearHostedAttempt(event.instance_id)
})
initializing = true
unlistenRequests = await download_request_listener((update: DownloadRequestUpdate) =>
updateRequest(update),
)
unlistenJobs = await install_job_listener((job: InstallJobSnapshot) => setJob(job))
unlistenLoading = await loading_listener(() => scheduleLegacyRefresh())
unlistenLoading = await loading_listener(
(payload: {
fraction: number | null
loader_uuid: string
event: LoadingBar['bar_type']
}) => {
hostedFailures.update(payload)
scheduleLegacyRefresh()
},
)
await Promise.all([refresh(), refreshLegacyDownloads()])
initializing = false
for (const update of pendingInitialUpdates.splice(0)) {
@ -396,7 +449,11 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
legacyDownloads,
activeJobs,
historyJobs,
activeCount: computed(() => activeJobs.value.length + legacyDownloads.value.length),
activeCount: computed(
() =>
activeJobs.value.length +
legacyDownloads.value.filter((bar) => !bar.bar_type?.error).length,
),
queuedCount: computed(() => jobs.value.filter((job) => job.status === 'queued').length),
start,
refresh,
@ -428,6 +485,8 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
unlistenJobs?.()
unlistenRequests?.()
unlistenLoading?.()
unlistenHostedAttempts?.()
unlistenInstances?.()
},
}
}

View File

@ -112,20 +112,16 @@ 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)
},
)
@ -149,7 +145,6 @@ export function setupCreationModal(
}
async function handleCreate(config: CreationFlowContextValue) {
const instanceMode = creationInstanceMode.value
try {
installationModal.value?.hide()
@ -267,8 +262,7 @@ export function setupCreationModal(
: gameRoot
: null
const job = await install_create_instance({
instanceMode,
await install_create_instance({
name,
gameVersion: config.selectedGameVersion.value!,
loader: loader as InstanceLoader,
@ -282,14 +276,6 @@ 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)
}

View File

@ -361,7 +361,9 @@ fn main() {
"install_cancel_import_plan",
"install_duplicate_instance",
"install_existing_instance",
"hosted_catalog",
"hosted_default",
"hosted_set_session",
"hosted_create",
"hosted_binding",
"hosted_sync",
"hosted_instance_mode",

View File

@ -14,7 +14,9 @@ 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_default,
hosted_set_session,
hosted_create,
hosted_binding,
hosted_sync,
hosted_instance_mode,
@ -54,9 +56,18 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
}
#[tauri::command]
pub async fn hosted_catalog() -> Result<Vec<theseus::pack::hosted::Publication>>
{
Ok(theseus::pack::hosted::catalog().await?)
pub async fn hosted_set_session(token: Option<String>) -> Result<()> {
Ok(theseus::pack::hosted::set_session(token).await?)
}
#[tauri::command]
pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
Ok(theseus::pack::hosted::default_publication().await?)
}
#[tauri::command]
pub async fn hosted_create() -> Result<String> {
Ok(theseus::pack::hosted::create().await?)
}
#[tauri::command]
@ -84,9 +95,8 @@ pub async fn hosted_binding(
#[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?)
Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
}
#[derive(Deserialize)]
@ -140,6 +150,14 @@ pub async fn install_get_modpack_preview(
pub async fn install_create_instance(
request: InstallCreateInstanceRequest,
) -> Result<InstallJobSnapshot> {
if request.instance_mode == theseus::data::InstanceMode::StarLight {
return Err(theseus::ErrorKind::InputError(
"StarLight 实例的版本由服务器决定,请使用官方整合包自动安装入口"
.into(),
)
.as_error()
.into());
}
let job = theseus::install::create_instance_with_adjuncts(
request.name.trim().to_string(),
request.game_version,

View File

@ -1,4 +1,4 @@
// Runs inside the embedded skin site only. The JWT never leaves that origin.
// Only the allowlisted launcher parent may request a JWT for native pack downloads.
;(() => {
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
@ -202,11 +202,7 @@
},
)
const skinBody = await skinResponse.json().catch(() => null)
if (
!skinResponse.ok ||
!skinBody?.payload ||
typeof skinBody.payload !== 'object'
)
if (!skinResponse.ok || !skinBody?.payload || typeof skinBody.payload !== 'object')
return { player, skinData: null }
return { player, skinData: skinBody.payload }
} catch {
@ -437,6 +433,23 @@
void checkSession(true)
return
}
if (
event.data?.type === 'starlight-pack-token-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^pack-token-\d+-\d+$/.test(event.data.requestId)
) {
let token = null
try {
token = localStorage.getItem('loginToken') || null
} catch {}
if (snapshot.status !== 'signed-in' || token !== lastToken) token = null
window.parent.postMessage(
{ type: 'starlight-pack-token-result', requestId: event.data.requestId, token },
parentOrigin,
)
return
}
if (
event.data?.type === 'starlight-skin-luck-request' &&
parentOrigin === event.origin &&

View File

@ -157,6 +157,30 @@ test('logout, invalid token, network error and account switching replace old sta
assert.equal(h.messages.at(-1).data.status, 'signed-out')
})
test('pack JWT is returned only for an explicit request from the connected launcher', async () => {
const h = harness()
h.token('pack.jwt.secret')
const request = { type: 'starlight-pack-token-request', requestId: 'pack-token-1-1' }
await h.message(request)
await h.connect('https://evil.example')
assert.equal(h.messages.length, 0)
await h.connect()
assert.ok(!JSON.stringify(h.messages).includes('pack.jwt.secret'))
await h.message(request, 'https://evil.example')
await h.message(request, 'http://localhost:5201', {})
assert.ok(!JSON.stringify(h.messages).includes('pack.jwt.secret'))
await h.message(request)
assert.equal(h.messages.at(-1).data.type, 'starlight-pack-token-result')
assert.equal(h.messages.at(-1).data.token, 'pack.jwt.secret')
assert.equal(h.messages.at(-1).target, 'http://localhost:5201')
h.token('unverified.account.token')
await h.message({ ...request, requestId: 'pack-token-1-2' })
assert.equal(h.messages.at(-1).data.token, null)
h.token(null)
await h.message({ ...request, requestId: 'pack-token-1-3' })
assert.equal(h.messages.at(-1).data.token, null)
})
test('a delayed response cannot restore the user after logout', async () => {
const h = harness()
let finish

Binary file not shown.

View File

@ -0,0 +1,9 @@
{
"replace": false,
"values": [
"simpletomb:grave_simple",
"simpletomb:grave_normal",
"simpletomb:grave_cross",
"simpletomb:tombstone"
]
}

View File

@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"simpletomb:grave_key"
]
}

View File

@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "SimpleTomb 与 FTB Chunks 领地交互兼容Minecraft 1.21.1"
}
}

View File

@ -63,6 +63,13 @@ val authlibInjector by tasks.registering {
outputs.file(output)
doLast {
val cached = output.get().asFile
if (cached.isFile) {
val checksum = MessageDigest.getInstance("SHA-256")
.digest(cached.readBytes())
.joinToString("") { "%02x".format(it) }
if (checksum == inputs.properties["sha256"]) return@doLast
}
val bytes = uri(
"https://authlib-injector.yushi.moe/artifact/56/authlib-injector-1.2.8.jar",
).toURL().readBytes()

View File

@ -34,6 +34,7 @@ pub async fn plan_instance_upgrade(
instance_id: &str,
target_environment: crate::state::InstanceUpgradeEnvironment,
) -> crate::Result<InstanceUpgradePlan> {
ensure_local_version_management(instance_id).await?;
let state = State::get().await?;
let creation_watch =
state.file_watcher.content_watch_snapshot(instance_id).await;
@ -380,6 +381,7 @@ pub async fn execute_instance_upgrade(
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
ensure_local_version_management(&stored.plan.instance_id).await?;
if stored.execution_started {
return Err(crate::ErrorKind::InputError(
"Upgrade plan execution has already started".to_string(),
@ -638,10 +640,22 @@ fn stored_plan_handle(plan_id: &str) -> crate::Result<StoredUpgradePlan> {
})
}
async fn ensure_local_version_management(
instance_id: &str,
) -> crate::Result<()> {
if crate::pack::hosted::instance_mode(instance_id).await?
== crate::state::InstanceMode::StarLight
{
return Err(crate::ErrorKind::InputError("StarLight 实例的游戏版本和加载器由服务器整合包管理,不能手动选择版本".into()).as_error());
}
Ok(())
}
async fn ensure_current_revision(
stored: &mut StoredUpgradePlanState,
state: &State,
) -> crate::Result<crate::state::instances::commands::ReadOnlyUpgradeSource> {
ensure_local_version_management(&stored.plan.instance_id).await?;
let current_revision = content_rows::get_applied_content_set(
&stored.plan.instance_id,
&state.pool,

View File

@ -1,4 +1,6 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod progress;
mod tagged;
use crate::{
State,
state::{
@ -6,9 +8,12 @@ use crate::{
InstanceLaunchOverridesPatch, InstanceMode, ModLoader,
},
util::fetch::{
DownloadRequest, Integrity, ResourceClass, download_to_path, fetch_json,
DownloadRequest, Integrity, ResourceClass, configured_client,
download_to_path,
},
};
use futures::FutureExt;
use progress::PackProgress;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
@ -26,6 +31,42 @@ 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);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
struct DownloadSession {
authorization: String,
updated: std::time::Instant,
}
impl DownloadSession {
fn new(token: String) -> crate::Result<Self> {
if token.is_empty()
|| token.len() > 16_384
|| token.bytes().any(|b| !b.is_ascii_graphic())
{
return Err(invalid("StarLight 登录凭据无效,请重新登录"));
}
Ok(Self {
authorization: format!("Bearer {token}"),
updated: std::time::Instant::now(),
})
}
fn authorization(&self) -> crate::Result<String> {
if self.updated.elapsed() > std::time::Duration::from_secs(90) {
return Err(invalid("StarLight 登录状态需要刷新,请重试"));
}
Ok(self.authorization.clone())
}
}
/// The embedded skin site's JWT is held in memory only; the server validates it.
pub async fn set_session(token: Option<String>) -> crate::Result<()> {
let mut session = SESSION.lock().await;
*session = None;
*session = token.map(DownloadSession::new).transpose()?;
Ok(())
}
fn instance_gate(instance_id: &str) -> Arc<Mutex<()>> {
GATES.entry(instance_id.to_owned()).or_default().clone()
@ -38,9 +79,13 @@ pub struct Runtime {
pub loader: String,
pub loader_version: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
#[serde(default)]
pub mod_ids: Vec<String>,
#[serde(default)]
pub external: Option<External>,
pub path: String,
pub sha256: String,
pub size: u64,
@ -49,7 +94,7 @@ pub struct PackFile {
#[serde(default)]
pub preserve: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct External {
pub project_id: u32,
@ -83,6 +128,25 @@ pub struct Publication {
pub struct Binding {
pub publication: Publication,
pub files: Vec<PackFile>,
#[serde(default)]
pub sync_marker: Option<String>,
#[serde(default)]
pub resolved_external: Vec<PackFile>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncState {
pack_id: String,
release_id: u64,
marker: String,
}
fn marker_matches(previous: &Binding, state: &SyncState) -> bool {
previous.sync_marker.as_deref() == Some(state.marker.as_str())
&& state.marker == format!("{}:{}", state.pack_id, state.release_id)
&& previous.publication.pack_id == state.pack_id
&& previous.publication.release_id == state.release_id
}
#[derive(Deserialize)]
struct Response<T> {
@ -228,25 +292,105 @@ fn write_json<T: Serialize>(
file.persist(dest).map_err(|e| e.error)?;
Ok(())
}
async fn authorization() -> crate::Result<String> {
SESSION
.lock()
.await
.as_ref()
.ok_or_else(|| {
invalid("请先登录 StarLight 皮肤站,再下载整合包;无需选择玩家")
})?
.authorization()
}
async fn ensure_session(auth: &str) -> crate::Result<()> {
if !SESSION
.lock()
.await
.as_ref()
.is_some_and(|session| session.authorization == auth)
{
return Err(invalid("StarLight 登录状态已变化,请重试整合包同步"));
}
Ok(())
}
async fn request<T: serde::de::DeserializeOwned>(
suffix: &str,
) -> crate::Result<T> {
let auth = authorization().await?;
request_authorized(suffix, &auth).await
}
async fn request_authorized<T: serde::de::DeserializeOwned>(
suffix: &str,
auth: &str,
) -> crate::Result<T> {
ensure_session(auth).await?;
let response = configured_client()
.await?
.get(format!("{API}{suffix}"))
.header(reqwest::header::AUTHORIZATION, auth)
.header(reqwest::header::CACHE_CONTROL, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if matches!(response.status().as_u16(), 401 | 403) {
return Err(invalid(
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
));
}
let data: Response<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
Ok(data.payload)
}
pub async fn default_publication() -> crate::Result<Publication> {
request::<Option<Publication>>("/default")
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})
}
pub async fn create() -> crate::Result<String> {
let publication = default_publication().await?;
let runtime = &publication.manifest.runtime;
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,
let instance = crate::state::create_instance(
crate::state::CreateInstance {
name: publication.manifest.name.clone(),
path: None,
game_version: runtime.game_version.clone(),
loader: ModLoader::try_from_string(&runtime.loader)?,
loader_version: runtime.loader_version.clone(),
icon_path: None,
link: crate::state::InstanceLink::Unmanaged,
symlink_target: None,
game_dir_override: None,
},
&state,
)
.await?;
Ok(response.payload)
}
pub async fn catalog() -> crate::Result<Vec<Publication>> {
request("").await
crate::instance::edit(
&instance.id,
EditInstance {
launch_overrides: Some(InstanceLaunchOverridesPatch {
instance_mode: Some(InstanceMode::StarLight),
..Default::default()
}),
..Default::default()
},
)
.await?;
crate::event::emit::emit_instance(
&instance.id,
crate::event::InstancePayloadType::Created,
)
.await?;
Ok(instance.id)
}
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
.await
@ -394,17 +538,14 @@ async fn recover(instance_id: &str) -> crate::Result<()> {
Ok(())
}
pub async fn synchronize(
instance_id: &str,
pack_id: &str,
) -> crate::Result<SyncResult> {
pub async fn synchronize(instance_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
synchronize_locked(instance_id).await
}
fn effective_mode(
@ -451,6 +592,9 @@ pub async fn set_instance_mode(
"外部关联、共享目录或由第三方整合包平台管理的实例不能开启 StarLight 自动同步,请创建独立实例",
));
}
if mode == InstanceMode::StarLight {
synchronize_locked(instance_id).await?;
}
crate::instance::edit(
instance_id,
EditInstance {
@ -472,25 +616,37 @@ pub async fn prepare_launch(
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?;
if offline {
return Err(invalid(
"StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例",
));
}
synchronize_locked(instance_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"))?;
async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
ensure_idle(instance_id).await?;
recover(instance_id).await?;
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
let progress =
PackProgress::new(instance_id, &metadata.instance.name).await?;
let result =
synchronize_with_progress(instance_id, metadata, &progress).await;
if let Err(error) = &result {
progress.fail(error);
}
result
}
async fn synchronize_with_progress(
instance_id: &str,
metadata: crate::state::InstanceMetadata,
progress: &PackProgress,
) -> crate::Result<SyncResult> {
if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some()
|| !matches!(
@ -505,29 +661,45 @@ async fn synchronize_locked(
}
let root = crate::instance::get_full_path(instance_id).await?;
let previous: Option<Binding> = read_json(&root, BINDING).await?;
if previous
let auth = authorization().await?;
let sync_state =
request_authorized::<Option<SyncState>>("/sync-state", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?;
let pack_unchanged = 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
{
.is_some_and(|binding| marker_matches(binding, &sync_state))
&& metadata.instance.install_stage == InstanceInstallStage::Installed;
let publication = if pack_unchanged {
progress.update(0, 0, "整合包已是最新,正在检查标签 Mod", true);
previous.as_ref().unwrap().publication.clone()
} else {
request_authorized::<Option<Publication>>("/default", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?
};
let sync_marker =
format!("{}:{}", publication.pack_id, publication.release_id);
uuid::Uuid::parse_str(&publication.pack_id)
.map_err(|_| invalid("Invalid modpack ID"))?;
if 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)
{
if previous.as_ref().is_some_and(|b| {
b.publication.pack_id == publication.pack_id
&& 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 {
let resolved_loader = if pack_unchanged {
metadata.applied_content_set.loader_version.clone()
} else 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)
@ -541,6 +713,18 @@ async fn synchronize_locked(
tokio::fs::create_dir_all(&cache).await?;
let state = State::get().await?;
let mut files = manifest.files.clone();
let tagged_manifest = request_authorized::<tagged::TaggedManifest>(
&format!("/tagged-mods/{}", publication.release_id),
&auth,
)
.await?;
tagged_manifest.validate()?;
let mut resolved_external = Vec::new();
if pack_unchanged {
resolved_external =
previous.as_ref().unwrap().resolved_external.clone();
files.extend(resolved_external.iter().cloned());
}
validate(&files)?;
let mut downloaded = 0;
let mut sources = BTreeMap::new();
@ -551,7 +735,20 @@ async fn synchronize_locked(
);
}
// Resolve CurseForge references using the launcher's existing API and integrity checks.
for external in &manifest.external {
for (external_index, external) in manifest.external.iter().enumerate() {
if pack_unchanged {
break;
}
progress.update(
0,
0,
&format!(
"正在解析外部模组 {}/{}",
external_index + 1,
manifest.external.len()
),
true,
);
let cf = crate::api::curseforge::get_file(
external.project_id,
external.file_id,
@ -598,6 +795,19 @@ async fn synchronize_locked(
invalid("This CurseForge file requires a manual download")
})?,
};
let label = format!(
"外部模组 {}/{} · {}",
external_index + 1,
manifest.external.len(),
cf.file_name
);
let mut transferred = 0;
let mut on_progress = |received: u64, _total: u64| {
transferred = received.min(cf.file_length);
progress.download(transferred, cf.file_length, &label, false);
futures::future::ready(Ok(())).boxed()
};
progress.download(0, cf.file_length, &label, true);
download_to_path(
DownloadRequest::new(url, ResourceClass::CurseForge)
.with_integrity(
@ -606,9 +816,10 @@ async fn synchronize_locked(
&staged,
&state.fetch_semaphore,
&state.pool,
None,
Some(&mut on_progress),
)
.await?;
progress.download(cf.file_length, cf.file_length, &label, true);
downloaded += cf.file_length;
} else if crate::util::fetch::sha1_file_async(&staged).await?
!= (cf.file_length, sha1)
@ -622,14 +833,26 @@ async fn synchronize_locked(
if !object.exists() {
tokio::fs::copy(&staged, object).await?;
}
files.push(PackFile {
let resolved = PackFile {
mod_ids: tagged::mod_ids(target(&cache, &sha256)?).await?,
external: Some(external.clone()),
path,
sha256,
size: cf.file_length,
force: true,
preserve: false,
});
};
resolved_external.push(resolved.clone());
files.push(resolved);
}
let duplicate_mods = tagged::merge(
&root,
&cache,
&mut files,
&mut sources,
&tagged_manifest,
)
.await?;
validate(&files)?;
if let Some(old) = &previous {
validate(&old.files)?;
@ -639,6 +862,7 @@ async fn synchronize_locked(
.map(|b| b.files.iter().map(|f| (f.path.as_str(), f)).collect())
.unwrap_or_default();
let mut actions = Vec::new();
let mut pending_downloads = BTreeMap::new();
let mut preserved = Vec::new();
let next_paths: BTreeMap<_, _> = files
.iter()
@ -654,7 +878,21 @@ async fn synchronize_locked(
));
}
}
for f in &files {
for (index, f) in files.iter().enumerate() {
if pack_unchanged
&& !tagged_manifest.contains(&f.path)
&& old
.get(f.path.as_str())
.is_some_and(|prior| prior.sha256 == f.sha256)
{
continue;
}
progress.update(
index as u64,
files.len() as u64,
&format!("正在检查文件 · {}", f.path),
false,
);
let live = target(&root, &f.path)?;
let local = hash(&live).await?;
if local.as_deref() == Some(&f.sha256) {
@ -681,20 +919,9 @@ async fn synchronize_locked(
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;
pending_downloads
.entry(f.sha256.clone())
.or_insert_with(|| (f.clone(), url.clone(), object));
}
actions.push(Action {
path: f.path.clone(),
@ -708,7 +935,9 @@ async fn synchronize_locked(
if local.is_none() {
continue;
}
if prior.preserve || local.as_deref() != Some(&prior.sha256) {
if !tagged::is_managed_file(prior)
&& (prior.preserve || local.as_deref() != Some(&prior.sha256))
{
preserved.push(prior.path.clone());
continue;
}
@ -719,12 +948,110 @@ async fn synchronize_locked(
});
}
}
for action in duplicate_mods {
preserved.retain(|path| path != &action.path);
if !actions.iter().any(|existing| existing.path == action.path) {
actions.push(action);
}
}
let mut tagged_downloads = Vec::new();
pending_downloads.retain(|_, (file, url, object)| {
if tagged_manifest.contains(&file.path) {
tagged_downloads.push((file.clone(), url.clone(), object.clone()));
false
} else {
true
}
});
let total_bytes = pending_downloads
.values()
.map(|(file, _, _)| file.size)
.sum::<u64>();
let total_files = pending_downloads.len();
let mut completed_bytes = 0;
for (index, (file, url, object)) in
pending_downloads.into_values().enumerate()
{
ensure_session(&auth).await?;
let url = if let Some(external) = &file.external {
match crate::api::curseforge::get_file(
external.project_id,
external.file_id,
)
.await?
.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")
})?,
}
} else {
url
};
let label =
format!("文件 {}/{} · {}", index + 1, total_files, file.path);
progress.download(completed_bytes, total_bytes, &label, index == 0);
let mut transferred = 0;
let mut on_progress = |received: u64, _total: u64| {
transferred = received.min(file.size);
progress.download(
completed_bytes + transferred,
total_bytes,
&label,
false,
);
futures::future::ready(Ok(())).boxed()
};
let mut request = DownloadRequest::new(&url, ResourceClass::Modpack)
.with_integrity(Integrity {
size: Some(file.size),
sha256: Some(file.sha256),
..Default::default()
});
if url.starts_with(&format!("{API}/files/")) {
request = request.with_header("Authorization", auth.clone());
}
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
completed_bytes += file.size;
downloaded += file.size;
progress.download(
completed_bytes,
total_bytes,
&label,
index + 1 == total_files,
);
}
ensure_session(&auth).await?;
downloaded += tagged::download(
instance_id,
&metadata.instance.name,
tagged_downloads,
&auth,
progress,
)
.await?;
ensure_session(&auth).await?;
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)
&& previous.as_ref().is_some_and(|b| {
b.sync_marker.as_deref() == Some(sync_marker.as_str())
&& b.files == files
&& b.resolved_external == resolved_external
})
{
return Ok(SyncResult {
instance_id: instance_id.into(),
@ -742,6 +1069,7 @@ async fn synchronize_locked(
};
write_json(&root, JOURNAL, &journal)?;
let result = async {
progress.update(0, 0, "正在安装游戏组件", true);
// 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),
@ -751,22 +1079,46 @@ async fn synchronize_locked(
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 {
progress.install(&job);
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")); }
status => {
let details = job
.error
.as_ref()
.or(job.rollback_error.as_ref())
.map(|error| error.message.as_str())
.unwrap_or(match status {
WaitingForUser => "安装任务正在等待用户处理",
Interrupted => "安装任务意外中断",
Canceled | Canceling => "安装任务已取消",
Failed => "安装任务失败",
_ => "安装任务未能完成",
});
if status == WaitingForUser {
let _ = crate::install::cancel_job(job.job_id).await;
}
return Err(invalid(format!(
"Minecraft 游戏组件安装失败:{details};请在“下载”中查看详情后重试"
)));
}
}
}
}
ensure_session(&auth).await?;
progress.update(0, 0, "正在应用更新", true);
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files })?;
progress.update(0, 0, "正在完成安装", true);
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
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 {
progress.update(0, 0, "正在恢复安装前的文件", true);
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);
@ -783,6 +1135,58 @@ async fn synchronize_locked(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release()
{
let mut binding: Binding = serde_json::from_value(serde_json::json!({
"publication": {"packId":"pack", "releaseId":3, "manifest": {
"schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc",
"runtime":{"gameVersion":"1.20.1", "loader":"vanilla", "loaderVersion":null}, "files":[]
}}, "files":[]
})).unwrap();
let mut state = SyncState {
pack_id: "pack".into(),
release_id: 3,
marker: "pack:3".into(),
};
assert!(!marker_matches(&binding, &state));
binding.sync_marker = Some("pack:3".into());
assert!(marker_matches(&binding, &state));
state.release_id = 4;
assert!(!marker_matches(&binding, &state));
state.release_id = 3;
state.pack_id = "other".into();
assert!(!marker_matches(&binding, &state));
state.pack_id = "pack".into();
state.marker = "pack:4".into();
assert!(!marker_matches(&binding, &state));
}
#[test]
fn download_session_uses_site_token_without_a_game_profile() {
let mut session =
DownloadSession::new("site.jwt.token".into()).unwrap();
assert_eq!(session.authorization().unwrap(), "Bearer site.jwt.token");
session.updated -= std::time::Duration::from_secs(91);
assert!(session.authorization().is_err());
assert!(DownloadSession::new(String::new()).is_err());
assert!(
DownloadSession::new("token\r\nInjected: header".into()).is_err()
);
}
#[tokio::test]
async fn logout_and_invalid_replacement_clear_download_authorization() {
set_session(Some("first.jwt.token".into())).await.unwrap();
assert_eq!(authorization().await.unwrap(), "Bearer first.jwt.token");
let old = authorization().await.unwrap();
set_session(None).await.unwrap();
assert!(authorization().await.is_err());
assert!(ensure_session(&old).await.is_err());
set_session(Some("second.jwt.token".into())).await.unwrap();
assert!(ensure_session(&old).await.is_err());
assert!(set_session(Some("bad token".into())).await.is_err());
assert!(authorization().await.is_err());
}
#[tokio::test]
async fn instance_operations_exclude_each_other_without_blocking_other_instances()
{
@ -872,6 +1276,8 @@ mod tests {
#[test]
fn rejects_case_and_ancestor_collisions() {
let make = |p: &str| PackFile {
mod_ids: Vec::new(),
external: None,
path: p.into(),
sha256: "a".repeat(64),
size: 1,

View File

@ -0,0 +1,129 @@
use crate::event::{
LoadingBarId, LoadingBarType,
emit::{fail_hosted_loading, init_loading, set_loading},
};
use std::{
sync::Mutex,
time::{Duration, Instant},
};
pub(super) struct PackProgress {
bar: LoadingBarId,
last_update: Mutex<Instant>,
}
#[cfg(all(test, not(feature = "tauri")))]
mod tests {
use super::*;
#[tokio::test]
async fn phases_and_failure_remain_in_snapshots_until_the_task_ends() {
let state = crate::EventState::init().await.unwrap();
let progress = PackProgress::new("progress-test", "Test pack")
.await
.unwrap();
let snapshot = || {
state.loading_bars.iter().find(|bar| matches!(&bar.bar_type,
LoadingBarType::HostedPackSync { instance_id, .. } if instance_id == "progress-test"
)).map(|bar| bar.value().clone()).unwrap()
};
assert_eq!(snapshot().total, 0.0);
progress.download(524_288, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current / snapshot().total, 0.5);
assert!(snapshot().message.contains("0.5 / 1.0 MiB"));
progress.download(1_048_576, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current, snapshot().total);
progress.download(262_144, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current / snapshot().total, 0.25);
progress.update(0, 0, "正在应用更新", true);
assert_eq!(snapshot().message, "正在应用更新");
assert_eq!(snapshot().total, 0.0);
progress.fail(
&crate::ErrorKind::InputError("download failed".into()).as_error(),
);
assert!(matches!(
snapshot().bar_type,
LoadingBarType::HostedPackSync { error: Some(_), .. }
));
let id = snapshot().loading_bar_uuid;
drop(progress);
tokio::task::yield_now().await;
assert!(!state.loading_bars.contains_key(&id));
}
}
impl PackProgress {
pub async fn new(instance_id: &str, name: &str) -> crate::Result<Self> {
let bar = init_loading(
LoadingBarType::HostedPackSync {
instance_id: instance_id.to_owned(),
instance_name: name.to_owned(),
error: None,
},
1.0,
"正在获取整合包信息",
)
.await?;
let progress = Self {
bar,
last_update: Mutex::new(Instant::now()),
};
progress.update(0, 0, "正在获取整合包信息", true);
Ok(progress)
}
pub fn update(&self, current: u64, total: u64, message: &str, force: bool) {
let mut last = self.last_update.lock().unwrap();
if !force && last.elapsed() < Duration::from_millis(200) {
return;
}
*last = Instant::now();
let _ = set_loading(&self.bar, current, total, message);
}
pub fn download(&self, current: u64, total: u64, file: &str, force: bool) {
self.update(
current,
total,
&format!(
"正在下载 · {:.1} / {:.1} MiB · {file}",
current as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
),
force,
);
}
pub fn install(&self, job: &crate::install::InstallJobSnapshot) {
use crate::install::InstallPhaseId::*;
let message = match job.phase {
PreparingJava => "正在准备 Java",
DownloadingMinecraft => "正在下载游戏组件",
ResolvingLoader | RunningLoaderProcessors | UpdatingLoader => {
"正在安装加载器"
}
Verifying => "正在校验游戏组件",
Finalizing | Completed => "正在完成游戏组件安装",
_ => "正在安装游戏组件",
};
let (current, total) = job
.progress
.as_ref()
.map(|p| (p.current, p.total))
.unwrap_or((0, 0));
let message = if let Some(bytes) = job.summary.bytes_total {
format!(
"{message} · {:.1} / {:.1} MiB",
job.summary.bytes_downloaded as f64 / 1_048_576.0,
bytes as f64 / 1_048_576.0
)
} else {
message.to_owned()
};
self.update(current, total, &message, false);
}
pub fn fail(&self, error: &crate::Error) {
fail_hosted_loading(&self.bar, &error.user_facing_message());
}
}

View File

@ -0,0 +1,428 @@
use super::*;
use crate::event::{
LoadingBarType,
emit::{fail_hosted_loading, init_loading, set_loading},
};
use futures::{StreamExt, stream};
use std::time::{Duration, Instant};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct TaggedFile {
mod_id: String,
mod_ids: Vec<String>,
path: String,
sha256: String,
size: u64,
}
#[derive(Deserialize)]
pub(super) struct TaggedManifest {
files: Vec<TaggedFile>,
replaces: Vec<String>,
}
impl TaggedManifest {
pub fn contains(&self, path: &str) -> bool {
self.files.iter().any(|file| file.path == path)
}
pub fn validate(&self) -> crate::Result<()> {
if self.files.len() > 8192 {
return Err(invalid("标签 Mod 数量过多"));
}
let mut ids = HashSet::new();
for file in &self.files {
if file.mod_id.is_empty()
|| file.mod_id.len() > 128
|| !file
.mod_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
|| file.path != format!("mods/{}.starlight.jar", file.mod_id)
|| !ids.insert(file.mod_id.to_ascii_lowercase())
{
return Err(invalid("标签 Mod 清单包含无效或重复标识"));
}
if !file.mod_ids.contains(&file.mod_id) || file.mod_ids.is_empty() {
return Err(invalid("标签 Mod 缺少完整的 Mod 标识"));
}
}
let all_ids: Vec<_> =
self.files.iter().flat_map(|file| &file.mod_ids).collect();
if all_ids.iter().any(|id| {
id.is_empty()
|| id.len() > 128
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
}) {
return Err(invalid("标签 Mod 包含无效标识"));
}
if all_ids.iter().collect::<HashSet<_>>().len() != all_ids.len() {
return Err(invalid("标签 Mod 存在重复标识"));
}
super::validate(
&self
.files
.iter()
.map(TaggedFile::pack_file)
.collect::<Vec<_>>(),
)?;
for path in &self.replaces {
safe_path(path)?;
if !is_mod_path(path) {
return Err(invalid("标签 Mod 替换路径无效"));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn manifest() -> TaggedManifest {
TaggedManifest {
files: vec![TaggedFile {
mod_id: "example".into(),
mod_ids: vec!["example".into()],
path: "mods/example.starlight.jar".into(),
sha256: "a".repeat(64),
size: 10,
}],
replaces: vec![],
}
}
#[tokio::test]
async fn replaces_external_mod_using_saved_identity_when_cache_is_missing()
{
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache");
let original = PackFile {
mod_ids: vec!["example".into()],
external: Some(External {
project_id: 1,
file_id: 2,
}),
path: "mods/old.jar".into(),
sha256: "b".repeat(64),
size: 12,
force: true,
preserve: false,
};
let mut files = vec![original.clone()];
let mut sources = BTreeMap::new();
let manifest = manifest();
manifest.validate().unwrap();
let actions =
merge(root.path(), &cache, &mut files, &mut sources, &manifest)
.await
.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "mods/example.starlight.jar");
assert!(actions.is_empty());
assert!(sources[&files[0].path].contains("/tagged-files/"));
let persisted = serde_json::to_vec(&original).unwrap();
let mut restored =
vec![serde_json::from_slice::<PackFile>(&persisted).unwrap()];
merge(
root.path(),
&cache,
&mut restored,
&mut sources,
&TaggedManifest {
files: vec![],
replaces: vec![],
},
)
.await
.unwrap();
assert_eq!(restored[0].external.as_ref().unwrap().file_id, 2);
assert_eq!(restored[0].path, "mods/old.jar");
}
#[tokio::test]
async fn local_duplicate_is_only_planned_for_removal_and_unrelated_mod_is_kept()
{
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("mods")).unwrap();
for (name, id) in [("old.jar", "example"), ("personal.jar", "personal")]
{
let mut zip = zip::ZipWriter::new(
std::fs::File::create(root.path().join("mods").join(name))
.unwrap(),
);
zip.start_file(
"fabric.mod.json",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(
format!(r#"{{"schemaVersion":1,"id":"{id}","version":"1"}}"#)
.as_bytes(),
)
.unwrap();
zip.finish().unwrap();
}
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut vec![],
&mut BTreeMap::new(),
&manifest(),
)
.await
.unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].path, "mods/old.jar");
assert!(actions[0].next_hash.is_none());
assert!(root.path().join("mods/old.jar").is_file());
assert!(root.path().join("mods/personal.jar").is_file());
}
#[test]
fn multi_mod_jar_cannot_be_partially_replaced() {
assert!(is_managed_file(&manifest().files[0].pack_file()));
let mut custom = manifest().files[0].pack_file();
custom.path = "mods/personal.jar".into();
assert!(!is_managed_file(&custom));
let declared = vec!["example".into(), "companion".into()];
assert!(replaces_ids(&declared, &HashSet::from(["example"])).is_err());
assert!(
replaces_ids(&declared, &HashSet::from(["example", "companion"]))
.unwrap()
);
assert!(!replaces_ids(&declared, &HashSet::from(["other"])).unwrap());
let mut bad = manifest();
bad.files[0].mod_ids.push("../escape".into());
assert!(bad.validate().is_err());
}
}
impl TaggedFile {
fn pack_file(&self) -> PackFile {
PackFile {
mod_ids: self.mod_ids.clone(),
external: None,
path: self.path.clone(),
sha256: self.sha256.clone(),
size: self.size,
force: true,
preserve: false,
}
}
}
fn is_mod_path(path: &str) -> bool {
path.starts_with("mods/")
&& path.to_ascii_lowercase().ends_with(".jar")
&& path.split('/').count() == 2
}
pub(super) fn is_managed_file(file: &PackFile) -> bool {
file.external.is_none()
&& file
.mod_ids
.iter()
.any(|id| file.path == format!("mods/{id}.starlight.jar"))
}
fn replaces_ids(
declared: &[String],
selected: &HashSet<&str>,
) -> crate::Result<bool> {
let overlaps = declared.iter().any(|id| selected.contains(id.as_str()));
if overlaps && !declared.iter().all(|id| selected.contains(id.as_str())) {
return Err(invalid(
"旧 JAR 同时包含多个 Mod标签未覆盖全部标识无法安全替换。请管理员更新完整 JAR 或整合包。",
));
}
Ok(overlaps)
}
pub(super) async fn mod_ids(path: PathBuf) -> crate::Result<Vec<String>> {
Ok(tokio::task::spawn_blocking(move || {
crate::mod_metadata::read_mod_ids(&path)
})
.await?)
}
pub(super) async fn merge(
root: &Path,
cache: &Path,
files: &mut Vec<PackFile>,
sources: &mut BTreeMap<String, String>,
manifest: &TaggedManifest,
) -> crate::Result<Vec<Action>> {
let ids: HashSet<_> = manifest
.files
.iter()
.flat_map(|file| file.mod_ids.iter().map(String::as_str))
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
let mut replaced: HashSet<String> =
manifest.replaces.iter().cloned().collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
if replaced.contains(&file.path) {
continue;
}
let object = target(cache, &file.sha256)?;
let declared = if !file.mod_ids.is_empty() {
file.mod_ids.clone()
} else {
mod_ids(object).await?
};
if replaces_ids(&declared, &ids)? {
replaced.insert(file.path.clone());
}
}
files.retain(|file| !replaced.contains(&file.path));
for file in &manifest.files {
if files
.iter()
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
{
return Err(invalid(format!(
"标签 Mod 与整合包文件冲突:{}",
file.path
)));
}
sources.insert(
file.path.clone(),
format!("{API}/tagged-files/{}", file.sha256),
);
files.push(file.pack_file());
}
let mut duplicate_actions = Vec::new();
let mods_dir = target(root, "mods")?;
if mods_dir.is_dir() {
let mut entries = tokio::fs::read_dir(&mods_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let Some(name) = entry.file_name().to_str().map(str::to_owned)
else {
continue;
};
let path = format!("mods/{name}");
if !is_mod_path(&path) || manifest.contains(&path) {
continue;
}
let local = target(root, &path)?;
if replaces_ids(&mod_ids(local.clone()).await?, &ids)? {
duplicate_actions.push(Action {
path,
old_hash: hash(&local).await?,
next_hash: None,
});
}
}
}
Ok(duplicate_actions)
}
pub(super) async fn download(
instance_id: &str,
instance_name: &str,
files: Vec<(PackFile, String, PathBuf)>,
auth: &str,
progress: &PackProgress,
) -> crate::Result<u64> {
if files.is_empty() {
return Ok(0);
}
let state = State::get().await?;
let concurrency = crate::api::settings::get()
.await?
.effective_max_concurrent_downloads()
.clamp(1, 16);
let batch_id = uuid::Uuid::new_v4().to_string();
let total = files.iter().map(|(file, _, _)| file.size).sum::<u64>();
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
let mut work = Vec::new();
for (file, url, object) in files {
let bar = init_loading(
LoadingBarType::HostedModDownload {
instance_id: instance_id.into(),
instance_name: instance_name.into(),
batch_id: batch_id.clone(),
file_name: file.path.clone(),
error: None,
},
file.size as f64,
"等待下载",
)
.await?;
set_loading(&bar, 0, file.size, "等待下载")?;
work.push((file, url, object, bar));
}
let results =
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
let current = &current;
let state = &state;
async move {
let result = async {
ensure_session(auth).await?;
set_loading(&bar, 0, file.size, "正在下载")?;
let mut last = Instant::now() - Duration::from_secs(1);
let mut report = |received: u64, _: u64| {
if last.elapsed() >= Duration::from_millis(200)
|| received >= file.size
{
last = Instant::now();
let _ = set_loading(
&bar,
received,
file.size,
"正在下载",
);
let mut bytes = current.lock().unwrap();
bytes.insert(
file.path.clone(),
received.min(file.size),
);
progress.download(
bytes.values().sum(),
total,
"标签 Mod",
false,
);
}
futures::future::ready(Ok(())).boxed()
};
download_to_path(
DownloadRequest::new(url, ResourceClass::Modpack)
.with_header("Authorization", auth.to_owned())
.with_integrity(Integrity {
size: Some(file.size),
sha256: Some(file.sha256),
..Default::default()
}),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut report),
)
.await?;
ensure_session(auth).await?;
set_loading(&bar, file.size, file.size, "下载完成")?;
Ok::<_, crate::Error>(file.size)
}
.await;
if let Err(error) = &result {
fail_hosted_loading(&bar, &error.user_facing_message());
}
result
}
}))
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
results
.into_iter()
.try_fold(0, |sum, result| Ok(sum + result?))
}

View File

@ -168,6 +168,7 @@ pub fn emit_loading(
.to_string(),
event: loading_bar.bar_type.clone(),
loader_uuid: loading_bar.loading_bar_uuid,
total: Some(loading_bar.total),
},
)
.map_err(EventError::from)?;
@ -181,6 +182,64 @@ pub fn emit_loading(
Ok(())
}
/// Set measured phase progress without completing the task at a phase boundary.
/// A zero total represents a phase whose amount of work is not yet known.
pub fn set_loading(
key: &LoadingBarId,
current: u64,
total: u64,
message: &str,
) -> crate::Result<()> {
let event_state = crate::EventState::get()?;
let Some(mut bar) = event_state.loading_bars.get_mut(&key.0) else {
return Err(EventError::NoLoadingBar(key.0).into());
};
bar.current = current.min(total) as f64;
bar.total = total as f64;
bar.message = message.to_owned();
let fraction = if total == 0 {
0.0
} else {
bar.current / bar.total
};
bar.last_sent = fraction;
#[cfg(feature = "tauri")]
event_state
.app
.emit(
"loading",
LoadingPayload {
fraction: Some(fraction),
message: bar.message.clone(),
event: bar.bar_type.clone(),
loader_uuid: bar.loading_bar_uuid,
total: Some(bar.total),
},
)
.map_err(EventError::from)?;
#[cfg(feature = "cli")]
{
bar.cli_progress_bar.set_message(bar.message.clone());
bar.cli_progress_bar
.set_position((fraction * CLI_PROGRESS_BAR_TOTAL as f64) as u64);
}
Ok(())
}
pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
if let Ok(state) = crate::EventState::get()
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
{
match &mut bar.bar_type {
LoadingBarType::HostedPackSync { error: failure, .. }
| LoadingBarType::HostedModDownload { error: failure, .. } => {
*failure = Some(error.to_owned())
}
_ => {}
}
}
}
// emit_warning(message)
pub async fn emit_warning(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")]

View File

@ -109,6 +109,7 @@ impl Drop for LoadingBarId {
message: "Completed".to_string(),
event,
loader_uuid,
total: Some(bar.total),
},
);
tracing::trace!(
@ -137,6 +138,18 @@ impl Drop for LoadingBarId {
#[serde(rename_all = "snake_case")]
pub enum LoadingBarType {
LegacyDataMigration,
HostedPackSync {
instance_id: String,
instance_name: String,
error: Option<String>,
},
HostedModDownload {
instance_id: String,
instance_name: String,
batch_id: String,
file_name: String,
error: Option<String>,
},
DirectoryMove {
old: PathBuf,
new: PathBuf,
@ -189,6 +202,7 @@ pub struct LoadingPayload {
pub loader_uuid: Uuid,
pub fraction: Option<f64>, // by convention, if optional, it means the loading is done
pub message: String,
pub total: Option<f64>,
}
#[derive(Serialize, Clone)]

View File

@ -642,11 +642,17 @@ pub(crate) fn is_native_library(library: &Library) -> bool {
.is_some_and(|classifier| classifier.starts_with("natives-"))
}
/// Whether this library carries a Java artifact (regular JAR) that must be
/// downloaded and placed on the classpath. A library can have both a Java
/// Whether this library carries a non-native primary artifact that must be
/// downloaded. This is normally a Java JAR, but loader processors can also
/// declare archives and mapping files. A library can have both a primary
/// artifact and native classifiers after manifest merging (LWJGL is the
/// canonical example); the two are independent and must not be treated as
/// mutually exclusive.
///
/// `include_in_classpath` only controls the final Minecraft runtime classpath.
/// Forge and NeoForge deliberately exclude installer processor dependencies
/// from that classpath, but those artifacts are still required while the
/// loader processors run.
pub(crate) fn needs_java_artifact(library: &Library) -> bool {
// Four-part native coordinates (group:artifact:version:natives-*) store
// their native archive metadata in downloads.artifact, which is not a
@ -671,7 +677,7 @@ pub(crate) fn needs_java_artifact(library: &Library) -> bool {
{
return false;
}
library.include_in_classpath
true
}
fn java_artifact_applies(
@ -2853,6 +2859,26 @@ mod tests {
);
}
#[test]
fn neoforge_processor_dependency_is_downloaded_outside_runtime_classpath() {
let library: Library = serde_json::from_value(serde_json::json!({
"name": "net.neoforged.installertools:installertools:2.1.2",
"include_in_classpath": false,
"downloadable": true,
"downloads": {"artifact": {
"path": "net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"sha1": "72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b",
"size": 83543
}}
}))
.unwrap();
assert!(!library.include_in_classpath);
assert!(needs_java_artifact(&library));
assert!(java_artifact_applies(&library, "x86_64", false));
}
#[test]
fn java_artifact_rules_are_applied_before_path_deduplication() {
let blocked: Library = serde_json::from_value(serde_json::json!({

View File

@ -83,23 +83,109 @@ pub(crate) fn is_env_dependency_id(id: &str) -> bool {
pub fn extract_mod_metadata(bytes: &Bytes) -> Option<LocalModMetadata> {
let cursor = std::io::Cursor::new(&**bytes);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
extract_archive_metadata(&mut archive)
}
// Try each known metadata path in priority order.
if let Some(meta) = try_fabric(&mut archive) {
return Some(meta);
}
if let Some(meta) = try_quilt(&mut archive) {
return Some(meta);
}
if let Some(meta) =
try_toml_path(&mut archive, "META-INF/neoforge.mods.toml")
/// Read only metadata entries without loading the entire Mod JAR into memory.
pub fn read_mod_metadata(path: &std::path::Path) -> Option<LocalModMetadata> {
let mut archive =
zip::ZipArchive::new(std::fs::File::open(path).ok()?).ok()?;
for name in [
"fabric.mod.json",
"quilt.mod.json",
"META-INF/mods.toml",
"META-INF/neoforge.mods.toml",
"mcmod.info",
"META-INF/MANIFEST.MF",
] {
if archive
.by_name(name)
.ok()
.is_some_and(|entry| entry.size() > 2 * 1024 * 1024)
{
return None;
}
}
extract_archive_metadata(&mut archive)
}
/// All top-level Mod IDs in a JAR, used to replace multi-Mod archives safely.
pub fn read_mod_ids(path: &std::path::Path) -> Vec<String> {
use std::io::Read;
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let mut ids = std::collections::BTreeSet::new();
for name in ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
let mut content = String::new();
if file
.take(2 * 1024 * 1024)
.read_to_string(&mut content)
.is_ok()
&& let Ok(parsed) = toml::from_str::<toml_mod::ModsToml>(&content)
{
ids.extend(
parsed
.mods
.unwrap_or_default()
.into_iter()
.filter_map(|entry| entry.mod_id),
);
}
}
for name in ["fabric.mod.json", "quilt.mod.json", "mcmod.info"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
if let Ok(value) = serde_json::from_reader::<_, serde_json::Value>(
file.take(2 * 1024 * 1024),
) {
if let Some(id) = value
.get("id")
.or_else(|| value.pointer("/quilt_loader/id"))
.and_then(|v| v.as_str())
{
ids.insert(id.to_owned());
}
if let Some(mods) = value.as_array() {
ids.extend(mods.iter().filter_map(|entry| {
entry.get("modid")?.as_str().map(str::to_owned)
}));
}
}
}
ids.into_iter().collect()
}
fn extract_archive_metadata<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
// Try each known metadata path in priority order.
if let Some(meta) = try_fabric(archive) {
return Some(meta);
}
if let Some(meta) = try_toml_path(&mut archive, "META-INF/mods.toml") {
if let Some(meta) = try_quilt(archive) {
return Some(meta);
}
if let Some(meta) = try_mcmod_info(&mut archive) {
if let Some(meta) = try_toml_path(archive, "META-INF/neoforge.mods.toml") {
return Some(meta);
}
if let Some(meta) = try_toml_path(archive, "META-INF/mods.toml") {
return Some(meta);
}
if let Some(meta) = try_mcmod_info(archive) {
return Some(meta);
}
@ -108,8 +194,8 @@ pub fn extract_mod_metadata(bytes: &Bytes) -> Option<LocalModMetadata> {
// ── format-specific parsers ────────────────────────────────────────────────
fn try_fabric(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_fabric<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("fabric.mod.json").ok()?;
let parsed: fabric::FabricModJson =
@ -141,8 +227,8 @@ fn try_fabric(
})
}
fn try_quilt(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_quilt<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("quilt.mod.json").ok()?;
let parsed: fabric::QuiltModJson =
@ -167,8 +253,8 @@ fn try_quilt(
})
}
fn try_toml_path(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_toml_path<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &str,
) -> Option<LocalModMetadata> {
let mut content = String::new();
@ -263,8 +349,8 @@ fn try_toml_path(
})
}
fn try_mcmod_info(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_mcmod_info<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("mcmod.info").ok()?;
let entries: Vec<mcmod_info::McmodInfoEntry> =
@ -324,9 +410,9 @@ fn extract_contact_url(contact: &Option<serde_json::Value>) -> Option<String> {
/// `${file.jarVersion}`) which the loader substitutes from the JAR manifest at
/// runtime; surface the real `Implementation-Version` from the manifest when
/// present, falling back to the original value otherwise.
fn resolve_toml_version(
fn resolve_toml_version<R: std::io::Read + std::io::Seek>(
version: Option<String>,
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
archive: &mut zip::ZipArchive<R>,
) -> Option<String> {
let placeholder = version.clone()?;
if !placeholder.starts_with("${") {
@ -345,6 +431,21 @@ fn resolve_toml_version(
mod tests {
use std::io::Write;
#[test]
fn reads_every_declared_mod_id_from_disk_without_including_dependencies() {
let jar = build_jar(&[(
"META-INF/mods.toml",
"[[mods]]\nmodId = \"first\"\nversion = \"1\"\n[[mods]]\nmodId = \"second\"\nversion = \"1\"\n[[dependencies.first]]\nmodId = \"minecraft\"\n",
)]);
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(&jar).unwrap();
assert_eq!(super::read_mod_ids(file.path()), vec!["first", "second"]);
assert_eq!(
super::read_mod_metadata(file.path()).unwrap().mod_id,
"first"
);
}
fn build_jar(entries: &[(&str, &str)]) -> bytes::Bytes {
let mut buffer = std::io::Cursor::new(Vec::new());
{

View File

@ -107,6 +107,7 @@ pub(crate) async fn try_download_via_h2(
destination: &Path,
part_path: &Path,
policy: super::native::NativeH2Policy,
progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
@ -229,6 +230,7 @@ pub(crate) async fn try_download_via_h2(
part_path,
total_size,
concurrency,
progress,
)
.await;
}
@ -282,6 +284,7 @@ pub(crate) async fn try_download_via_h2(
&integrity,
total_size,
policy,
progress,
)
.await;
match result {
@ -460,6 +463,7 @@ async fn single_stream(
integrity: &Integrity,
total_size: u64,
policy: super::native::NativeH2Policy,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> crate::Result<DownloadResult> {
let mut headers = request_headers(request, route);
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
@ -511,6 +515,9 @@ async fn single_stream(
super::h2_receive::release_capacity(&mut stream, chunk.len())?;
if progress_gate.should_report(downloaded, total_size) {
record_install_progress(request, downloaded, total_size).await;
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
}
if policy.abort_if_slow
&& matches!(
@ -530,6 +537,9 @@ async fn single_stream(
}
file.flush().await?;
drop(file);
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
let computed = hashers.finish(downloaded);
record_install_stage(
request,
@ -759,6 +769,96 @@ impl AssetBatchConnectionGroup {
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[tokio::test]
async fn single_stream_reports_bytes_before_the_response_finishes() {
let size = 512 * 1024;
let data = Bytes::from(vec![42; size]);
let progress_seen = Arc::new(tokio::sync::Notify::new());
let server_progress = Arc::clone(&progress_seen);
let server_data = data.clone();
let (client_io, server_io) = tokio::io::duplex(1024 * 1024);
let server = tokio::spawn(async move {
let mut connection =
h2::server::handshake(server_io).await.unwrap();
while let Some(result) = connection.accept().await {
let (_, mut respond) = result.unwrap();
let data = server_data.clone();
let progress = Arc::clone(&server_progress);
tokio::spawn(async move {
let mut stream = respond
.send_response(http::Response::new(()), false)
.unwrap();
stream.send_data(data.slice(..size / 2), false).unwrap();
progress.notified().await;
stream.send_data(data.slice(size / 2..), true).unwrap();
});
}
});
let mut builder = h2::client::Builder::new();
builder.initial_window_size(1024 * 1024);
let (sender, driver) =
builder.handshake::<_, Bytes>(client_io).await.unwrap();
let client = tokio::spawn(driver);
let connection = SharedH2Connection::for_test(sender);
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("pack.zip");
let part = directory.path().join("pack.zip.part");
let integrity =
Integrity::sha1(sha1_smol::Sha1::from(&data[..]).hexdigest())
.with_size(size as u64);
let request = DownloadRequest::new(
"https://h2-progress.test/pack.zip",
fetch::ResourceClass::Modpack,
)
.with_integrity(integrity.clone());
let route = DownloadRoute {
url: request.url.clone(),
source: fetch::DownloadRouteSource::Official,
is_mirror: false,
allow_sensitive_headers: true,
supports_range: true,
proxy: fetch::ProxyPolicy::Direct,
};
let mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
progress_seen.notify_one();
Box::pin(async { Ok(()) })
as Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
};
let result = tokio::time::timeout(
Duration::from_secs(5),
single_stream(
&connection,
&request.url.parse().unwrap(),
&request,
&route,
&destination,
&part,
&integrity,
size as u64,
super::super::native::NativeH2Policy {
allow_cold_connection: true,
abort_if_slow: false,
expected_speed: None,
},
Some(&mut progress),
),
)
.await;
client.abort();
server.abort();
result.unwrap().unwrap();
assert!(
reports
.iter()
.any(|&(current, total)| current > 0 && current < total)
);
assert_eq!(reports.last(), Some(&(size as u64, size as u64)));
assert_eq!(tokio::fs::read(destination).await.unwrap(), data);
}
#[test]
fn asset_batch_expansion_requires_sustained_saturation() {

View File

@ -54,6 +54,7 @@ pub(crate) async fn download(
part_path: &Path,
total_size: u64,
concurrency: usize,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
@ -103,19 +104,33 @@ pub(crate) async fn download(
progress_delta,
));
}
let mut progress_interval =
tokio::time::interval(Duration::from_millis(200));
progress_interval
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let cancellation = request.cancellation.clone().unwrap_or_default();
loop {
let next = if let Some(cancellation) = request.cancellation.as_ref() {
tokio::select! {
let next = tokio::select! {
biased;
_ = cancellation.cancelled() => {
drop(tasks);
drop(output);
return H2DownloadOutcome::Canceled;
}
result = tasks.next() => result,
_ = progress_interval.tick(), if progress.is_some() => {
if let Some(callback) = progress.as_deref_mut()
&& callback(downloaded.load(Ordering::Relaxed).min(total_size), total_size).await.is_err()
{
drop(tasks);
drop(output);
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
} else {
tasks.next().await
continue;
}
result = tasks.next() => result,
};
let Some(result) = next else {
break;
@ -130,6 +145,14 @@ pub(crate) async fn download(
}
}
drop(output);
if let Some(callback) = progress.as_deref_mut()
&& callback(total_size, total_size).await.is_err()
{
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
let verification = if let Some(cancellation) = request.cancellation.as_ref()
{
tokio::select! {
@ -385,6 +408,10 @@ mod tests {
)
.unwrap();
offset += length;
if offset == start + length {
tokio::time::sleep(Duration::from_millis(300))
.await;
}
}
});
}
@ -420,6 +447,18 @@ mod tests {
};
let uri = route.url.parse().unwrap();
let mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
Box::pin(async { Ok(()) })
as std::pin::Pin<
Box<
dyn std::future::Future<Output = crate::Result<()>>
+ Send,
>,
>
};
let result = download(
&connection,
&uri,
@ -429,10 +468,16 @@ mod tests {
&part_path,
data.len() as u64,
8,
Some(&mut progress),
)
.await;
assert!(matches!(result, H2DownloadOutcome::Completed(_)));
let size = data.len() as u64;
assert!(reports.iter().any(|&(current, total)| current > 0
&& current < size
&& total == size));
assert_eq!(reports.last(), Some(&(size, size)));
assert_eq!(request_count.load(Ordering::Relaxed), 8);
assert_eq!(tokio::fs::read(destination).await.unwrap(), *data);
client_driver.abort();
@ -504,6 +549,7 @@ mod tests {
&part_for_task,
2 * 1024 * 1024,
16,
None,
)
.await
});

View File

@ -5626,6 +5626,7 @@ async fn download_to_path_inner(
destination,
&part_path,
h2_policy,
progress.as_deref_mut(),
)
.await
{

View File

@ -143,6 +143,7 @@ import { useScrollIndicator } from '../../composables/scroll-indicator'
import { injectModalBehavior } from '../../providers'
import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
import { createModalTransition } from './modal-transition'
const { formatMessage } = useVIntl()
@ -220,6 +221,7 @@ const headerId = `${modalId}-header`
const closeLabel = computed(() => formatMessage(commonMessages.closeButton))
const open = ref(false)
const transition = createModalTransition()
const visible = ref(false)
const stackDepth = ref(0)
const modalBodyRef = ref<HTMLElement | null>(null)
@ -237,6 +239,8 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
const current = transition.begin(true)
if (!current) return
props.onShow?.()
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
@ -255,8 +259,10 @@ function show(event?: MouseEvent) {
mouseY.value = Math.round(window.innerHeight / 2)
}
setTimeout(() => {
if (!current()) return
visible.value = true
nextTick(() => {
if (!current()) return
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
@ -271,6 +277,8 @@ async function hide() {
if (props.disableClose) {
return
}
const current = transition.begin(false)
if (!current) return
props.onHide?.()
resetMousePosition()
visible.value = false
@ -289,9 +297,10 @@ async function hide() {
await new Promise<void>((resolve) => {
setTimeout(resolve, 300)
})
if (!current()) return
open.value = false
await nextTick()
props.onAfterHide?.()
if (current()) props.onAfterHide?.()
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
@ -336,7 +345,9 @@ function resetMousePosition() {
}
onUnmounted(() => {
if (open.value) {
const wasActive = transition.isActive()
transition.dispose()
if (wasActive) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)

View File

@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createModalTransition } from './modal-transition.ts'
test('fast reopen invalidates the previous closing animation', () => {
const transition = createModalTransition()
assert.equal(transition.begin(false), undefined)
const opening = transition.begin(true)!
assert.equal(transition.begin(true), undefined)
const closing = transition.begin(false)!
assert.equal(opening(), false)
assert.equal(transition.begin(false), undefined)
const reopened = transition.begin(true)!
assert.equal(closing(), false)
assert.equal(reopened(), true)
assert.equal(transition.isActive(), true)
})
test('closing before the opening animation and unmount invalidate stale callbacks', () => {
const transition = createModalTransition()
const opening = transition.begin(true)!
const closing = transition.begin(false)!
assert.equal(opening(), false)
assert.equal(closing(), true)
assert.equal(transition.isActive(), false)
transition.dispose()
assert.equal(closing(), false)
})

View File

@ -0,0 +1,18 @@
export function createModalTransition() {
let active = false
let revision = 0
function begin(next: boolean): (() => boolean) | undefined {
if (active === next) return undefined
active = next
const current = ++revision
return () => current === revision
}
return {
begin,
isActive: () => active,
dispose: () => {
active = false
revision++
},
}
}

View File

@ -4,14 +4,14 @@
启动器支持两种实例类型可在创建页、实例设置或「Mod → 管理整合包」中选择:
- **StarLight 实例**:自动同步 StarLight 服务器发布的变更,游玩 StarLight 服务器必选。首次需要在整合包管理中选定并安装官方整合包;未配置完成时阻止启动。之后联网启动前自动同步,离线启动使用已经安装的版本
- **本地实例**:跳过 StarLight 同步检查,启动更快,可自选整合包,适合第三方服务器和本地个人游玩。切换为本地时保留已有文件、存档和官方整合包绑定,停止后续自动同步;重新切回 StarLight 后可继续同步
- **StarLight 实例**:自动同步 StarLight 服务器发布的变更,游玩 StarLight 服务器必选。创建时直接拉取管理员指定的默认整合包,自动安装对应的游戏版本和加载器,不提供版本选择。每次启动必须登录 StarLight 皮肤站并联网检查,先完成增量更新再启动;检查或下载失败时停止启动,不提供跳过检查的离线启动
- **本地实例**:跳过 StarLight 同步检查,启动更快,可自选整合包,适合第三方服务器和本地个人游玩。切换为本地时保留已有文件、存档和官方整合包绑定,停止后续自动同步;重新切回 StarLight 时先按服务器默认整合包完成安装,成功后才保存类型;失败时保留原类型
类型保存在实例配置的 `launch_overrides.instance_mode``starlight` / `local`),随配置保存、复制和恢复。不需要数据库结构迁移。兼容没有该字段的旧实例:存在官方整合包绑定则按 StarLight 实例处理,否则按本地实例处理。新建实例默认显式保存为本地类型;从其他启动器导入(包括旧 StarLight / Axolotl 配置)也按本地实例处理。外部关联、共享目录和第三方平台管理的实例不能开启 StarLight 自动同步。
同一实例的类型切换、同步和启动准备互斥,锁保持到游戏进程创建完成。不同实例不互相等待,本地实例启动不请求 StarLight 更新。新手引导在创建方式之前介绍类型选择;创建页、实例设置和 Mod 管理共用类型选择组件。
两端内容切换栏初始为 Mod保存各自上次选择;新建 StarLight 实例后直接引导到整合包管理
两端内容切换栏初始为 Mod保存各自上次选择StarLight 创建入口一键安装,安装状态和原实例 ID 跨页面保留,失败可重试原实例;完成后进入实例详情。整合包管理仅显示服务器指定内容和检查/重试入口,不允许玩家换包、换游戏版本或加载器
## ZIP 格式依据
@ -25,30 +25,49 @@
每个新增或变化的文件作为一个完整对象传输,未变化文件不下载。这里的粒度是文件级,不是 JAR 内的二进制补丁。对象按 SHA-256 去重发布版本可直接从任意旧版本更新不要求逐个应用中间补丁。CurseForge 引用通过启动器现有接口解析并校验下载,复用已有本地文件或缓存。
公共路由前缀为 `/starlight/mod/packs`
路由前缀为 `/starlight/mod/packs`。所有玩家读取路由均要求 `Authorization: Bearer <token>`,接受有效的站内登录 JWT 或有效的本站 Yggdrasil 玩家 access token沿用账号封禁、撤销、过期检查。下载为 `private, no-store`Range 和条件请求同样先经过鉴权。启动器凭据仅用于固定的本站 API/文件地址,不传给 CurseForge 等外部下载源。启动器通过内嵌皮肤站的受限消息桥按需取得当前 JWT原生下载端只在内存中短暂持有退出、切换账号或会话断开时清除。安装、切换为 StarLight 和启动前更新均使用站内 JWT不依赖所选 Minecraft 玩家。游戏启动身份由游戏登录流程单独处理。认证采用 Ktor 的 [FirstSuccessful 多提供器校验](https://api.ktor.io/ktor-server/ktor-server-plugins/ktor-server-auth/io.ktor.server.auth/-authentication-strategy/index.html)。
管理员必须在「Mod 管理 → 管理整合包」指定一个已审核发布的默认包;没有默认包时明确报错,不猜测列表顺序。默认包随该包最新已审核版本更新;管理员切换默认包后,已有 StarLight 实例下次同步会迁移至新默认包,并继续遵守旧文件删除及个人文件保留规则。
| 请求 | 内容 |
| --- | --- |
| `GET /` | 每个整合包的最新已发布版本 |
| `GET /default` | 管理员指定整合包的最新已发布版本(未设置时为 null |
| `GET /sync-state` | 默认包标志 `packId:releaseId`,只查询已发布版本,不读取完整清单 |
| `GET /tagged-mods/{releaseId}` | 当前整合包 Mod 标签的文件清单及需要覆盖的包内 Mod 路径 |
| `GET /tagged-files/{sha256}` | 已登记的标签 Mod 不可变快照,仍须有效登录 token |
| `GET /admin/mod-tag` / `POST /admin/mod-tag` | 读取或保存整合包 Mod 标签,保存同时记录管理员审计 |
| `GET /{packId}` | 指定整合包的最新已发布清单 |
| `GET /files/{releaseId}/{sha256}` | 该已发布版本中存在的文件对象 |
| `GET /admin` | 管理员的草稿和版本记录 |
| `GET /admin/default` | 当前默认整合包 |
| `POST /admin/default/{packId}` | 指定已发布默认包并写入审计 |
| `POST /admin/upload?version=...&packId=...` | 上传一个完整 ZIP新建时省略 `packId` |
| `POST /admin/{id}/publish` | 认可变更、写入审计并发布 |
| `DELETE /admin/{id}` | 撤销未发布草稿 |
数据表为 `launcher_modpack_release``launcher_modpack_audit`。文件位于 `runtimeUpdatePath` 同级的 `launcher-modpacks/objects`,不能直接挂载为公共静态目录。对象下载必须同时满足已发布状态和清单成员校验。重复发布同一草稿不重复写入审计;发布基线已变化的旧草稿不能发布。
数据表为 `launcher_modpack_release``launcher_modpack_audit`,新增的 `launcher_modpack_default_audit` 保存默认包选择及管理员、时间。默认设置成功的同一条持久化审计记录即成为新的默认来源,不修改历史发布审计。文件位于 `runtimeUpdatePath` 同级的 `launcher-modpacks/objects`,不能直接挂载为公共静态目录。对象下载必须同时满足登录校验、已发布状态和清单成员校验。重复发布同一草稿不重复写入审计;发布基线已变化的旧草稿不能发布。
## 本地应用与恢复
实例保存 `.starlight-pack.json` 作为上次同步清单。先验证并缓存全部变动文件,再通过持久化操作记录替换文件;失败或中断后恢复已替换的旧文件和实例配置。安装任务尚未停止时不开始恢复。运行中的游戏禁止同步。
成功同步后才保存 `syncMarker`。每次启动先请求轻量标志;本地标志、包 ID、发布 ID 均匹配且实例已安装时,复用本地包清单与加载器信息,跳过整合包清单请求及未变化包文件的哈希检查。首次使用、缺少标志、安装失败、发布新版本或切换默认包均不能走此捷径。草稿上传不改变标志。本地整合包文件的手动改动不会因为标志相同而触发完整修复。
管理员在「Mod 管理 → 管理整合包 → 整合包 Mod 标签」选择已有常用标签。标签按现有规则集合匹配 Mod每次启动独立获取最新清单并检查标签文件 SHA-256包标志相同也不跳过这一步。服务端保存内容快照上传新 Mod 不会破坏正在下载的旧快照。设置审计保存于 `launcher_modpack_tag_audit`,快照登记于 `launcher_tagged_mod_object`,文件位于 `launcher-modpacks/tagged-mods`,不得公开挂载。
标签 Mod 优先于整合包中同标识的旧 Mod安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
标签下载使用启动器有效并发设置,上限 16 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。
此弹窗在需要更新时自动出现,不引入新的首次使用操作,因此不增加引导步骤;现有下载页引导及弹窗「查看下载」入口继续适用。
删除只作用于上次清单管理且未被本地修改的文件。存档、截图、日志、备份、个人选项和服务器列表保留;`force=false` 的已修改本地文件也保留,并在手动同步结果中列出。其他用户添加的文件不作为删除目标。内部缓存与备份不会被 ZIP 覆盖,路径越界、大小写冲突、符号链接和 Windows 联接路径会被拒绝。跨版本只改变文件名大小写的更新目前需要新实例。
单次 ZIP 上限 4 GiB最多 100,000 个条目,单文件最多 2 GiB总解压大小最多 16 GiB。反向代理如另有限制应同步配置上传请求上限。对象、历史版本和本地备份目前保留尚未实现自动回收。
## 验证入口
- 服务端:`gradlew test --tests '*Modpack*'`,覆盖格式识别、内容校验、路径限制、文件差异、策略预览、审核前隔离、重复审核过期草稿。
- 服务端:`gradlew test --tests '*Modpack*'`,覆盖格式识别、内容校验、路径限制、文件差异、策略预览、审核前隔离、重复审核过期草稿、默认包审计及无凭据/无效凭据请求的隔离
- 启动器:`cargo test -p theseus --lib api::pack::hosted::tests`,覆盖路径限制、冲突检测和修改/新增/删除文件的中断恢复。
- 桌面命令与权限:`cargo check -p theseus_gui`