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

@ -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' },
})