fix: 完善整合包同步与启动器交互

This commit is contained in:
2026-09-16 17:01:22 +08:00
parent bd09fed0ed
commit 51fb0c30f7
45 changed files with 1804 additions and 219 deletions

View File

@ -92,6 +92,7 @@ import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useDropImport } from '@/composables/useDropImport'
@ -2154,6 +2155,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

View File

@ -0,0 +1,240 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
type InstancePlayer,
type PlayerChoice,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const instanceId = ref('')
const locked = ref<InstancePlayer | null>(null)
const awaitingSkinLogin = ref(false)
let hideForLogin = false
let generation = 0
let resolveSelection: ((player: InstancePlayer) => void) | undefined
let rejectSelection: ((reason: Error) => void) | undefined
const choices = computed(() => {
const skin: PlayerChoice[] =
skinSiteStatus.value === 'signed-in'
? skinSitePlayers.value
.filter((player) => !player.isMojang)
.map((player) => ({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value?.uuid,
head: player.headDataUrl,
}))
: []
return [...skin, ...accounts.value].filter(
(player) =>
!locked.value ||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
player.account_type === locked.value.account_type &&
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
)
})
async function refresh() {
const revision = ++generation
loading.value = true
error.value = ''
try {
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
if (revision !== generation) return
accounts.value = available
.filter(
(account) =>
account.account_type === 'microsoft' ||
(mode === 'local' && account.account_type === 'offline'),
)
.map((account) => ({
id: account.profile.id,
name: account.profile.name,
account_type: account.account_type,
}))
await waitForSkinSiteSession()
if (revision !== generation) return
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
} catch (cause) {
if (revision === generation)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (revision === generation) loading.value = false
}
}
function cancelled() {
if (hideForLogin) {
hideForLogin = false
return
}
if (!active.value) return
active.value = false
awaitingSkinLogin.value = false
generation++
rejectSelection?.(new Error('已取消选择实例玩家'))
resolveSelection = undefined
rejectSelection = undefined
}
async function select(player: PlayerChoice) {
if (loading.value || saving.value || !active.value) return
saving.value = true
error.value = ''
try {
const { head: _, ...binding } = player
await saveInstancePlayer(instanceId.value, binding)
active.value = false
resolveSelection?.(binding)
resolveSelection = undefined
rejectSelection = undefined
modal.value?.hide()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
saving.value = false
}
}
function signInSkinSite() {
awaitingSkinLogin.value = true
hideForLogin = true
openSkinSiteLogin()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
instanceId.value = id
locked.value = saved
accounts.value = []
awaitingSkinLogin.value = false
active.value = true
modal.value?.show()
void refresh()
return new Promise((resolve, reject) => {
resolveSelection = resolve
rejectSelection = reject
})
})
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
if (!active.value || saving.value) return
if (
awaitingSkinLogin.value &&
skinSiteStatus.value === 'signed-in' &&
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
) {
awaitingSkinLogin.value = false
modal.value?.show()
}
void refresh()
})
onUnmounted(() => {
unregister()
cancelled()
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="!saving"
:on-hide="cancelled"
>
<div class="flex flex-col gap-3">
<p class="m-0 text-secondary">
{{
locked
? formatMessage(messages.restore, { name: locked.name })
: formatMessage(messages.remember)
}}
</p>
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
<template v-else>
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
<button
type="button"
:disabled="saving"
class="flex items-center gap-3"
@click="select(player)"
>
<img
v-if="player.head"
:src="player.head"
alt=""
class="size-8 rounded-md [image-rendering:pixelated]"
/>
<span>{{ player.name }}</span
><span class="text-secondary">{{
formatMessage(
player.account_type === 'microsoft'
? messages.licensed
: player.account_type === 'offline'
? messages.offline
: messages.skin,
)
}}</span>
</button>
</ButtonStyled>
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
</p>
</template>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
<ButtonStyled v-if="error"
><button :disabled="loading || saving" @click="refresh">
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
<ButtonStyled
v-if="
skinSiteStatus === 'signed-out' ||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
"
><button :disabled="saving" @click="signInSkinSite">
{{ formatMessage(messages.skinLogin) }}
</button></ButtonStyled
>
<div class="flex flex-col items-center gap-2">
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
<ButtonStyled
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
{{ formatMessage(messages.useMicrosoft) }}
</button></ButtonStyled
>
</div>
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const props = defineProps<{ instanceId: string }>()
const player = ref<InstancePlayer | null>(null)
const busy = ref(false)
const error = ref('')
let generation = 0
const stop = onInstancePlayerChanged((id, saved) => {
if (id === props.instanceId) {
generation++
player.value = saved
busy.value = false
error.value = ''
}
})
onUnmounted(() => {
generation++
stop()
})
watch(
() => props.instanceId,
async (id) => {
const revision = ++generation
player.value = null
error.value = ''
busy.value = true
try {
const saved = await getInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision) error.value = String(cause)
} finally {
if (generation === revision) busy.value = false
}
},
{ immediate: true },
)
async function change() {
if (busy.value) return
const revision = generation
const id = props.instanceId
busy.value = true
error.value = ''
try {
const saved = await chooseInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (generation === revision) busy.value = false
}
}
</script>
<template>
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
<p class="m-0 text-secondary">
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
</p>
<ButtonStyled
><button :disabled="busy" @click="change">
{{ formatMessage(busy ? messages.loading : messages.change) }}
</button></ButtonStyled
>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
</div>
</template>

View File

@ -133,14 +133,18 @@ function openDownloads() {
</section>
</div>
<template #actions>
<ButtonStyled
><button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button></ButtonStyled
>
<ButtonStyled
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui'
export const playerMessages = defineMessages({
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
remember: {
id: 'app.instance-player.remember',
defaultMessage:
'This instance will keep using your choice. Switch players in instance settings.',
},
restore: {
id: 'app.instance-player.restore',
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
},
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
empty: {
id: 'app.instance-player.empty',
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
},
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
useMicrosoft: {
id: 'app.instance-player.use-microsoft',
defaultMessage: 'Use a Minecraft account',
},
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
firstLaunch: {
id: 'app.instance-player.first-launch',
defaultMessage: 'Choose a player on first launch',
},
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
})

View File

@ -22,7 +22,8 @@ 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 brushCursor = ref<string>()
const brushActive = computed(() => props.playing && props.selected >= 0)
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
@ -87,37 +88,21 @@ 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,
}
let cursorColor = ''
function updateBrushCursor() {
if (!brushActive.value || !grid.value) return
const color = getComputedStyle(grid.value).color
if (color === cursorColor) return
cursorColor = color
// Let the native cursor follow the pointer independently of Vue and board rendering.
// The hotspot is the same brush tip as the previous 32px floating SVG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" 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>`
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
}
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' },
)
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
@ -135,6 +120,7 @@ function label(index: number) {
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
@ -142,7 +128,10 @@ onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
getView: () => ({
left: viewport.value?.scrollLeft ?? 0,
top: viewport.value?.scrollTop ?? 0,
}),
})
</script>
@ -152,16 +141,18 @@ defineExpose({
<div
ref="grid"
class="mine-grid"
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
:class="{
'mine-grid-small': !large,
'mine-grid-brush-active': brushActive,
}"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
'--mine-brush-cursor': brushCursor,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointermove="moveBrushCursor"
@pointerleave="hideBrushCursor"
@pointercancel="hideBrushCursor"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@ -190,24 +181,6 @@ 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"
@ -260,7 +233,10 @@ defineExpose({
<style scoped>
.mine-navigation {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
display: grid;
gap: var(--gap-md);
}
@ -268,6 +244,9 @@ defineExpose({
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
@ -308,17 +287,9 @@ defineExpose({
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.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%);
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
@ -392,12 +363,4 @@ 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

@ -301,6 +301,7 @@ defineExpose({ show })
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
scrollable
:closable="false"
:close-on-esc="false"
actions-divider
@ -455,15 +456,21 @@ defineExpose({ show })
justify-content: space-between;
}
.mine-frame {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.mine-actions {
justify-content: flex-end;

View File

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

View File

@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.',
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',

View File

@ -28,6 +28,17 @@ import {
skinSiteUser,
} from './skin-site-session.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
})
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> }> = []

View File

@ -1,4 +1,4 @@
import { readonly, ref } from 'vue'
import { readonly, ref, watch } from 'vue'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
@ -113,6 +113,22 @@ export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() {
if (status.value !== 'checking') return
await new Promise<void>((resolve, reject) => {
const stop = watch(status, next => {
if (next === 'checking') return
clearTimeout(timer)
stop()
resolve()
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
@ -138,6 +154,7 @@ function rejectPendingSkinUpdateRequests(message: string) {
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')

View File

@ -3,6 +3,7 @@ import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
let sessionUpdate: Promise<void> = Promise.resolve()
@ -28,6 +29,7 @@ export function clearHostedSession(): Promise<void> {
}
export async function prepareHostedSession(): Promise<void> {
await waitForSkinSiteSession()
const userId = skinSiteUser.value?.uuid
const token = await requestSkinSiteDownloadToken()
const update = sessionUpdate

View File

@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
setSkinSiteFrame,
resetSkinSiteSession,
receiveSkinSiteMessage,
SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts'
import {
prepareInstancePlayer,
registerInstancePlayerPicker,
saveInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => {
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
const saved: InstancePlayer = { id: 'saved', name: 'Saved', account_type: 'microsoft' }
let binding: InstancePlayer | null = saved
let accounts = [
{ profile: { id: 'other', name: 'Other' }, account_type: 'microsoft' },
{ profile: { id: 'saved', name: 'Saved' }, account_type: 'microsoft' },
]
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
invoke: async (command: string) => {
if (command === 'plugin:auth|get_instance_player') return binding
if (command === 'plugin:auth|get_users') return accounts
throw new Error(`Unexpected command ${command}`)
},
},
},
})
const requests: Array<InstancePlayer | null> = []
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
requests.push(locked)
binding = saved
return saved
})
try {
setSkinSiteFrame({ postMessage() {} } as unknown as Window)
await prepareInstancePlayer('instance')
assert.equal(requests.length, 0)
setSkinSiteFrame(null)
binding = null
await Promise.all([prepareInstancePlayer('instance'), prepareInstancePlayer('instance')])
assert.deepEqual(requests, [null])
await prepareInstancePlayer('instance')
assert.equal(requests.length, 1)
accounts = accounts.filter((account) => account.profile.id !== saved.id)
await prepareInstancePlayer('instance')
assert.equal(
requests[1],
saved,
'Missing saved player must remain locked, not fall back to the other account',
)
} finally {
unregister()
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('signed-in skin site renews the saved player without prompting and only notifies after saving', async () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
const player: InstancePlayer = {
id: '12345678-1234-1234-1234-123456789abc',
name: 'Skin',
account_type: 'yggdrasil',
skin_site_user: 'owner',
}
const calls: string[] = []
let failSave = false
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
},
} as unknown 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)
if (command.endsWith('get_instance_player')) return player
if (command.endsWith('login_skin_site_player')) {
assert.equal(args.playerId, player.id)
assert.equal(args.userId, 'owner')
assert.equal(args.token, 'fixture.jwt')
} else if (command.endsWith('set_instance_player') && failSave)
throw new Error('Save failed')
},
},
},
})
let prompts = 0
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
prompts++
assert.equal(locked, player)
return player
})
const changes: string[] = []
const stop = onInstancePlayerChanged((id) => changes.push(id))
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
},
} as unknown as MessageEvent,
frame,
)
await prepareInstancePlayer('one')
assert.equal(prompts, 0)
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
await saveInstancePlayer('one', player)
assert.deepEqual(changes, ['one'])
failSave = true
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
assert.deepEqual(changes, ['one'])
resetSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 1)
} finally {
stop()
unregister()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
}
})

