fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (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
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
Sync LobeHub models / sync (push) Has been cancelled

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>