1134 lines
35 KiB
Vue
1134 lines
35 KiB
Vue
<template>
|
||
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
|
||
<Avatar :src="axolotlLogo" size="36px" />
|
||
<div class="flex min-w-0 flex-col">
|
||
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
|
||
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
|
||
</div>
|
||
</div>
|
||
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
|
||
{{ formatMessage(messages.skinSiteChecking) }}
|
||
</p>
|
||
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||
{{ formatMessage(messages.skinSiteSyncError) }}
|
||
</p>
|
||
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||
<LogInIcon />
|
||
{{ formatMessage(messages.signInToStarlight) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
<div
|
||
v-if="offline"
|
||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
||
>
|
||
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
|
||
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
|
||
<ButtonStyled>
|
||
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
||
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
||
<RefreshCwIcon v-else />
|
||
{{ formatMessage(messages.refreshNetworkStatus) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
<div
|
||
v-if="accounts.length === 0"
|
||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||
>
|
||
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||
<LogInIcon v-if="!loginDisabled" />
|
||
<SpinnerIcon v-else class="animate-spin" />
|
||
{{ formatMessage(messages.signInToStarlight) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
<ButtonStyled v-if="!offline && skinSiteUser">
|
||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||
<PlusIcon />
|
||
{{ formatMessage(messages.addSkinGameAccount) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
<ButtonStyled v-if="!offline">
|
||
<button :disabled="loginDisabled" @click="login()">
|
||
<PlusIcon />
|
||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
<Accordion
|
||
v-else
|
||
class="w-full mt-2 bg-button-bg border border-solid border-surface-5 rounded-xl overflow-clip"
|
||
button-class="button-base w-full bg-transparent px-3 py-2 border-0 cursor-pointer"
|
||
:open-by-default="false"
|
||
>
|
||
<template #title>
|
||
<div class="flex gap-2 w-full min-w-0">
|
||
<Avatar
|
||
size="36px"
|
||
:src="selectedAccount ? avatarUrl : axolotlLogo"
|
||
:pixelated="Boolean(selectedAccount)"
|
||
:unframed-natural-width="72"
|
||
/>
|
||
<div class="flex flex-col items-start w-full min-w-0">
|
||
<span class="truncate w-full text-left">{{
|
||
selectedAccount ? selectedAccount.profile.name : formatMessage(messages.selectAccount)
|
||
}}</span>
|
||
<span class="text-secondary text-xs">
|
||
{{
|
||
selectedAccount?.account_type === 'offline'
|
||
? formatMessage(messages.offlineAccount)
|
||
: selectedAccount?.account_type === 'yggdrasil'
|
||
? selectedAccount.yggdrasil?.server_name ||
|
||
formatMessage(messages.thirdPartyAccount)
|
||
: formatMessage(messages.minecraftAccount)
|
||
}}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
|
||
<template v-if="accounts.length > 0">
|
||
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
||
<button
|
||
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
|
||
@click="setAccount(account)"
|
||
>
|
||
<RadioButtonCheckedIcon
|
||
v-if="selectedAccount && selectedAccount.profile.id === account.profile.id"
|
||
class="w-5 h-5 text-brand shrink-0"
|
||
/>
|
||
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
|
||
<Avatar
|
||
:src="getAccountAvatarUrl(account)"
|
||
size="24px"
|
||
pixelated
|
||
:unframed-natural-width="72"
|
||
/>
|
||
<div class="flex flex-1 min-w-0 flex-col text-left">
|
||
<p
|
||
class="m-0 truncate text-left"
|
||
:class="
|
||
selectedAccount && selectedAccount.profile.id === account.profile.id
|
||
? 'text-contrast font-semibold'
|
||
: 'text-primary'
|
||
"
|
||
>
|
||
{{ account.profile.name }}
|
||
</p>
|
||
<p
|
||
v-if="duplicateAccountNames.has(account.profile.name)"
|
||
class="m-0 truncate text-left text-xs text-secondary"
|
||
>
|
||
{{ account.profile.id }}
|
||
</p>
|
||
</div>
|
||
<span v-if="account.account_type === 'offline'" class="text-secondary text-xs shrink-0">
|
||
{{ formatMessage(messages.offlineBadge) }}
|
||
</span>
|
||
<span
|
||
v-else-if="account.account_type === 'microsoft'"
|
||
class="text-secondary text-xs shrink-0"
|
||
>
|
||
{{ formatMessage(messages.officialBadge) }}
|
||
</span>
|
||
<span
|
||
v-else-if="account.account_type === 'yggdrasil'"
|
||
class="text-secondary text-xs shrink-0"
|
||
>
|
||
{{ account.yggdrasil?.server_name || formatMessage(messages.thirdPartyBadge) }}
|
||
</span>
|
||
</button>
|
||
<div class="flex shrink-0 items-center">
|
||
<button
|
||
v-tooltip="formatMessage(messages.copyUuid)"
|
||
type="button"
|
||
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-brand"
|
||
@click="copyAccountUuid(account)"
|
||
>
|
||
<CopyIcon />
|
||
</button>
|
||
<button
|
||
v-tooltip="formatMessage(messages.removeAccount)"
|
||
type="button"
|
||
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
|
||
@click="logout(account)"
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="flex flex-col gap-2 px-2 pt-2">
|
||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||
<PlusIcon />
|
||
{{ formatMessage(messages.addSkinGameAccount) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||
<button :disabled="loginDisabled" @click="login()">
|
||
<PlusIcon />
|
||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
</div>
|
||
</Accordion>
|
||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
||
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
|
||
<div class="flex min-w-[24rem] flex-col gap-4">
|
||
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
|
||
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
|
||
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
|
||
<div
|
||
v-for="savedLogin in savedYggdrasilLogins"
|
||
:key="`${savedLogin.api_root}:${savedLogin.login}`"
|
||
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
|
||
>
|
||
<button
|
||
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
|
||
:disabled="loginDisabled"
|
||
@click="selectSavedYggdrasilLogin(savedLogin)"
|
||
>
|
||
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
|
||
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
|
||
</button>
|
||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||
<button
|
||
v-tooltip="formatMessage(messages.removeSavedLogin)"
|
||
:disabled="loginDisabled"
|
||
@click="removeSavedYggdrasilLogin(savedLogin)"
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
</div>
|
||
<label class="flex flex-col gap-2 font-semibold">
|
||
{{ formatMessage(messages.apiRootLabel) }}
|
||
<StyledInput
|
||
v-model="yggdrasilApiRoot"
|
||
:disabled="true"
|
||
readonly
|
||
:placeholder="formatMessage(messages.apiRootPlaceholder)"
|
||
inputmode="url"
|
||
@blur="loadRememberedYggdrasilPassword()"
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-2 font-semibold">
|
||
{{ formatMessage(messages.accountLabel) }}
|
||
<StyledInput
|
||
v-model="yggdrasilLogin"
|
||
:disabled="loginDisabled"
|
||
:placeholder="formatMessage(messages.accountPlaceholder)"
|
||
autocomplete="username"
|
||
@blur="loadRememberedYggdrasilPassword()"
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-2 font-semibold">
|
||
{{ formatMessage(messages.passwordLabel) }}
|
||
<StyledInput
|
||
v-model="yggdrasilPassword"
|
||
type="password"
|
||
:disabled="loginDisabled"
|
||
autocomplete="current-password"
|
||
@keyup.enter="addYggdrasilAccount()"
|
||
/>
|
||
</label>
|
||
<Checkbox
|
||
v-model="rememberYggdrasilPassword"
|
||
:disabled="loginDisabled"
|
||
:label="formatMessage(messages.rememberPassword)"
|
||
/>
|
||
<div class="input-group push-right">
|
||
<ButtonStyled>
|
||
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
|
||
{{ formatMessage(commonMessages.cancelButton) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
<ButtonStyled color="brand">
|
||
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
|
||
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
|
||
<LogInIcon v-else />
|
||
{{ formatMessage(messages.signInButton) }}
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
</div>
|
||
</ModalWrapper>
|
||
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
|
||
<div class="flex min-w-[22rem] flex-col gap-2">
|
||
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
|
||
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
|
||
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
|
||
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
|
||
<RadioButtonIcon v-else />
|
||
{{ profile.name }}
|
||
</button>
|
||
</ButtonStyled>
|
||
</div>
|
||
</ModalWrapper>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
CopyIcon,
|
||
LogInIcon,
|
||
PlusIcon,
|
||
RadioButtonCheckedIcon,
|
||
RadioButtonIcon,
|
||
RefreshCwIcon,
|
||
SpinnerIcon,
|
||
TrashIcon,
|
||
} from '@modrinth/assets'
|
||
import {
|
||
Accordion,
|
||
Avatar,
|
||
ButtonStyled,
|
||
Checkbox,
|
||
commonMessages,
|
||
defineMessages,
|
||
injectNotificationManager,
|
||
StyledInput,
|
||
useVIntl,
|
||
} from '@modrinth/ui'
|
||
import { useQueryClient } from '@tanstack/vue-query'
|
||
import { listen } from '@tauri-apps/api/event'
|
||
import type { Ref } from 'vue'
|
||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import axolotlLogo from '@/assets/netherstar.png'
|
||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||
import {
|
||
begin_yggdrasil_login,
|
||
delete_yggdrasil_password,
|
||
finish_yggdrasil_login,
|
||
get_default_user,
|
||
get_yggdrasil_password,
|
||
list_yggdrasil_saved_logins,
|
||
login as loginToMinecraft,
|
||
remove_user,
|
||
set_default_user,
|
||
set_yggdrasil_password,
|
||
users,
|
||
} from '@/helpers/auth'
|
||
import { process_listener } from '@/helpers/events'
|
||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||
import type { Skin } from '@/helpers/skins'
|
||
import { get_available_skins } from '@/helpers/skins'
|
||
import { handleSevereError } from '@/store/error.js'
|
||
import { useTheming } from '@/store/state'
|
||
|
||
const { formatMessage } = useVIntl()
|
||
const { handleError } = injectNotificationManager()
|
||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
||
const queryClient = useQueryClient()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const themeStore = useTheming()
|
||
|
||
async function goToSkinSiteLogin() {
|
||
openSkinSiteLogin()
|
||
// The login action must reveal the iframe even if Minimal Home was selected.
|
||
themeStore.homeLayout = 'standard'
|
||
await router.push('/').catch(handleError)
|
||
}
|
||
const refreshingNetwork = ref(false)
|
||
|
||
/**
|
||
* Re-checks session server reachability on demand so users can leave offline
|
||
* mode without restarting the launcher. Goes through the shared
|
||
* `authServerReachability` query so the reachability state and the auth
|
||
* warning banner stay consistent.
|
||
*/
|
||
async function refreshNetworkStatus() {
|
||
if (refreshingNetwork.value) return
|
||
refreshingNetwork.value = true
|
||
try {
|
||
refreshBrowserOffline()
|
||
await nextTick()
|
||
await queryClient.refetchQueries({ queryKey: ['authServerReachability'] })
|
||
} finally {
|
||
refreshingNetwork.value = false
|
||
}
|
||
}
|
||
|
||
const emit = defineEmits<{
|
||
change: []
|
||
}>()
|
||
|
||
type MinecraftCredential = {
|
||
account_type: 'microsoft' | 'offline' | 'yggdrasil'
|
||
profile: {
|
||
id: string
|
||
name: string
|
||
skins?: Array<{
|
||
state: string
|
||
url: string
|
||
variant: Skin['variant']
|
||
textureKey?: string
|
||
}>
|
||
}
|
||
yggdrasil?: {
|
||
api_root: string
|
||
server_name: string
|
||
login: string
|
||
}
|
||
}
|
||
|
||
type YggdrasilProfile = {
|
||
id: string
|
||
name: string
|
||
}
|
||
|
||
type SavedYggdrasilLogin = {
|
||
api_root: string
|
||
login: string
|
||
}
|
||
|
||
type YggdrasilLoginResult =
|
||
| { status: 'complete'; credentials: MinecraftCredential }
|
||
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
|
||
|
||
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
||
|
||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||
const loginDisabled = ref(false)
|
||
const defaultUser = ref<string | undefined>()
|
||
const equippedSkin = ref<Skin | null>(null)
|
||
const accountChangeRevision = ref(0)
|
||
const headUrlCache = ref(new Map<string, string>())
|
||
const accountHeadUrlCache = ref(new Map<string, string>())
|
||
const accountHeadTextureKeyCache = ref(new Map<string, string>())
|
||
let refreshGeneration = 0
|
||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||
let defaultUserUpdateQueue = Promise.resolve()
|
||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
||
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
||
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
|
||
const yggdrasilLogin = ref('')
|
||
const yggdrasilPassword = ref('')
|
||
const rememberYggdrasilPassword = ref(true)
|
||
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
|
||
const pendingYggdrasilFlowId = ref<string | undefined>()
|
||
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
|
||
const yggdrasilFormValid = computed(
|
||
() =>
|
||
yggdrasilApiRoot.value.trim().length > 0 &&
|
||
yggdrasilLogin.value.trim().length > 0 &&
|
||
yggdrasilPassword.value.length > 0,
|
||
)
|
||
|
||
function createSkinHeadDataUrl(textureUrl: string) {
|
||
const escapedTextureUrl = textureUrl
|
||
.replaceAll('&', '&')
|
||
.replaceAll('"', '"')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
|
||
|
||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||
}
|
||
|
||
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
|
||
|
||
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
||
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
||
|
||
function hasResolvedAccountHead(account: MinecraftCredential) {
|
||
const skin = getAccountSkin(account)
|
||
return Boolean(
|
||
skin &&
|
||
accountHeadUrlCache.value.has(account.profile.id) &&
|
||
accountHeadTextureKeyCache.value.get(account.profile.id) === skin.texture_key,
|
||
)
|
||
}
|
||
|
||
function hasMissingAccountHeads() {
|
||
return accounts.value.some(
|
||
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
|
||
)
|
||
}
|
||
|
||
function clearHeadRefreshRetry() {
|
||
if (headRefreshTimer !== undefined) {
|
||
clearTimeout(headRefreshTimer)
|
||
headRefreshTimer = undefined
|
||
}
|
||
}
|
||
|
||
function scheduleHeadRefreshRetry(generation: number, attempt: number) {
|
||
if (offline.value || generation !== refreshGeneration) return
|
||
const delay = HEAD_REFRESH_RETRY_DELAYS[attempt] ?? HEAD_REFRESH_CONTINUOUS_DELAY
|
||
|
||
clearHeadRefreshRetry()
|
||
headRefreshTimer = setTimeout(() => {
|
||
headRefreshTimer = undefined
|
||
if (generation !== refreshGeneration) return
|
||
void refreshValues(Math.min(attempt + 1, HEAD_REFRESH_RETRY_DELAYS.length)).catch((error) => {
|
||
console.warn('Failed to refresh account heads:', error)
|
||
})
|
||
}, delay)
|
||
}
|
||
|
||
async function refreshValues(headRefreshAttempt = 0) {
|
||
clearHeadRefreshRetry()
|
||
const generation = ++refreshGeneration
|
||
const selectedUser = await get_default_user(offline.value).catch(handleError)
|
||
if (generation !== refreshGeneration) return
|
||
|
||
defaultUser.value = selectedUser
|
||
if (offline.value && selectedUser) {
|
||
await persistDefaultUser(selectedUser)
|
||
if (generation !== refreshGeneration) return
|
||
}
|
||
const userList = await users(offline.value).catch(handleError)
|
||
if (generation !== refreshGeneration) return
|
||
// The Rust backend returns a plain array that structurally matches
|
||
// MinecraftCredential but the TS types from the Tauri IPC bridge do not
|
||
// carry this refinement. The double cast is deliberate — the shape is
|
||
// correct and verified at runtime by the backend.
|
||
accounts.value = Array.isArray(userList)
|
||
? [...(userList as unknown as MinecraftCredential[])].filter(
|
||
(account) =>
|
||
account.account_type === 'microsoft' ||
|
||
(account.account_type === 'yggdrasil' &&
|
||
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
|
||
)
|
||
: []
|
||
accounts.value.sort(compareMinecraftAccounts)
|
||
await renderAccountHeads(accounts.value, generation)
|
||
if (generation !== refreshGeneration) return
|
||
try {
|
||
const skins = await get_available_skins()
|
||
if (generation !== refreshGeneration) return
|
||
equippedSkin.value = skins.find((skin) => skin.is_equipped) ?? null
|
||
|
||
if (equippedSkin.value) {
|
||
try {
|
||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||
if (generation !== refreshGeneration) return
|
||
headUrlCache.value = new Map(headUrlCache.value).set(
|
||
equippedSkin.value.texture_key,
|
||
headUrl,
|
||
)
|
||
if (selectedUser) {
|
||
const selectedAccountSkin = getAccountSkin(
|
||
accounts.value.find((account) => account.profile.id === selectedUser),
|
||
)
|
||
cacheAccountHead(selectedUser, selectedAccountSkin ?? equippedSkin.value, headUrl)
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to get head render for equipped skin:', error)
|
||
}
|
||
}
|
||
} catch {
|
||
equippedSkin.value = null
|
||
}
|
||
|
||
if (hasMissingAccountHeads()) {
|
||
scheduleHeadRefreshRetry(generation, headRefreshAttempt)
|
||
}
|
||
}
|
||
|
||
async function setEquippedSkin(skin: Skin) {
|
||
const selectedUser = defaultUser.value
|
||
equippedSkin.value = skin
|
||
|
||
try {
|
||
const headUrl = await getPlayerHeadUrl(skin)
|
||
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
|
||
if (selectedUser) {
|
||
cacheAccountHead(selectedUser, skin, headUrl)
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to get head render for equipped skin:', error)
|
||
}
|
||
}
|
||
|
||
function setLoginDisabled(value: boolean) {
|
||
loginDisabled.value = value
|
||
}
|
||
|
||
defineExpose({
|
||
accountChangeRevision,
|
||
login,
|
||
refreshValues,
|
||
setEquippedSkin,
|
||
setLoginDisabled,
|
||
loginDisabled,
|
||
})
|
||
|
||
await refreshValues()
|
||
|
||
watch(offline, async () => {
|
||
await refreshValues()
|
||
notifyAccountChange()
|
||
})
|
||
|
||
const selectedAccount = computed(() =>
|
||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||
)
|
||
|
||
function notifyAccountChange() {
|
||
accountChangeRevision.value += 1
|
||
emit('change')
|
||
}
|
||
|
||
const duplicateAccountNames = computed(() => {
|
||
const counts = new Map<string, number>()
|
||
for (const account of accounts.value) {
|
||
counts.set(account.profile.name, (counts.get(account.profile.name) ?? 0) + 1)
|
||
}
|
||
return new Set([...counts].filter(([, count]) => count > 1).map(([name]) => name))
|
||
})
|
||
|
||
watch(
|
||
() => route.fullPath,
|
||
() => {
|
||
if (!hasMissingAccountHeads()) return
|
||
void refreshValues().catch((error) => {
|
||
console.warn('Failed to refresh account heads after navigation:', error)
|
||
})
|
||
},
|
||
)
|
||
|
||
function getAccountSkin(account: MinecraftCredential | undefined): Skin | undefined {
|
||
if (!account || account.account_type === 'offline') return undefined
|
||
const skin =
|
||
account.profile.skins?.find((skin) => skin.state === 'ACTIVE') ?? account.profile.skins?.[0]
|
||
if (!skin?.url) return undefined
|
||
|
||
return {
|
||
texture_key: skin.textureKey ?? `${account.profile.id}:${skin.url}`,
|
||
variant: skin.variant ?? 'UNKNOWN',
|
||
texture: skin.url,
|
||
source: 'custom_external',
|
||
is_equipped: true,
|
||
}
|
||
}
|
||
|
||
function cacheAccountHead(accountId: string, skin: Skin, headUrl: string) {
|
||
accountHeadUrlCache.value = new Map(accountHeadUrlCache.value).set(accountId, headUrl)
|
||
accountHeadTextureKeyCache.value = new Map(accountHeadTextureKeyCache.value).set(
|
||
accountId,
|
||
skin.texture_key,
|
||
)
|
||
}
|
||
|
||
async function renderAccountHeads(accountList: MinecraftCredential[], generation: number) {
|
||
await Promise.all(
|
||
accountList.map(async (account) => {
|
||
const skin = getAccountSkin(account)
|
||
if (!skin) return
|
||
|
||
try {
|
||
const headUrl = await getPlayerHeadUrl(skin)
|
||
if (generation !== refreshGeneration) return
|
||
cacheAccountHead(account.profile.id, skin, headUrl)
|
||
} catch (error) {
|
||
console.warn(`Failed to render head for account ${account.profile.id}:`, error)
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
const avatarUrl = computed(() => {
|
||
if (selectedAccount.value) {
|
||
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
|
||
if (cachedHeadUrl) return cachedHeadUrl
|
||
}
|
||
if (equippedSkin.value?.texture_key) {
|
||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||
if (cachedUrl) {
|
||
return cachedUrl
|
||
}
|
||
}
|
||
return selectedAccount.value ? defaultSteveHeadUrl : axolotlLogo
|
||
})
|
||
|
||
function getAccountAvatarUrl(account: MinecraftCredential) {
|
||
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
|
||
if (cachedHeadUrl) {
|
||
return cachedHeadUrl
|
||
}
|
||
if (
|
||
account.profile.id === selectedAccount.value?.profile?.id &&
|
||
equippedSkin.value?.texture_key
|
||
) {
|
||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||
if (cachedUrl) {
|
||
return cachedUrl
|
||
}
|
||
}
|
||
return defaultSteveHeadUrl
|
||
}
|
||
|
||
function persistDefaultUser(userId: string) {
|
||
const update = defaultUserUpdateQueue.then(async () => {
|
||
await set_default_user(userId).catch(handleError)
|
||
})
|
||
defaultUserUpdateQueue = update.catch(() => {})
|
||
return update
|
||
}
|
||
|
||
async function setAccount(account: MinecraftCredential) {
|
||
const userId = account.profile.id
|
||
refreshGeneration += 1
|
||
defaultUser.value = userId
|
||
equippedSkin.value = null
|
||
|
||
await persistDefaultUser(userId)
|
||
if (defaultUser.value !== userId) return
|
||
await refreshValues()
|
||
if (defaultUser.value === userId) notifyAccountChange()
|
||
}
|
||
|
||
async function login() {
|
||
if (offline.value) return
|
||
loginDisabled.value = true
|
||
try {
|
||
const account = await loginToMinecraft({
|
||
trouble: formatMessage(messages.loginTrouble),
|
||
browserLogin: formatMessage(messages.loginBrowser),
|
||
deviceCode: formatMessage(messages.loginDeviceCode),
|
||
})
|
||
if (account) await onMicrosoftLogin(account)
|
||
} catch (error) {
|
||
handleSevereError(error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function onMicrosoftLogin(account: MinecraftCredential) {
|
||
loginDisabled.value = true
|
||
try {
|
||
await setAccount(account)
|
||
} catch (error) {
|
||
handleSevereError(error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function showYggdrasilAccountModal() {
|
||
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
|
||
yggdrasilLogin.value = ''
|
||
yggdrasilPassword.value = ''
|
||
rememberYggdrasilPassword.value = true
|
||
pendingYggdrasilFlowId.value = undefined
|
||
pendingYggdrasilProfiles.value = []
|
||
await loadSavedYggdrasilLogins()
|
||
yggdrasilAccountModal.value?.show()
|
||
}
|
||
|
||
async function loadSavedYggdrasilLogins() {
|
||
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
|
||
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
|
||
? [...storedLogins].filter(
|
||
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
|
||
)
|
||
: []
|
||
const savedLoginKeys = new Set(
|
||
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
|
||
)
|
||
|
||
for (const account of accounts.value) {
|
||
if (
|
||
!account.yggdrasil ||
|
||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
|
||
)
|
||
continue
|
||
const savedLogin = {
|
||
api_root: account.yggdrasil.api_root,
|
||
login: account.yggdrasil.login,
|
||
}
|
||
const key = `${savedLogin.api_root}\n${savedLogin.login}`
|
||
if (savedLoginKeys.has(key)) continue
|
||
|
||
try {
|
||
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||
if (!password) continue
|
||
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
|
||
savedLogins.push(savedLogin)
|
||
savedLoginKeys.add(key)
|
||
} catch {
|
||
continue
|
||
}
|
||
}
|
||
|
||
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
|
||
left.login.localeCompare(right.login),
|
||
)
|
||
}
|
||
|
||
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
|
||
if (loginDisabled.value) return
|
||
|
||
loginDisabled.value = true
|
||
try {
|
||
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||
if (!password) {
|
||
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||
await loadSavedYggdrasilLogins()
|
||
return
|
||
}
|
||
yggdrasilApiRoot.value = savedLogin.api_root
|
||
yggdrasilLogin.value = savedLogin.login
|
||
yggdrasilPassword.value = password
|
||
rememberYggdrasilPassword.value = true
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
|
||
if (loginDisabled.value) return
|
||
|
||
loginDisabled.value = true
|
||
try {
|
||
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
|
||
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
|
||
)
|
||
if (
|
||
yggdrasilApiRoot.value === savedLogin.api_root &&
|
||
yggdrasilLogin.value === savedLogin.login
|
||
) {
|
||
yggdrasilPassword.value = ''
|
||
rememberYggdrasilPassword.value = false
|
||
}
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function loadRememberedYggdrasilPassword() {
|
||
if (
|
||
!rememberYggdrasilPassword.value ||
|
||
!yggdrasilApiRoot.value.trim() ||
|
||
!yggdrasilLogin.value.trim() ||
|
||
yggdrasilPassword.value
|
||
)
|
||
return
|
||
|
||
try {
|
||
const password = await get_yggdrasil_password(
|
||
yggdrasilApiRoot.value.trim(),
|
||
yggdrasilLogin.value.trim(),
|
||
)
|
||
if (password) yggdrasilPassword.value = password
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
|
||
async function persistYggdrasilPasswordPreference() {
|
||
try {
|
||
if (rememberYggdrasilPassword.value) {
|
||
await set_yggdrasil_password(
|
||
yggdrasilApiRoot.value.trim(),
|
||
yggdrasilLogin.value.trim(),
|
||
yggdrasilPassword.value,
|
||
)
|
||
} else {
|
||
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
|
||
}
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
}
|
||
}
|
||
|
||
async function addYggdrasilAccount() {
|
||
if (!yggdrasilFormValid.value || loginDisabled.value) return
|
||
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
|
||
|
||
loginDisabled.value = true
|
||
try {
|
||
const result = (await begin_yggdrasil_login(
|
||
yggdrasilApiRoot.value.trim(),
|
||
yggdrasilLogin.value.trim(),
|
||
yggdrasilPassword.value,
|
||
)) as YggdrasilLoginResult
|
||
if (result.status === 'complete') {
|
||
await persistYggdrasilPasswordPreference()
|
||
yggdrasilAccountModal.value?.hide()
|
||
await setAccount(result.credentials)
|
||
} else {
|
||
pendingYggdrasilFlowId.value = result.flow_id
|
||
pendingYggdrasilProfiles.value = result.profiles
|
||
yggdrasilAccountModal.value?.hide()
|
||
yggdrasilProfileModal.value?.show()
|
||
}
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function selectYggdrasilProfile(profileId: string) {
|
||
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
|
||
|
||
loginDisabled.value = true
|
||
try {
|
||
const account = (await finish_yggdrasil_login(
|
||
pendingYggdrasilFlowId.value,
|
||
profileId,
|
||
)) as MinecraftCredential
|
||
await persistYggdrasilPasswordPreference()
|
||
yggdrasilProfileModal.value?.hide()
|
||
await setAccount(account)
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
} finally {
|
||
loginDisabled.value = false
|
||
}
|
||
}
|
||
|
||
async function logout(account: MinecraftCredential) {
|
||
await remove_user(account.profile.id).catch(handleError)
|
||
await refreshValues()
|
||
if (!selectedAccount.value && accounts.value.length > 0) {
|
||
await setAccount(accounts.value[0])
|
||
} else {
|
||
notifyAccountChange()
|
||
}
|
||
}
|
||
|
||
async function copyAccountUuid(account: MinecraftCredential) {
|
||
try {
|
||
await navigator.clipboard.writeText(account.profile.id)
|
||
} catch (error) {
|
||
handleError(error as Error)
|
||
}
|
||
}
|
||
|
||
const unlisten = await process_listener(async (e) => {
|
||
if (e.event === 'launched') {
|
||
await refreshValues()
|
||
}
|
||
})
|
||
const unlistenDeviceLogin = await listen('minecraft-device-login-requested', () => {
|
||
minecraftLoginModal.value?.showDeviceLogin()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
clearHeadRefreshRetry()
|
||
unlisten()
|
||
unlistenDeviceLogin()
|
||
})
|
||
|
||
const messages = defineMessages({
|
||
skinSiteSignedIn: {
|
||
id: 'minecraft-account.skin-site.signed-in',
|
||
defaultMessage: 'Signed in to StarLight Skin Site',
|
||
},
|
||
skinSiteChecking: {
|
||
id: 'minecraft-account.skin-site.checking',
|
||
defaultMessage: 'Checking skin site session…',
|
||
},
|
||
skinSiteSyncError: {
|
||
id: 'minecraft-account.skin-site.sync-error',
|
||
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||
},
|
||
addSkinGameAccount: {
|
||
id: 'minecraft-account.add-skin-game-account',
|
||
defaultMessage: 'Add skin site game account',
|
||
},
|
||
offlineMode: {
|
||
id: 'minecraft-account.offline-mode',
|
||
defaultMessage: 'Offline mode',
|
||
},
|
||
offlineModeDescription: {
|
||
id: 'minecraft-account.offline-mode.description',
|
||
defaultMessage:
|
||
'Only offline accounts are available. You can launch fully downloaded instances.',
|
||
},
|
||
refreshNetworkStatus: {
|
||
id: 'minecraft-account.offline-mode.refresh',
|
||
defaultMessage: 'Refresh connection status',
|
||
},
|
||
notSignedIn: {
|
||
id: 'minecraft-account.not-signed-in',
|
||
defaultMessage: 'Not signed in',
|
||
},
|
||
addMicrosoftAccount: {
|
||
id: 'minecraft-account.add-microsoft-account',
|
||
defaultMessage: 'Add your own Minecraft account',
|
||
},
|
||
signInToStarlight: {
|
||
id: 'minecraft-account.sign-in-starlight',
|
||
defaultMessage: 'Sign in to StarLight Skin Site',
|
||
},
|
||
thirdPartyAccount: {
|
||
id: 'minecraft-account.third-party-account',
|
||
defaultMessage: 'StarLight skin account',
|
||
},
|
||
thirdPartyBadge: {
|
||
id: 'minecraft-account.third-party-badge',
|
||
defaultMessage: 'StarLight skin',
|
||
},
|
||
thirdPartyModalTitle: {
|
||
id: 'minecraft-account.third-party-modal.title',
|
||
defaultMessage: 'Sign in with StarLight skin',
|
||
},
|
||
thirdPartyModalDescription: {
|
||
id: 'minecraft-account.third-party-modal.description',
|
||
defaultMessage: 'Sign in with your StarLight skin account.',
|
||
},
|
||
apiRootLabel: {
|
||
id: 'minecraft-account.third-party-modal.api-root',
|
||
defaultMessage: 'StarLight skin API address',
|
||
},
|
||
apiRootPlaceholder: {
|
||
id: 'minecraft-account.third-party-modal.api-root-placeholder',
|
||
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
|
||
},
|
||
accountLabel: {
|
||
id: 'minecraft-account.third-party-modal.account',
|
||
defaultMessage: 'Account or email',
|
||
},
|
||
accountPlaceholder: {
|
||
id: 'minecraft-account.third-party-modal.account-placeholder',
|
||
defaultMessage: 'Enter your account or email',
|
||
},
|
||
passwordLabel: {
|
||
id: 'minecraft-account.third-party-modal.password',
|
||
defaultMessage: 'Password',
|
||
},
|
||
rememberPassword: {
|
||
id: 'minecraft-account.third-party-modal.remember-password',
|
||
defaultMessage: 'Save this login on this device',
|
||
},
|
||
savedLogins: {
|
||
id: 'minecraft-account.third-party-modal.saved-logins',
|
||
defaultMessage: 'Saved logins',
|
||
},
|
||
removeSavedLogin: {
|
||
id: 'minecraft-account.third-party-modal.remove-saved-login',
|
||
defaultMessage: 'Remove saved login',
|
||
},
|
||
signInButton: {
|
||
id: 'minecraft-account.third-party-modal.sign-in',
|
||
defaultMessage: 'Sign in',
|
||
},
|
||
selectProfileTitle: {
|
||
id: 'minecraft-account.third-party-profile.title',
|
||
defaultMessage: 'Select a profile',
|
||
},
|
||
selectProfileDescription: {
|
||
id: 'minecraft-account.third-party-profile.description',
|
||
defaultMessage: 'Choose the Minecraft profile to use with this account.',
|
||
},
|
||
offlineAccount: {
|
||
id: 'minecraft-account.offline-account',
|
||
defaultMessage: 'Offline Minecraft account',
|
||
},
|
||
offlineBadge: {
|
||
id: 'minecraft-account.offline-badge',
|
||
defaultMessage: 'Offline',
|
||
},
|
||
officialBadge: {
|
||
id: 'minecraft-account.official-badge',
|
||
defaultMessage: 'Official',
|
||
},
|
||
offlineModalTitle: {
|
||
id: 'minecraft-account.offline-modal.title',
|
||
defaultMessage: 'Add offline account',
|
||
},
|
||
offlineModalDescription: {
|
||
id: 'minecraft-account.offline-modal.description',
|
||
defaultMessage:
|
||
'Choose the username used in offline games. This account can only join servers that allow offline players.',
|
||
},
|
||
usernameLabel: {
|
||
id: 'minecraft-account.offline-modal.username-label',
|
||
defaultMessage: 'Minecraft username',
|
||
},
|
||
usernamePlaceholder: {
|
||
id: 'minecraft-account.offline-modal.username-placeholder',
|
||
defaultMessage: 'Enter a username',
|
||
},
|
||
usernameValidation: {
|
||
id: 'minecraft-account.offline-modal.username-validation',
|
||
defaultMessage: 'Use 1–16 letters, numbers, or underscores, including Chinese characters.',
|
||
},
|
||
chineseUsernameWarning: {
|
||
id: 'minecraft-account.offline-modal.chinese-username-warning',
|
||
defaultMessage:
|
||
'Minecraft 1.18 and newer may reject Chinese usernames when entering singleplayer worlds or servers. Use this account with an older version, or choose an English username for newer versions.',
|
||
},
|
||
customUuidLabel: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid',
|
||
defaultMessage: 'Custom UUID',
|
||
},
|
||
customUuidDuplicate: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid-duplicate',
|
||
defaultMessage: 'An account with this UUID already exists. Please use a different UUID.',
|
||
},
|
||
customUuidWarning: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid-warning',
|
||
defaultMessage: "If you don't understand what this is, do not enable this feature.",
|
||
},
|
||
customUuidInputLabel: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid-input-label',
|
||
defaultMessage: 'UUID',
|
||
},
|
||
customUuidPlaceholder: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid-placeholder',
|
||
defaultMessage: '00000000-0000-0000-0000-000000000000',
|
||
},
|
||
customUuidValidation: {
|
||
id: 'minecraft-account.offline-modal.custom-uuid-validation',
|
||
defaultMessage: 'Use 32 hexadecimal characters. Hyphens are optional.',
|
||
},
|
||
createOfflineAccount: {
|
||
id: 'minecraft-account.offline-modal.create',
|
||
defaultMessage: 'Create account',
|
||
},
|
||
copyUuid: {
|
||
id: 'minecraft-account.copy-uuid',
|
||
defaultMessage: 'Copy UUID',
|
||
},
|
||
removeAccount: {
|
||
id: 'minecraft-account.remove-account',
|
||
defaultMessage: 'Remove account',
|
||
},
|
||
selectAccount: {
|
||
id: 'minecraft-account.select-account',
|
||
defaultMessage: 'Select account',
|
||
},
|
||
minecraftAccount: {
|
||
id: 'minecraft-account.label',
|
||
defaultMessage: 'Minecraft account',
|
||
},
|
||
loginTrouble: {
|
||
id: 'minecraft-login.trouble',
|
||
defaultMessage: 'Having trouble?',
|
||
},
|
||
loginBrowser: {
|
||
id: 'minecraft-login.browser',
|
||
defaultMessage: 'Use browser login',
|
||
},
|
||
loginDeviceCode: {
|
||
id: 'minecraft-login.device-code',
|
||
defaultMessage: 'Use device code',
|
||
},
|
||
})
|
||
</script>
|