View File

@ -0,0 +1,104 @@
import { invoke } from '@tauri-apps/api/core'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
export { waitForSkinSiteSession } from '../composables/skin-site-session.ts'
import { users } from './auth.js'
import { isOfflineMode } from '../composables/useNetworkStatus.ts'
export type InstancePlayer = {
id: string
name: string
account_type: 'microsoft' | 'yggdrasil' | 'offline'
skin_site_user?: string | null
}
export type PlayerChoice = InstancePlayer & { head?: string }
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
let picker: Picker | undefined
const preparing = new Map<string, Promise<void>>()
const listeners = new Set<(instanceId: string, player: InstancePlayer) => void>()
export function onInstancePlayerChanged(
listener: (instanceId: string, player: InstancePlayer) => void,
) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function registerInstancePlayerPicker(value: Picker) {
picker = value
return () => {
if (picker === value) picker = undefined
}
}
export const getInstancePlayer = (instanceId: string) =>
invoke<InstancePlayer | null>('plugin:auth|get_instance_player', { instanceId })
export async function authenticateInstancePlayer(player: InstancePlayer) {
if (!player.skin_site_user) return
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== player.skin_site_user) {
throw new Error(`请登录实例玩家 ${player.name} 所属的皮肤站账号。`)
}
const token = await requestSkinSiteDownloadToken()
await invoke('plugin:auth|login_skin_site_player', {
token,
playerId: player.id,
userId: player.skin_site_user,
})
if (skinSiteUser.value?.uuid !== player.skin_site_user || skinSiteStatus.value !== 'signed-in') {
throw new Error('皮肤站账号已变化,请重试。')
}
}
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
await authenticateInstancePlayer(player)
await invoke('plugin:auth|set_instance_player', { instanceId, player })
for (const listener of listeners) listener(instanceId, player)
}
export async function chooseInstancePlayer(
instanceId: string,
locked: InstancePlayer | null = null,
) {
if (!picker) throw new Error('玩家选择界面尚未就绪,请稍后重试。')
return picker(instanceId, locked)
}
export function prepareInstancePlayer(instanceId: string): Promise<void> {
if (isOfflineMode()) return Promise.resolve()
const current = preparing.get(instanceId)
if (current) return current
const task = (async () => {
const saved = await getInstancePlayer(instanceId)
if (saved && !saved.skin_site_user) {
const available = await users()
if (
available.some(
(account) =>
account.profile.id === saved.id && account.account_type === saved.account_type,
)
)
return
}
if (saved?.skin_site_user) await waitForSkinSiteSession()
if (
saved?.skin_site_user &&
skinSiteUser.value?.uuid === saved.skin_site_user &&
skinSiteStatus.value === 'signed-in'
) {
await authenticateInstancePlayer(saved)
return
}
await chooseInstancePlayer(instanceId, saved)
})().finally(() => {
preparing.delete(instanceId)
})
preparing.set(instanceId, task)
return task
}

View File

@ -18,6 +18,7 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player'
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'
@ -1074,6 +1075,7 @@ export async function run(
instanceId: string,
serverAddress: string | null = null,
): Promise<InstanceRunResult> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {

View File

@ -4,6 +4,8 @@ import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { isOfflineMode } from '@/composables/useNetworkStatus'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
import { openPath } from '@/helpers/utils'
@ -248,6 +250,8 @@ export async function start_join_singleplayer_world(
instanceId: string,
world: string,
): Promise<unknown> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_singleplayer_world', {
instanceId,
@ -259,6 +263,8 @@ export async function start_join_singleplayer_world(
}
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_server', {
instanceId,

View File

@ -1,4 +1,21 @@
{
"app.instance-player.title": { "message": "Choose an instance player" },
"app.instance-player.remember": { "message": "This instance will keep using your choice. Switch players in instance settings." },
"app.instance-player.restore": { "message": "Sign in again as {name}. To choose someone else, open instance settings." },
"app.instance-player.loading": { "message": "Loading signed-in players…" },
"app.instance-player.licensed": { "message": "Minecraft account" },
"app.instance-player.skin": { "message": "Skin site player" },
"app.instance-player.offline": { "message": "Offline player" },
"app.instance-player.empty": { "message": "No available skin site players. You can create a player on the skin site." },
"app.instance-player.sign-in": { "message": "Sign in to choose a player." },
"app.instance-player.retry": { "message": "Retry" },
"app.instance-player.skin-login": { "message": "Sign in to skin site" },
"app.instance-player.or": { "message": "Or" },
"app.instance-player.use-microsoft": { "message": "Use a Minecraft account" },
"app.instance-player.saving": { "message": "Saving instance player…" },
"app.instance-player.setting": { "message": "Instance player" },
"app.instance-player.first-launch": { "message": "Choose a player on first launch" },
"app.instance-player.change": { "message": "Switch instance player" },
"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" },
@ -5861,7 +5878,7 @@
"message": "Switch your home"
},
"app.onboarding.instance-actions.description": {
"message": "Launch, stop, repair, configure, export, or open the instance from its header."
"message": "Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
},
"app.onboarding.instance-actions.title": {
"message": "The main controls"
@ -7721,7 +7738,7 @@
"message": "Tiny Takeover"
},
"app.skins.skin-site-account.description": {
"message": "Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages."
"message": "Select a skin below and apply it directly to {player}."
},
"app.skins.skin-site-account.empty-description": {
"message": "This skin site account does not have an available player profile yet."

View File

@ -1,4 +1,21 @@
{
"app.instance-player.title": { "message": "选择实例玩家" },
"app.instance-player.remember": { "message": "选择后,此实例将一直使用该玩家。可在实例设置中切换。" },
"app.instance-player.restore": { "message": "请重新登录 {name};要更换玩家,请前往实例设置。" },
"app.instance-player.loading": { "message": "正在读取已登录的玩家…" },
"app.instance-player.licensed": { "message": "正版玩家" },
"app.instance-player.skin": { "message": "皮肤站玩家" },
"app.instance-player.offline": { "message": "离线玩家" },
"app.instance-player.empty": { "message": "当前账号没有可用的皮肤站玩家,可在皮肤站创建玩家。" },
"app.instance-player.sign-in": { "message": "请登录账号后选择玩家。" },
"app.instance-player.retry": { "message": "重试" },
"app.instance-player.skin-login": { "message": "登录皮肤站" },
"app.instance-player.or": { "message": "或者" },
"app.instance-player.use-microsoft": { "message": "使用正版账号" },
"app.instance-player.saving": { "message": "正在保存实例玩家…" },
"app.instance-player.setting": { "message": "实例玩家" },
"app.instance-player.first-launch": { "message": "首次启动时选择玩家" },
"app.instance-player.change": { "message": "切换实例玩家" },
"app.hosted-mods.title": { "message": "正在更新服务器 Mod" },
"app.hosted-mods.description": { "message": "所有更新安装完成后将继续启动游戏。" },
"app.hosted-mods.complete": { "message": "下载完成" },
@ -5947,7 +5964,7 @@
"message": "切换主页样式"
},
"app.onboarding.instance-actions.description": {
"message": "从实例页头部启动、停止、修复、配置、导出或打开这个实例。"
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
},
"app.onboarding.instance-actions.title": {
"message": "主操作台"
@ -7792,7 +7809,7 @@
"message": "小鬼当家"
},
"app.skins.skin-site-account.description": {
"message": "在下方选择皮肤后,可直接应用到 {player}。切换启动器页面时,皮肤站登录状态也会保持。"
"message": "在下方选择皮肤后,可直接应用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "这个皮肤站账号暂时没有可用的玩家角色。"

View File

@ -7483,7 +7483,7 @@
"message": "小鬼當家"
},
"app.skins.skin-site-account.description": {
"message": "在下方選擇皮膚後,可直接套用到 {player}。切換啟動器頁面時,皮膚站登入狀態也會保持。"
"message": "在下方選擇皮膚後,可直接套用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"

View File

@ -231,8 +231,7 @@ const messages = defineMessages({
},
skinSiteManagementDescription: {
id: 'app.skins.skin-site-account.description',
defaultMessage:
'Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages.',
defaultMessage: 'Select a skin below and apply it directly to {player}.',
},
skinSiteMojangDescription: {
id: 'app.skins.skin-site-account.mojang-description',

View File

@ -10,7 +10,7 @@ import {
import { onUnmounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import instanceThinkingImage from '@/assets/illustrations/instance-thinking.png'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
import { instance_listener } from '@/helpers/events.js'
@ -73,11 +73,15 @@ onUnmounted(() => {
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance flex flex-col items-center justify-center h-full gap-3">
<div class="icon">
<NewInstanceImage />
</div>
<h3>{{ formatMessage(messages.noInstances) }}</h3>
<div v-else class="flex flex-col items-center justify-center h-full gap-3">
<img
:src="instanceThinkingImage"
alt=""
aria-hidden="true"
class="h-40 w-40 object-contain"
draggable="false"
/>
<h3 class="m-0">{{ formatMessage(messages.noInstances) }}</h3>
<ButtonStyled color="brand">
<button
data-onboarding-id="create-instance"
@ -91,19 +95,3 @@ onUnmounted(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
.no-instance {
p,
h3 {
margin: 0;
}
.icon {
svg {
width: 10rem;
height: 10rem;
}
}
}
</style>

View File

@ -111,6 +111,9 @@ fn main() {
"poll_device_login",
"begin_yggdrasil_login",
"finish_yggdrasil_login",
"login_skin_site_player",
"get_instance_player",
"set_instance_player",
"list_yggdrasil_saved_logins",
"get_yggdrasil_password",
"set_yggdrasil_password",

View File

@ -31,6 +31,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
get_default_user,
set_default_user,
get_users,
get_instance_player,
set_instance_player,
login_skin_site_player,
])
.build()
}
@ -318,6 +321,33 @@ pub async fn finish_yggdrasil_login(
const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins";
#[tauri::command]
pub async fn get_instance_player(
instance_id: String,
) -> Result<Option<minecraft_auth::InstancePlayer>> {
Ok(minecraft_auth::get_instance_player(&instance_id).await?)
}
#[tauri::command]
pub async fn set_instance_player(
instance_id: String,
player: minecraft_auth::InstancePlayer,
) -> Result<()> {
Ok(minecraft_auth::set_instance_player(&instance_id, player).await?)
}
#[tauri::command]
pub async fn login_skin_site_player(
token: String,
player_id: uuid::Uuid,
user_id: String,
) -> Result<Credentials> {
Ok(
minecraft_auth::login_skin_site_player(&token, player_id, &user_id)
.await?,
)
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SavedYggdrasilLogin {
pub api_root: String,
@ -443,22 +473,24 @@ fn yggdrasil_saved_logins_entry() -> Result<keyring::Entry> {
fn read_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
match yggdrasil_saved_logins_entry()?.get_password() {
Ok(saved_logins) => match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
Ok(saved_logins) => {
match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
}
}
},
}
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(error) => Err(keyring_error(error)),
}

View File

@ -462,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
})
.transpose()?,
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: None,
java_path: edit_instance.java_path,
extra_launch_args: edit_instance.extra_launch_args,

View File

@ -354,7 +354,8 @@ unsafe extern "system" fn maximize_if_owned_by_process(
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, SetForegroundWindow, ShowWindow,
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE,
SetForegroundWindow, ShowWindow,
};
use windows::core::BOOL;

View File

@ -148,7 +148,6 @@ pub fn run() -> Result<(), String> {
let arguments = parse_arguments()?;
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let window = WindowBuilder::new()
.with_title("Starlight Launcher")
.with_inner_size(LogicalSize::new(940.0, 620.0))
.with_min_inner_size(LogicalSize::new(940.0, 620.0))

View File

@ -78,19 +78,32 @@ async fn run_with_extra_launch_args_inner(
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
let state = State::get().await?;
let launch_preparation_timeout =
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.await?;
let launch_preparation_timeout = context
.as_ref()
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
.clamp(
MIN_LAUNCH_PREPARATION_TIMEOUT,
MAX_LAUNCH_PREPARATION_TIMEOUT,
);
let default_account = if offline_mode {
let saved_player = context
.as_ref()
.and_then(|context| context.launch_overrides.player.as_ref());
let default_account = if let Some(player) = saved_player {
if offline_mode
&& player.account_type
!= crate::state::MinecraftAccountType::Offline
{
return Err(crate::ErrorKind::InputError("当前实例绑定的是在线玩家,请恢复网络后启动,或在实例设置中手动切换玩家".into()).as_error());
}
Credentials::for_instance_player(player, &state.pool).await?
} else if offline_mode {
Credentials::get_offline_credential(&state.pool)
.await?
.ok_or_else(|| {

View File

@ -7,6 +7,7 @@ use std::time::Duration;
use uuid::Uuid;
use crate::State;
pub use crate::state::InstancePlayer;
pub use crate::state::YggdrasilLoginResult;
use crate::state::{
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
@ -172,6 +173,72 @@ pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
crate::state::normalize_api_root(api_root)
}
pub async fn get_instance_player(
instance_id: &str,
) -> crate::Result<Option<crate::state::InstancePlayer>> {
let state = State::get().await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".into()).as_error()
})?;
Ok(context.launch_overrides.player)
}
pub async fn set_instance_player(
instance_id: &str,
player: crate::state::InstancePlayer,
) -> crate::Result<()> {
let state = State::get().await?;
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
let account = accounts.get(&player.id).ok_or_else(|| {
crate::ErrorKind::InputError("所选玩家未登录,请重新登录该账号".into())
.as_error()
})?;
if account.account_type != player.account_type
|| player.skin_site_user.as_ref().is_some_and(|user| {
account.yggdrasil.as_ref().is_none_or(|ygg| {
ygg.login != *user
|| ygg.api_root != "https://skin.starlight.cool/yggdrasil"
})
})
{
return Err(
crate::ErrorKind::InputError("玩家身份不匹配".into()).as_error()
);
}
drop(account);
crate::state::edit_instance(
instance_id,
crate::state::EditInstance {
launch_overrides: Some(
crate::state::InstanceLaunchOverridesPatch {
player: Some(player),
..Default::default()
},
),
..Default::default()
},
&state.pool,
)
.await?;
Ok(())
}
pub async fn login_skin_site_player(
token: &str,
player_id: uuid::Uuid,
user_id: &str,
) -> crate::Result<Credentials> {
let state = State::get().await?;
crate::state::login_skin_site_player(token, player_id, user_id, &state.pool)
.await
}
#[tracing::instrument]
pub async fn get_default_user(
offline_mode: bool,

View File

@ -1,6 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod progress;
mod tagged;
mod transport;
use crate::{
State,
state::{
@ -32,6 +33,7 @@ use tokio::{
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
const BINDING: &str = ".starlight-pack.json";
const JOURNAL: &str = ".starlight-pack-pending.json";
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
LazyLock::new(dashmap::DashMap::new);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
@ -230,11 +232,42 @@ fn validate(files: &[PackFile]) -> crate::Result<()> {
Ok(())
}
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
#[cfg(windows)]
fn filesystem_path(path: &Path) -> PathBuf {
use std::ffi::OsString;
use std::os::windows::ffi::{OsStrExt, OsStringExt};
if !path.is_absolute() {
return path.to_path_buf();
}
let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
const BACKSLASH: u16 = b'\\' as u16;
const QUESTION_MARK: u16 = b'?' as u16;
if path.starts_with(&[BACKSLASH, BACKSLASH, QUESTION_MARK, BACKSLASH]) {
return PathBuf::from(OsString::from_wide(&path));
}
let mut extended = "\\\\?\\".encode_utf16().collect::<Vec<_>>();
if path.starts_with(&[BACKSLASH, BACKSLASH]) {
extended.extend("UNC\\".encode_utf16());
extended.extend_from_slice(&path[2..]);
} else {
extended.extend_from_slice(&path);
}
PathBuf::from(OsString::from_wide(&extended))
}
#[cfg(not(windows))]
fn filesystem_path(path: &Path) -> PathBuf {
path.to_path_buf()
}
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for part in relative.split('/') {
path.push(part);
match std::fs::symlink_metadata(&path) {
match std::fs::symlink_metadata(filesystem_path(&path)) {
Ok(meta) => {
#[cfg(windows)]
{
@ -250,21 +283,32 @@ fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(
crate::util::io::IOError::with_path(e, &path).into()
);
}
}
}
Ok(path)
// Windows' legacy Win32 path parser reports ERROR_PATH_NOT_FOUND once a
// perfectly valid pack path exceeds MAX_PATH. Extended-length paths keep
// deep KubeJS/data-pack trees addressable without changing their layout.
Ok(filesystem_path(&path))
}
async fn hash(path: &Path) -> crate::Result<Option<String>> {
let mut input = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(crate::util::io::IOError::with_path(e, path).into());
}
};
let mut digest = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let n = input.read(&mut buffer).await?;
let n = input.read(&mut buffer).await.map_err(|error| {
crate::util::io::IOError::with_path(error, path)
})?;
if n == 0 {
break;
}
@ -276,10 +320,11 @@ async fn read_json<T: serde::de::DeserializeOwned>(
root: &Path,
name: &str,
) -> crate::Result<Option<T>> {
match tokio::fs::read(target(root, name)?).await {
let path = target(root, name)?;
match tokio::fs::read(&path).await {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
Err(e) => Err(crate::util::io::IOError::with_path(e, &path).into()),
}
}
fn write_json<T: Serialize>(
@ -289,10 +334,31 @@ fn write_json<T: Serialize>(
) -> crate::Result<()> {
use std::io::Write;
let dest = target(root, name)?;
let mut file = tempfile::NamedTempFile::new_in(root)?;
file.write_all(&serde_json::to_vec(value)?)?;
file.as_file().sync_all()?;
file.persist(dest).map_err(|e| e.error)?;
let mut file = tempfile::NamedTempFile::new_in(root).map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(error, root))
.with_context(format!("创建 {name} 的临时文件失败"))
})?;
let temporary = file.path().to_path_buf();
file.write_all(&serde_json::to_vec(value)?)
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("写入 {name} 的临时文件失败"))
})?;
file.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("同步 {name} 的临时文件失败"))
})?;
file.persist(&dest).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交 {name} 失败;临时文件:{};目标文件:{}",
temporary.display(),
dest.display()
))
})?;
Ok(())
}
async fn authorization() -> crate::Result<String> {
@ -330,18 +396,22 @@ async fn request_authorized<T: serde::de::DeserializeOwned>(
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 client = configured_client().await?;
let response =
transport::metadata_request(&client, &format!("{API}{suffix}"), auth)
.await?;
if !response.status().is_success() {
let message = transport::response_failure(
&client,
response,
"https://skin.starlight.cool/starlight/user",
suffix,
auth,
)
.await;
ensure_session(auth).await?;
tracing::warn!("{message}");
return Err(invalid(message));
}
let data: Response<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
@ -433,7 +503,13 @@ async fn apply_files(
) -> crate::Result<()> {
for action in actions {
let live = target(root, &action.path)?;
if hash(&live).await? != action.old_hash {
if hash(&live).await.map_err(|error| {
error.with_context(format!(
"检查整合包目标文件失败:{}",
live.display()
))
})? != action.old_hash
{
return Err(invalid(format!(
"File changed during sync: {}",
action.path
@ -442,16 +518,82 @@ async fn apply_files(
if action.old_hash.is_some() {
let backup =
target(root, &format!("{}/{}", backup_dir, action.path))?;
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
tokio::fs::rename(&live, backup).await?;
let backup_parent = backup.parent().unwrap();
tokio::fs::create_dir_all(backup_parent).await.map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
backup_parent,
))
.with_context(format!(
"创建整合包备份目录失败:{}",
backup_parent.display()
))
},
)?;
tokio::fs::rename(&live, &backup).await.map_err(|error| {
crate::Error::from(error).with_context(format!(
"备份整合包文件失败;源文件:{};目标文件:{}",
live.display(),
backup.display()
))
})?;
}
if let Some(next) = &action.next_hash {
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
let staged =
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
staged.as_file().sync_all()?;
staged.persist(&live).map_err(|e| e.error)?;
let live_parent = live.parent().unwrap();
tokio::fs::create_dir_all(live_parent)
.await
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包目标目录失败:{}",
live_parent.display()
))
})?;
let staged = tempfile::NamedTempFile::new_in(live_parent).map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包临时文件失败:{}",
live.display()
))
},
)?;
let staged_path = staged.path().to_path_buf();
let cache_object = target(cache, next)?;
tokio::fs::copy(&cache_object, &staged_path)
.await
.map_err(|error| {
crate::Error::from(error).with_context(format!(
"复制整合包缓存文件失败;缓存文件:{};临时文件:{};目标文件:{}",
cache_object.display(),
staged_path.display(),
live.display()
))
})?;
staged.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&staged_path,
))
.with_context(format!(
"同步整合包临时文件失败:{}",
staged_path.display()
))
})?;
staged.persist(&live).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交整合包文件失败;临时文件:{};目标文件:{}",
staged_path.display(),
live.display()
))
})?;
}
}
Ok(())
@ -975,7 +1117,7 @@ async fn synchronize_with_progress(
let concurrency = crate::api::settings::get()
.await?
.effective_max_concurrent_downloads()
.clamp(1, 16);
.clamp(1, MAX_HOSTED_CONCURRENT_FILES);
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
let completed = AtomicUsize::new(0);
progress.download(
@ -1038,6 +1180,10 @@ async fn synchronize_with_progress(
};
let mut request =
DownloadRequest::new(&url, ResourceClass::Modpack)
// The batch already downloads several files in
// parallel. Do not let an HTTP/1 fallback multiply
// every large file into another four connections.
.with_http1_segmented_download(false)
.with_integrity(Integrity {
size: Some(file_size),
sha256: Some(file.sha256),
@ -1047,14 +1193,34 @@ async fn synchronize_with_progress(
request = request
.with_header("Authorization", auth.clone());
}
download_to_path(
request,
let first_result = download_to_path(
request.clone(),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
.await;
if let Err(error) = first_result {
tracing::warn!(
path = %file_path,
%error,
"Parallel StarLight file download failed; retrying after the batch load has eased"
);
ensure_session(auth).await?;
tokio::time::sleep(std::time::Duration::from_millis(
750,
))
.await;
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
}
let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = {
@ -1158,12 +1324,33 @@ async fn synchronize_with_progress(
}
ensure_session(&auth).await?;
progress.update(0, 0, "正在应用更新", true);
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
apply_files(&root, &cache, &journal.backup, &journal.actions)
.await
.map_err(|error| error.with_context(format!(
"应用 StarLight 整合包文件失败;实例目录:{}",
root.display()
)))?;
progress.update(0, 0, "正在完成安装", true);
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })
.map_err(|error| error.with_context(format!(
"写入 StarLight 整合包绑定信息失败;实例目录:{}",
root.display()
)))?;
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?;
crate::instance::sync_content_files(instance_id).await.map_err(|error| {
error.with_context(format!(
"扫描 StarLight 整合包内容失败;实例目录:{}",
root.display()
))
})?;
let journal_path = target(&root, JOURNAL)?;
tokio::fs::remove_file(&journal_path).await.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&journal_path,
))
.with_context("清理 StarLight 整合包安装日志失败")
})?;
Ok::<_, crate::Error>(())
}.await;
if let Err(error) = result {

View File

@ -184,6 +184,41 @@ mod tests {
assert!(root.path().join("mods/personal.jar").is_file());
}
#[tokio::test]
async fn identical_tagged_mod_keeps_the_modpack_file() {
let root = tempfile::tempdir().unwrap();
let original = PackFile {
mod_ids: vec![],
external: None,
path: "mods/example-from-pack.jar".into(),
sha256: "a".repeat(64),
size: 10,
force: true,
preserve: false,
};
let mut files = vec![original.clone()];
let mut sources = BTreeMap::from([(
original.path.clone(),
"https://example.invalid/pack-file".into(),
)]);
let mut tagged = manifest();
tagged.replaces.push(original.path.clone());
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut sources,
&tagged,
)
.await
.unwrap();
assert!(actions.is_empty());
assert_eq!(files, vec![original]);
assert!(!sources.contains_key("mods/example.starlight.jar"));
}
#[test]
fn multi_mod_jar_cannot_be_partially_replaced() {
assert!(is_managed_file(&manifest().files[0].pack_file()));
@ -266,10 +301,33 @@ pub(super) async fn merge(
if ids.is_empty() {
return Ok(Vec::new());
}
let mut replaced: HashSet<String> =
manifest.replaces.iter().cloned().collect();
// The modpack is the installation baseline. A tagged snapshot with the
// same content already passes verification, even when the skin site uses
// a canonical filename. Keep the pack's file and only replace it when the
// tagged JAR is actually different.
let satisfied_pack_paths: HashSet<String> = manifest
.files
.iter()
.filter_map(|tagged| {
files
.iter()
.find(|file| {
is_mod_path(&file.path) && file.sha256 == tagged.sha256
})
.map(|file| file.path.clone())
})
.collect();
let mut replaced: HashSet<String> = manifest
.replaces
.iter()
.filter(|path| !satisfied_pack_paths.contains(*path))
.cloned()
.collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
if replaced.contains(&file.path) {
if satisfied_pack_paths.contains(&file.path)
|| replaced.contains(&file.path)
{
continue;
}
let object = target(cache, &file.sha256)?;
@ -284,6 +342,11 @@ pub(super) async fn merge(
}
files.retain(|file| !replaced.contains(&file.path));
for file in &manifest.files {
if files.iter().any(|existing| {
is_mod_path(&existing.path) && existing.sha256 == file.sha256
}) {
continue;
}
if files
.iter()
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
@ -299,6 +362,11 @@ pub(super) async fn merge(
);
files.push(file.pack_file());
}
let desired_paths: HashSet<_> = files
.iter()
.filter(|file| is_mod_path(&file.path))
.map(|file| file.path.to_ascii_lowercase())
.collect();
let mut duplicate_actions = Vec::new();
let mods_dir = target(root, "mods")?;
if mods_dir.is_dir() {
@ -309,7 +377,9 @@ pub(super) async fn merge(
continue;
};
let path = format!("mods/{name}");
if !is_mod_path(&path) || manifest.contains(&path) {
if !is_mod_path(&path)
|| desired_paths.contains(&path.to_ascii_lowercase())
{
continue;
}
let local = target(root, &path)?;

View File

@ -0,0 +1,228 @@
use reqwest::{Client, Response, header};
pub(super) async fn metadata_request(
client: &Client,
url: &str,
auth: &str,
) -> Result<Response, reqwest::Error> {
client
.get(url)
.query(&[("starlight_request", uuid::Uuid::new_v4().to_string())])
.header(header::AUTHORIZATION, auth)
.header(header::CACHE_CONTROL, "no-cache, no-store")
.header(header::PRAGMA, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
}
async fn bounded_json(mut response: Response) -> Option<serde_json::Value> {
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.ok()? {
if bytes.len() + chunk.len() > 16_384 {
return None;
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).ok()
}
pub(super) async fn response_failure(
client: &Client,
response: Response,
user_url: &str,
suffix: &str,
auth: &str,
) -> String {
let status = response.status().as_u16();
let trace = response
.headers()
.get("eo-log-uuid")
.and_then(|value| value.to_str().ok())
.filter(|value| {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
})
.map(|value| format!("CDN 请求编号 {value}"))
.unwrap_or_default();
let context = format!("(整合包 HTTP {status};接口 {suffix}{trace}");
if let Some(body) = bounded_json(response).await {
if let Some(message) = body["errorMessage"]
.as_str()
.filter(|s| !s.trim().is_empty())
{
let token = auth.strip_prefix("Bearer ").unwrap_or(auth);
let message = if token.is_empty() {
message.to_owned()
} else {
message.replace(token, "[凭据已隐藏]")
};
let message: String = message
.chars()
.filter(|c| !c.is_control())
.take(500)
.collect();
return format!("StarLight 服务端返回:{message}{context}");
}
}
if !matches!(status, 401 | 403) {
return format!("StarLight 服务端请求失败{context}");
}
let detail = match metadata_request(client, user_url, auth).await {
Ok(user_response) => {
let user_status = user_response.status().as_u16();
if user_status == 200 {
let valid_user =
bounded_json(user_response).await.is_some_and(|body| {
body["payload"]["uuid"]
.as_str()
.is_some_and(|id| !id.is_empty())
&& body["payload"]["username"].is_string()
});
if valid_user {
"皮肤站登录有效,但整合包接口返回错误,且没有提供具体原因。请用请求编号查询服务端或 CDN 日志".to_owned()
} else {
"整合包接口拒绝访问,用户接口返回内容异常,无法确认登录状态"
.to_owned()
}
} else if matches!(user_status, 401 | 403) {
format!(
"整合包和用户接口均拒绝登录凭据(用户接口 HTTP {user_status})。请重新登录;若仍失败,请检查服务端鉴权及 CDN 配置"
)
} else {
format!(
"整合包接口拒绝访问,暂时无法复核登录状态(用户接口 HTTP {user_status}"
)
}
}
Err(_) => "整合包接口拒绝访问,用户接口连接失败,暂时无法复核登录状态"
.to_owned(),
};
format!("{detail}{context}")
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn hosted_auth_failure_verifies_same_credential_without_exposing_it()
{
for (pack_status, user_status, body, expected) in [
(
401,
200,
r#"{"payload":{"uuid":"owner","username":"Player"}}"#,
"皮肤站登录有效",
),
(403, 401, "{}", "均拒绝登录凭据"),
(401, 200, "<html>Proxy error</html>", "用户接口返回内容异常"),
] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for (status, body) in [(pack_status, ""), (user_status, body)] {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
requests.push(String::from_utf8(request).unwrap());
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nEO-LOG-UUID: trace-123\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
requests
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response =
metadata_request(&client, &format!("{base}/default"), auth)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/default",
auth,
)
.await;
assert!(message.contains(expected), "{message}");
assert!(message.contains(&format!("整合包 HTTP {pack_status}")));
assert!(message.contains("trace-123"));
assert!(!message.contains("test-secret"));
let requests = server.await.unwrap();
for (request, path) in requests.iter().zip(["/default", "/user"]) {
assert!(
request
.starts_with(&format!("GET {path}?starlight_request="))
);
let headers = request.to_ascii_lowercase();
assert!(headers.contains(
"authorization: bearer test-secret-never-display\r\n"
));
assert!(
headers.contains("cache-control: no-cache, no-store\r\n")
);
}
}
}
#[tokio::test]
async fn hosted_business_errors_preserve_server_reason_without_login_probe()
{
for status in [400, 403, 409, 500] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
let body = r#"{"error":"IllegalState","errorMessage":"整合包 Mod 标签已删除,请管理员重新设置 test-secret-never-display"}"#;
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response = metadata_request(
&client,
&format!("{base}/tagged-mods/2"),
auth,
)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/tagged-mods/2",
auth,
)
.await;
assert!(
message.contains("整合包 Mod 标签已删除,请管理员重新设置"),
"{message}"
);
assert!(message.contains("/tagged-mods/2"));
assert!(message.contains(&format!("HTTP {status}")));
assert!(!message.contains("test-secret"));
assert!(!message.contains("请重新登录"));
server.await.unwrap();
}
}
}

View File

@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: Some(crate::state::InstanceMode::Local),
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(

View File

@ -1,7 +1,7 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod hosted;
pub mod import;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;

View File

@ -16,7 +16,10 @@ mod tests {
fn user_agent_is_unique_and_contains_no_contact_information() {
let user_agent = user_agent("1.2.3", "windows");
assert_eq!(user_agent, "garbage-human-studio/starlight/1.2.3 (windows)");
assert_eq!(
user_agent,
"garbage-human-studio/starlight/1.2.3 (windows)"
);
assert!(!user_agent.contains("ghs.red"));
assert!(!user_agent.contains("http"));
assert!(!user_agent.contains('@'));

View File

@ -249,7 +249,11 @@ impl std::fmt::Display for Error {
impl Error {
pub(crate) fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
let context = context.into();
self.context = Some(match self.context.take() {
Some(existing) => format!("{existing}\n{context}"),
None => context,
});
self
}

View File

@ -22,17 +22,12 @@ const DEFAULT_CONSOLE_COLUMNS: usize = 80;
#[cfg(debug_assertions)]
const CONSOLE_TRUNCATION_MARKER: &str = "... [console output truncated]";
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(not(debug_assertions))]
const LAUNCHER_WARN_ERROR_MAX_BYTES: u64 = 30 * 1024 * 1024;
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_FILES: usize = 5;
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(3 * 24 * 60 * 60);
#[cfg(any(test, not(debug_assertions)))]
#[derive(Clone)]
struct RotatingLogWriter {
state: std::sync::Arc<std::sync::Mutex<RotatingLogState>>,
@ -40,7 +35,6 @@ struct RotatingLogWriter {
warn_error_max_bytes: u64,
}
#[cfg(any(test, not(debug_assertions)))]
struct RotatingLogState {
logs_dir: std::path::PathBuf,
session_name: String,
@ -51,7 +45,6 @@ struct RotatingLogState {
max_age: std::time::Duration,
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogWriter {
fn new(
logs_dir: std::path::PathBuf,
@ -120,7 +113,6 @@ impl RotatingLogWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogState {
fn write_event(
&mut self,
@ -157,7 +149,6 @@ impl RotatingLogState {
}
}
#[cfg(any(test, not(debug_assertions)))]
fn rotating_log_path(
logs_dir: &std::path::Path,
session_name: &str,
@ -170,7 +161,6 @@ fn rotating_log_path(
}
}
#[cfg(any(test, not(debug_assertions)))]
fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
@ -178,7 +168,6 @@ fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
.open(path)
}
#[cfg(any(test, not(debug_assertions)))]
fn cleanup_launcher_logs(
logs_dir: &std::path::Path,
max_files: usize,
@ -194,7 +183,6 @@ fn cleanup_launcher_logs(
);
}
#[cfg(any(test, not(debug_assertions)))]
fn cleanup_launcher_logs_at(
logs_dir: &std::path::Path,
max_files: usize,
@ -244,7 +232,6 @@ fn cleanup_launcher_logs_at(
}
}
#[cfg(any(test, not(debug_assertions)))]
fn launcher_log_is_expired(
created: std::time::SystemTime,
now: std::time::SystemTime,
@ -253,14 +240,12 @@ fn launcher_log_is_expired(
now.duration_since(created).is_ok_and(|age| age > max_age)
}
#[cfg(any(test, not(debug_assertions)))]
struct LogEventWriter {
writer: RotatingLogWriter,
buffer: Vec<u8>,
max_file_bytes: u64,
}
#[cfg(any(test, not(debug_assertions)))]
impl LogEventWriter {
fn commit(&mut self) -> std::io::Result<()> {
if self.buffer.is_empty() {
@ -272,7 +257,6 @@ impl LogEventWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl std::io::Write for LogEventWriter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.buffer.extend_from_slice(buffer);
@ -285,14 +269,12 @@ impl std::io::Write for LogEventWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl Drop for LogEventWriter {
fn drop(&mut self) {
let _ = self.commit();
}
}
#[cfg(any(test, not(debug_assertions)))]
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
type Writer = LogEventWriter;
@ -314,7 +296,6 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogWriter {
fn event_writer(&self, max_file_bytes: u64) -> LogEventWriter {
LogEventWriter {
@ -627,11 +608,35 @@ impl std::io::Write for TruncatedConsoleWriter {
}
}
// Handling for the live development logging
// This will log to the console, and will not log to a file
// Handling for live development logging. Keep the compact console output, but
// also write the full event stream so exported error reports contain the
// session that produced the error.
#[cfg(debug_assertions)]
pub fn start_logger(_app_identifier: &str) -> Option<()> {
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use tracing_subscriber::fmt::time::ChronoLocal;
use tracing_subscriber::prelude::*;
let logs_dir = DirectoryInfo::launcher_logs_dir_path(app_identifier)
.or_else(|| {
eprintln!("Could not resolve launcher logs directory");
None
})?;
let session_name =
format!("session_{}", Local::now().format("%Y%m%d_%H%M%S"));
let file_writer = RotatingLogWriter::new(
logs_dir,
session_name,
LAUNCHER_LOG_MAX_BYTES,
LAUNCHER_WARN_ERROR_MAX_BYTES,
LAUNCHER_LOG_MAX_FILES,
LAUNCHER_LOG_MAX_AGE,
)
.map_err(|error| {
eprintln!("Could not start launcher log writer: {error}");
error
})
.ok()?;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
@ -646,6 +651,12 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
stdout: std::io::stdout(),
}
}))
.with(
tracing_subscriber::fmt::layer()
.with_writer(file_writer)
.with_ansi(false)
.with_timer(ChronoLocal::rfc_3339()),
)
.with(filter)
.with(tracing_error::ErrorLayer::default())
.init();

View File

@ -515,19 +515,18 @@ fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
))
})?;
let backup_dir = base.join("Backups").join("app-db");
match db_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
{
Some("beta") | Some("release") => Ok(backup_dir.join(
db_path
.parent()
.and_then(Path::file_name)
.expect("database channel directory has a name"),
)),
_ => Ok(backup_dir),
Ok(default_app_db_backup_dir(base))
}
fn default_app_db_backup_dir(database_dir: &Path) -> PathBuf {
match database_dir.file_name().and_then(|name| name.to_str()) {
Some(channel @ ("beta" | "release")) => database_dir
.parent()
.unwrap_or_else(|| Path::new(""))
.join("Backups")
.join("app-db")
.join(channel),
_ => database_dir.join("Backups").join("app-db"),
}
}
@ -653,6 +652,21 @@ async fn create_sqlite_snapshot(
mod tests {
use super::*;
#[test]
fn recovery_and_update_share_the_channel_backup_directory() {
for channel in ["release", "beta"] {
let settings = Path::new("launcher-settings");
assert_eq!(
default_app_db_backup_dir(&settings.join(channel)),
settings.join("Backups").join("app-db").join(channel)
);
}
assert_eq!(
default_app_db_backup_dir(Path::new("legacy-settings")),
Path::new("legacy-settings").join("Backups").join("app-db")
);
}
async fn create_test_app_db(path: &Path, marker: &str) {
let options = SqliteConnectOptions::new()
.filename(path)

View File

@ -54,6 +54,7 @@ pub struct EditInstance {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InstanceLaunchOverridesPatch {
pub player: Option<crate::state::InstancePlayer>,
pub instance_mode: Option<crate::state::InstanceMode>,
#[serde(
default,
@ -373,6 +374,9 @@ fn apply_launch_overrides_patch(
if let Some(mode) = patch.instance_mode {
overrides.instance_mode = Some(mode);
}
if let Some(player) = patch.player {
overrides.player = Some(player);
}
if let Some(timeout) = patch.launch_preparation_timeout {
overrides.launch_preparation_timeout = timeout;
}

View File

@ -14,8 +14,19 @@ pub enum InstanceMode {
Local,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstancePlayer {
pub id: uuid::Uuid,
pub name: String,
pub account_type: crate::state::MinecraftAccountType,
#[serde(default)]
pub skin_site_user: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstanceLaunchOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player: Option<InstancePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_mode: Option<InstanceMode>,
pub instance_id: String,
@ -34,6 +45,7 @@ pub struct InstanceLaunchOverrides {
impl InstanceLaunchOverrides {
pub fn empty(instance_id: String) -> Self {
Self {
player: None,
instance_mode: None,
instance_id,
java_path: None,
@ -55,6 +67,8 @@ impl InstanceLaunchOverrides {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct InstanceLaunchOverridesData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player: Option<InstancePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_mode: Option<InstanceMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -83,6 +97,7 @@ impl InstanceLaunchOverridesData {
instance_id: String,
) -> InstanceLaunchOverrides {
InstanceLaunchOverrides {
player: self.player,
instance_mode: self.instance_mode,
instance_id,
java_path: self.java_path,
@ -101,6 +116,7 @@ impl InstanceLaunchOverridesData {
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
fn from(overrides: &InstanceLaunchOverrides) -> Self {
Self {
player: overrides.player.clone(),
instance_mode: overrides.instance_mode,
java_path: overrides.java_path.clone(),
extra_launch_args: overrides.extra_launch_args.clone(),
@ -154,4 +170,35 @@ mod tests {
);
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
}
#[test]
fn instance_player_survives_configuration_round_trip() {
let mut original = InstanceLaunchOverrides::empty("one".into());
let id = uuid::Uuid::new_v4();
original.player = Some(InstancePlayer {
id,
name: "SavedPlayer".into(),
account_type: crate::state::MinecraftAccountType::Yggdrasil,
skin_site_user: Some("owner".into()),
});
let encoded = serde_json::to_string(
&InstanceLaunchOverridesData::from(&original),
)
.unwrap();
let decoded: InstanceLaunchOverridesData =
serde_json::from_str(&encoded).unwrap();
let saved = decoded.into_launch_overrides("one".into()).player.unwrap();
assert_eq!(saved.id, id);
assert_eq!(saved.skin_site_user.as_deref(), Some("owner"));
assert_eq!(
saved.account_type,
crate::state::MinecraftAccountType::Yggdrasil
);
assert!(
serde_json::from_str::<InstanceLaunchOverridesData>("{}")
.unwrap()
.player
.is_none()
);
}
}

View File

@ -611,6 +611,7 @@ where
}
let launch_overrides = InstanceLaunchOverrides {
player: None,
instance_mode: None,
instance_id: instance_id.clone(),
java_path: input.java_path,

View File

@ -798,6 +798,39 @@ impl Credentials {
Self::get_active_with_refresh(exec, true).await
}
pub async fn for_instance_player(
player: &crate::state::InstancePlayer,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Self> {
let accounts = Self::get_all_without_refresh(exec).await?;
let mut account = accounts
.remove(&player.id)
.map(|(_, account)| account)
.ok_or_else(|| {
ErrorKind::InputError(format!(
"实例玩家 {} 已退出登录,请重新登录该玩家或在实例设置中切换",
player.name
))
.as_error()
})?;
if account.account_type != player.account_type
|| player.skin_site_user.as_ref().is_some_and(|user| {
account.yggdrasil.as_ref().is_none_or(|ygg| {
ygg.login != *user
|| ygg.api_root
!= "https://skin.starlight.cool/yggdrasil"
})
})
{
return Err(ErrorKind::InputError(
"实例玩家身份不匹配,请在实例设置中重新选择".into(),
)
.as_error());
}
account.refresh(exec).await?;
Ok(account)
}
pub async fn get_active_without_refresh(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Option<Self>> {
@ -900,6 +933,21 @@ impl Credentials {
pub async fn upsert(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
self.upsert_inner(exec, false).await
}
pub(crate) async fn upsert_preserving_selection(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
self.upsert_inner(exec, true).await
}
async fn upsert_inner(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
preserve_selection: bool,
) -> crate::Result<()> {
let profile = self.maybe_online_profile().await;
let expires = self.expires.timestamp();
@ -922,7 +970,7 @@ impl Credentials {
.as_ref()
.map_or("", |account| account.client_token.as_str());
if self.active {
if self.active && !preserve_selection {
sqlx::query!(
"
UPDATE minecraft_users
@ -943,7 +991,7 @@ impl Credentials {
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (uuid) DO UPDATE SET
active = $2,
active = CASE WHEN $12 THEN minecraft_users.active ELSE $2 END,
username = $3,
account_type = $4,
access_token = $5,
@ -956,7 +1004,7 @@ impl Credentials {
",
)
.bind(uuid)
.bind(self.active)
.bind(self.active && !preserve_selection)
.bind(&profile.name)
.bind(account_type)
.bind(&self.access_token)
@ -966,6 +1014,7 @@ impl Credentials {
.bind(yggdrasil_server_name)
.bind(yggdrasil_login)
.bind(yggdrasil_client_token)
.bind(preserve_selection)
.execute(exec)
.await?;
@ -1042,6 +1091,75 @@ impl Serialize for Credentials {
mod offline_account_tests {
use super::*;
#[tokio::test]
async fn instance_player_never_falls_back_to_global_account() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let first = Credentials::offline("First").unwrap();
let second = Credentials::offline("Second").unwrap();
first.upsert(&pool).await.unwrap();
let mut refreshed = Credentials::offline("First").unwrap();
refreshed.active = false;
refreshed.upsert_preserving_selection(&pool).await.unwrap();
assert_eq!(
Credentials::get_active_without_refresh(&pool)
.await
.unwrap()
.unwrap()
.offline_profile
.id,
first.offline_profile.id
);
second.upsert(&pool).await.unwrap();
first.upsert_preserving_selection(&pool).await.unwrap();
let binding = crate::state::InstancePlayer {
id: first.offline_profile.id,
name: "First".into(),
account_type: MinecraftAccountType::Offline,
skin_site_user: None,
};
assert_eq!(
Credentials::get_active_without_refresh(&pool)
.await
.unwrap()
.unwrap()
.offline_profile
.id,
second.offline_profile.id
);
assert_eq!(
Credentials::for_instance_player(&binding, &pool)
.await
.unwrap()
.offline_profile
.id,
first.offline_profile.id
);
let mismatched = crate::state::InstancePlayer {
account_type: MinecraftAccountType::Microsoft,
..binding.clone()
};
assert!(
Credentials::for_instance_player(&mismatched, &pool)
.await
.is_err()
);
sqlx::query("DELETE FROM minecraft_users WHERE uuid = ?")
.bind(binding.id.to_string())
.execute(&pool)
.await
.unwrap();
assert!(
Credentials::for_instance_player(&binding, &pool)
.await
.is_err()
);
}
#[test]
fn creates_java_compatible_offline_uuid() {
let credentials = Credentials::offline("Notch").unwrap();

View File

@ -199,7 +199,8 @@ pub async fn begin_yggdrasil_login(
.await?;
// 收集本次登录可用的所有角色;优先使用 availableProfiles回退到 selectedProfile。
let mut profiles: Vec<YggdrasilProfile> = response.available_profiles.clone();
let mut profiles: Vec<YggdrasilProfile> =
response.available_profiles.clone();
if profiles.is_empty() {
if let Some(selected) = response.selected_profile.clone() {
profiles.push(selected);
@ -607,10 +608,122 @@ fn create_credentials(
}
}
pub async fn login_skin_site_player(
token: &str,
player_id: Uuid,
user_id: &str,
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
) -> crate::Result<Credentials> {
#[derive(Deserialize)]
struct Envelope {
payload: SkinSiteLogin,
}
let client_token = Uuid::new_v4().to_string();
let response = reqwest::Client::builder()
.timeout(StdDuration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()?
.post("https://skin.starlight.cool/starlight/launcher/login")
.bearer_auth(token)
.json(&json!({ "playerId": player_id, "clientToken": client_token }))
.send()
.await?;
if !response.status().is_success() {
return Err(ErrorKind::InputError(format!(
"皮肤站玩家登录失败HTTP {}),请检查登录状态后重试",
response.status().as_u16()
))
.as_error());
}
let login = response.json::<Envelope>().await?.payload;
let credential =
skin_site_credentials(login, player_id, user_id, &client_token)?;
credential.upsert_preserving_selection(exec).await?;
Ok(credential)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SkinSiteLogin {
access_token: String,
client_token: String,
selected_profile: YggdrasilProfile,
user_id: String,
}
fn skin_site_credentials(
login: SkinSiteLogin,
player_id: Uuid,
user_id: &str,
client_token: &str,
) -> crate::Result<Credentials> {
if login.selected_profile.id != player_id
|| login.user_id != user_id
|| login.client_token != client_token
|| login.access_token.is_empty()
{
return Err(ErrorKind::InputError(
"皮肤站返回的玩家身份不匹配,请重试".into(),
)
.as_error());
}
let mut credential = create_credentials(
login.selected_profile,
login.access_token,
login.client_token,
YggdrasilMetadata {
api_root: "https://skin.starlight.cool/yggdrasil".into(),
server_name: "StarLight".into(),
raw: String::new(),
},
user_id,
);
credential.active = false;
Ok(credential)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skin_site_login_validates_identity_and_keeps_global_selection() {
let id = Uuid::new_v4();
let response = || SkinSiteLogin {
access_token: "game-token".into(),
client_token: "client".into(),
selected_profile: YggdrasilProfile {
id,
name: "Player".into(),
},
user_id: "owner".into(),
};
let credentials =
skin_site_credentials(response(), id, "owner", "client").unwrap();
assert!(!credentials.active);
assert_eq!(credentials.account_type, MinecraftAccountType::Yggdrasil);
assert_eq!(
credentials.yggdrasil.unwrap().api_root,
"https://skin.starlight.cool/yggdrasil"
);
assert!(
skin_site_credentials(
response(),
Uuid::new_v4(),
"owner",
"client"
)
.is_err()
);
assert!(
skin_site_credentials(response(), id, "other", "client").is_err()
);
assert!(
skin_site_credentials(response(), id, "owner", "other-client")
.is_err()
);
}
#[test]
fn normalizes_api_roots() {
assert_eq!(

View File

@ -105,7 +105,11 @@ const REASSIGNABLE_FIRST_BYTE_TIMEOUT: time::Duration =
const MAX_DOWNLOAD_ATTEMPT_HISTORY: usize = 12;
const MAX_DOWNLOAD_DIAGNOSTIC_BYTES: usize = 8 * 1024;
const MAX_FAILURE_COOLDOWN: time::Duration = time::Duration::from_secs(1);
const H2_FALLBACK_TTL: time::Duration = MAX_FAILURE_COOLDOWN;
// Once an authority has returned a broken HTTP/2 body, keep subsequent file
// retries on HTTP/1.1 for the rest of a typical install. A one-second memory
// allowed longer batch downloads to fall straight back onto the same bad H2
// connection between retry rounds.
const H2_FALLBACK_TTL: time::Duration = time::Duration::from_secs(10 * 60);
const TASK_PROBE_MAX_ROUTES: usize = 3;
const MAX_TASK_PROBE_STATES: usize = 64;
#[cfg(not(test))]
@ -6495,7 +6499,10 @@ async fn download_to_path_inner(
Ok(chunk) => chunk,
Err(error) => {
let decode_failure = error.is_decode();
if is_h2_protocol_failure(&error)
if (is_h2_protocol_failure(&error)
|| (decode_failure
&& http_version
== reqwest::Version::HTTP_2))
&& let Some(authority) =
url_authority(&final_url)
{

View File

@ -56,7 +56,7 @@
管理员在「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 身份,清理缓存后仍能使用原下载源恢复。
整合包自带 Mod 是安装基线,之后再校验标签 Mod。标签快照与整合包 JAR 的 SHA-256 相同时保留整合包原文件和原文件名,不重复下载或改名;只有内容确实不同时,标签 Mod 才优先替换同标识的旧 Mod安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识且内容不同的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
标签下载使用启动器有效并发设置,上限 16 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。