feat: 完善 StarLight 皮肤站集成
This commit is contained in:
@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
|
|||||||
import {
|
import {
|
||||||
ChangeSkinIcon,
|
ChangeSkinIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
GlobeIcon,
|
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||||
import { hideAllPoppers } from 'floating-vue'
|
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
|
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { type as getOsType } from '@tauri-apps/plugin-os'
|
import { type as getOsType } from '@tauri-apps/plugin-os'
|
||||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||||
|
import { hideAllPoppers } from 'floating-vue'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||||
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@ -730,10 +729,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.navigation.skin-selector',
|
id: 'app.navigation.skin-selector',
|
||||||
defaultMessage: 'Skin selector',
|
defaultMessage: 'Skin selector',
|
||||||
},
|
},
|
||||||
starlightSkin: {
|
|
||||||
id: 'app.navigation.starlight-skin',
|
|
||||||
defaultMessage: '斯达莱特',
|
|
||||||
},
|
|
||||||
library: {
|
library: {
|
||||||
id: 'app.navigation.library',
|
id: 'app.navigation.library',
|
||||||
defaultMessage: 'Library',
|
defaultMessage: 'Library',
|
||||||
@ -2237,13 +2232,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
<ChangeSkinIcon />
|
<ChangeSkinIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<NavButton
|
|
||||||
v-tooltip.right="formatMessage(messages.starlightSkin)"
|
|
||||||
data-onboarding-id="nav-starlight-skin"
|
|
||||||
to="/starlight-skin"
|
|
||||||
>
|
|
||||||
<GlobeIcon />
|
|
||||||
</NavButton>
|
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="formatMessage(messages.library)"
|
v-tooltip.right="formatMessage(messages.library)"
|
||||||
data-onboarding-id="nav-library"
|
data-onboarding-id="nav-library"
|
||||||
|
|||||||
41
apps/app-frontend/src/assets/icons/lucky-clover.svg
Normal file
41
apps/app-frontend/src/assets/icons/lucky-clover.svg
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<title>幸运四叶草</title>
|
||||||
|
<path
|
||||||
|
d="M33 34C39.5 38.5 44.5 46 47 56"
|
||||||
|
stroke="var(--clover-edge, #16883F)"
|
||||||
|
stroke-width="5.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||||
|
fill="var(--clover-leaf, #42C95A)"
|
||||||
|
stroke="var(--clover-edge, #16883F)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||||
|
transform="rotate(90 32 32)"
|
||||||
|
fill="var(--clover-leaf, #42C95A)"
|
||||||
|
stroke="var(--clover-edge, #16883F)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||||
|
transform="rotate(180 32 32)"
|
||||||
|
fill="var(--clover-leaf, #42C95A)"
|
||||||
|
stroke="var(--clover-edge, #16883F)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||||
|
transform="rotate(270 32 32)"
|
||||||
|
fill="var(--clover-leaf, #42C95A)"
|
||||||
|
stroke="var(--clover-edge, #16883F)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@ -1,35 +1,70 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ExternalIcon } from '@modrinth/assets'
|
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
||||||
|
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
requestSkinSiteLuck,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
|
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
|
||||||
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
|
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
|
||||||
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
|
loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
|
||||||
|
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
|
||||||
|
error: {
|
||||||
|
id: 'app.home.luck.error',
|
||||||
|
defaultMessage: 'The little sprite did not answer. Please try again later.',
|
||||||
|
},
|
||||||
|
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
|
const luck = ref<number | null>(null)
|
||||||
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
|
const loading = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
|
const waitingForLogin = ref(false)
|
||||||
|
const signedIn = computed(() => Boolean(skinSiteUser.value))
|
||||||
|
|
||||||
async function openProfile() {
|
async function fetchLuck() {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
try {
|
try {
|
||||||
await openUrl(SKIN_PROFILE_URL)
|
luck.value = await requestSkinSiteLuck()
|
||||||
} catch (error) {
|
} catch {
|
||||||
handleError(error)
|
failed.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSkinSite() {
|
function handlePrimaryAction() {
|
||||||
try {
|
if (signedIn.value) {
|
||||||
await openUrl(SKIN_HOME_URL)
|
void fetchLuck()
|
||||||
} catch (error) {
|
return
|
||||||
handleError(error)
|
|
||||||
}
|
}
|
||||||
|
waitingForLogin.value = true
|
||||||
|
openSkinSiteLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => skinSiteUser.value?.uuid,
|
||||||
|
(uuid, previousUuid) => {
|
||||||
|
if (uuid === previousUuid) return
|
||||||
|
luck.value = null
|
||||||
|
failed.value = false
|
||||||
|
if (uuid && waitingForLogin.value) {
|
||||||
|
waitingForLogin.value = false
|
||||||
|
void fetchLuck()
|
||||||
|
} else if (!uuid) {
|
||||||
|
waitingForLogin.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -37,32 +72,72 @@ async function openSkinSite() {
|
|||||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
|
||||||
<h2 class="m-0 truncate text-lg">
|
<h2 class="m-0 truncate text-lg">
|
||||||
{{ formatMessage(messages.widgetTitle) }}
|
{{ formatMessage(messages.widgetTitle) }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-3">
|
||||||
<button
|
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
|
||||||
type="button"
|
<LuckyCloverIcon
|
||||||
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg"
|
class="lucky-clover lucky-clover-loading size-7 shrink-0"
|
||||||
@click="openProfile"
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<span class="text-sm font-semibold text-contrast">
|
<span>{{ formatMessage(messages.loading) }}</span>
|
||||||
{{ formatMessage(messages.luckyIndex) }}
|
</div>
|
||||||
</span>
|
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
|
||||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
|
||||||
</button>
|
<span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
|
||||||
|
{{ formatMessage(messages.error) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<button
|
<ButtonStyled color="brand" class="w-full">
|
||||||
type="button"
|
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
|
||||||
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90"
|
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
|
||||||
@click="openSkinSite"
|
</button>
|
||||||
>
|
</ButtonStyled>
|
||||||
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
|
|
||||||
<span>{{ formatMessage(messages.jump) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lucky-clover {
|
||||||
|
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
|
||||||
|
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
.luck-feedback {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lucky-clover-loading {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: clover-wait 850ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes clover-wait {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(1px) rotate(-5deg) scale(0.96);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-2px) rotate(6deg) scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lucky-clover-loading {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -12,6 +12,55 @@
|
|||||||
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||||||
{{ formatMessage(messages.skinSiteSyncError) }}
|
{{ formatMessage(messages.skinSiteSyncError) }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="skinSiteUser"
|
||||||
|
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<span class="font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="skinSitePlayersStatus === 'error'"
|
||||||
|
type="button"
|
||||||
|
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
|
||||||
|
@click="refreshSkinSitePlayers()"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon class="h-4 w-4" />
|
||||||
|
{{ formatMessage(messages.retrySkinSitePlayers) }}
|
||||||
|
</button>
|
||||||
|
<SpinnerIcon
|
||||||
|
v-else-if="skinSitePlayersStatus === 'checking'"
|
||||||
|
class="h-4 w-4 animate-spin text-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
|
||||||
|
<div
|
||||||
|
v-for="player in skinSitePlayers"
|
||||||
|
:key="player.uuid"
|
||||||
|
class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<Avatar :src="defaultSteveHeadUrl" size="24px" pixelated :unframed-natural-width="72" />
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm text-primary">{{ player.name }}</span>
|
||||||
|
<span class="truncate text-xs text-secondary">{{ player.uuid }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-xs text-secondary">
|
||||||
|
{{
|
||||||
|
launcherAccountIds.has(player.uuid)
|
||||||
|
? formatMessage(messages.skinSitePlayerReady)
|
||||||
|
: formatMessage(messages.skinSitePlayerSynced)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.noSkinSitePlayers) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.skinSitePlayersError) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||||||
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
<LogInIcon />
|
<LogInIcon />
|
||||||
@ -23,7 +72,15 @@
|
|||||||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
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="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
|
||||||
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
|
<span class="text-sm text-secondary">
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
browserOffline
|
||||||
|
? messages.offlineModeNoInternetDescription
|
||||||
|
: messages.offlineModeServerUnavailableDescription,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
||||||
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
||||||
@ -44,12 +101,6 @@
|
|||||||
{{ formatMessage(messages.signInToStarlight) }}
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="!offline && skinSiteUser">
|
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
|
||||||
<PlusIcon />
|
|
||||||
{{ formatMessage(messages.addSkinGameAccount) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="!offline">
|
<ButtonStyled v-if="!offline">
|
||||||
<button :disabled="loginDisabled" @click="login()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@ -161,12 +212,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col gap-2 px-2 pt-2">
|
<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">
|
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||||
<button :disabled="loginDisabled" @click="login()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@ -177,99 +222,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -287,11 +239,8 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Checkbox,
|
|
||||||
commonMessages,
|
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
StyledInput,
|
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
@ -303,21 +252,21 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import axolotlLogo from '@/assets/netherstar.png'
|
import axolotlLogo from '@/assets/netherstar.png'
|
||||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import {
|
||||||
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
openSkinSiteLogin,
|
||||||
|
requestSkinSitePlayers,
|
||||||
|
skinSitePlayers,
|
||||||
|
skinSitePlayersStatus,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||||
import {
|
import {
|
||||||
begin_yggdrasil_login,
|
|
||||||
delete_yggdrasil_password,
|
|
||||||
finish_yggdrasil_login,
|
|
||||||
get_default_user,
|
get_default_user,
|
||||||
get_yggdrasil_password,
|
|
||||||
list_yggdrasil_saved_logins,
|
|
||||||
login as loginToMinecraft,
|
login as loginToMinecraft,
|
||||||
remove_user,
|
remove_user,
|
||||||
set_default_user,
|
set_default_user,
|
||||||
set_yggdrasil_password,
|
|
||||||
users,
|
users,
|
||||||
} from '@/helpers/auth'
|
} from '@/helpers/auth'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
@ -329,7 +278,7 @@ import { useTheming } from '@/store/state'
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -384,20 +333,6 @@ type MinecraftCredential = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
||||||
|
|
||||||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||||
@ -412,21 +347,11 @@ let refreshGeneration = 0
|
|||||||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let defaultUserUpdateQueue = Promise.resolve()
|
let defaultUserUpdateQueue = Promise.resolve()
|
||||||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||||||
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
const launcherAccountIds = computed(() => new Set(accounts.value.map((account) => account.profile.id)))
|
||||||
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
|
||||||
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
|
async function refreshSkinSitePlayers() {
|
||||||
const yggdrasilLogin = ref('')
|
await requestSkinSitePlayers().catch(() => {})
|
||||||
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) {
|
function createSkinHeadDataUrl(textureUrl: string) {
|
||||||
const escapedTextureUrl = textureUrl
|
const escapedTextureUrl = textureUrl
|
||||||
@ -721,185 +646,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function logout(account: MinecraftCredential) {
|
||||||
await remove_user(account.profile.id).catch(handleError)
|
await remove_user(account.profile.id).catch(handleError)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
@ -946,18 +692,43 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.skin-site.sync-error',
|
id: 'minecraft-account.skin-site.sync-error',
|
||||||
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||||||
},
|
},
|
||||||
addSkinGameAccount: {
|
skinSitePlayers: {
|
||||||
id: 'minecraft-account.add-skin-game-account',
|
id: 'minecraft-account.skin-site.players',
|
||||||
defaultMessage: 'Add skin site game account',
|
defaultMessage: 'Skin site players ({count})',
|
||||||
|
},
|
||||||
|
retrySkinSitePlayers: {
|
||||||
|
id: 'minecraft-account.skin-site.players.retry',
|
||||||
|
defaultMessage: 'Retry',
|
||||||
|
},
|
||||||
|
noSkinSitePlayers: {
|
||||||
|
id: 'minecraft-account.skin-site.players.empty',
|
||||||
|
defaultMessage: 'No player profiles are attached to this skin site account.',
|
||||||
|
},
|
||||||
|
skinSitePlayersError: {
|
||||||
|
id: 'minecraft-account.skin-site.players.error',
|
||||||
|
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
|
||||||
|
},
|
||||||
|
skinSitePlayerReady: {
|
||||||
|
id: 'minecraft-account.skin-site.player.ready',
|
||||||
|
defaultMessage: 'Ready to launch',
|
||||||
|
},
|
||||||
|
skinSitePlayerSynced: {
|
||||||
|
id: 'minecraft-account.skin-site.player.synced',
|
||||||
|
defaultMessage: 'Synced from skin site',
|
||||||
},
|
},
|
||||||
offlineMode: {
|
offlineMode: {
|
||||||
id: 'minecraft-account.offline-mode',
|
id: 'minecraft-account.offline-mode',
|
||||||
defaultMessage: 'Offline mode',
|
defaultMessage: 'Offline mode',
|
||||||
},
|
},
|
||||||
offlineModeDescription: {
|
offlineModeNoInternetDescription: {
|
||||||
id: 'minecraft-account.offline-mode.description',
|
id: 'minecraft-account.offline-mode.description.no-internet',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Only offline accounts are available. You can launch fully downloaded instances.',
|
'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
|
||||||
|
},
|
||||||
|
offlineModeServerUnavailableDescription: {
|
||||||
|
id: 'minecraft-account.offline-mode.description.server-unavailable',
|
||||||
|
defaultMessage:
|
||||||
|
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
|
||||||
},
|
},
|
||||||
refreshNetworkStatus: {
|
refreshNetworkStatus: {
|
||||||
id: 'minecraft-account.offline-mode.refresh',
|
id: 'minecraft-account.offline-mode.refresh',
|
||||||
@ -983,58 +754,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.third-party-badge',
|
id: 'minecraft-account.third-party-badge',
|
||||||
defaultMessage: 'StarLight skin',
|
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: {
|
offlineAccount: {
|
||||||
id: 'minecraft-account.offline-account',
|
id: 'minecraft-account.offline-account',
|
||||||
defaultMessage: 'Offline Minecraft account',
|
defaultMessage: 'Offline Minecraft account',
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { defineMessages, useVIntl } from '@modrinth/ui'
|
|||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
receiveSkinSiteSession,
|
receiveSkinSiteMessage,
|
||||||
resetSkinSiteSession,
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
SKIN_SITE_ORIGIN,
|
SKIN_SITE_ORIGIN,
|
||||||
skinSiteFrameUrl,
|
skinSiteFrameUrl,
|
||||||
} from '@/composables/skin-site-session'
|
} from '@/composables/skin-site-session'
|
||||||
@ -22,14 +23,13 @@ let lastMessage = 0
|
|||||||
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
frame.value?.contentWindow?.postMessage(
|
const contentWindow = frame.value?.contentWindow ?? null
|
||||||
{ type: 'starlight-skin-session-connect' },
|
setSkinSiteFrame(contentWindow)
|
||||||
SKIN_SITE_ORIGIN,
|
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function receive(event: MessageEvent) {
|
function receive(event: MessageEvent) {
|
||||||
if (receiveSkinSiteSession(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -43,6 +43,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('message', receive)
|
window.removeEventListener('message', receive)
|
||||||
clearInterval(expiryTimer)
|
clearInterval(expiryTimer)
|
||||||
|
setSkinSiteFrame(null)
|
||||||
resetSkinSiteSession()
|
resetSkinSiteSession()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,10 +3,15 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
openSkinSiteLogin,
|
||||||
receiveSkinSiteSession,
|
receiveSkinSiteMessage,
|
||||||
|
requestSkinSiteLuck,
|
||||||
|
requestSkinSitePlayers,
|
||||||
resetSkinSiteSession,
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
SKIN_SITE_ORIGIN,
|
SKIN_SITE_ORIGIN,
|
||||||
skinSiteFrameUrl,
|
skinSiteFrameUrl,
|
||||||
|
skinSitePlayers,
|
||||||
|
skinSitePlayersStatus,
|
||||||
skinSiteStatus,
|
skinSiteStatus,
|
||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
} from './skin-site-session.ts'
|
} from './skin-site-session.ts'
|
||||||
@ -28,24 +33,154 @@ test('skin site session accepts only the embedded origin and window, and redirec
|
|||||||
jwt: 'must-not-copy',
|
jwt: 'must-not-copy',
|
||||||
})
|
})
|
||||||
assert.equal(
|
assert.equal(
|
||||||
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
|
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
|
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
|
||||||
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
|
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
|
||||||
assert.equal(skinSiteUser.value, null)
|
assert.equal(skinSiteUser.value, null)
|
||||||
assert.equal(receiveSkinSiteSession(signedIn, frame), true)
|
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
|
||||||
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
|
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
|
||||||
assert.equal(skinSiteStatus.value, 'signed-in')
|
assert.equal(skinSiteStatus.value, 'signed-in')
|
||||||
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
|
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
|
||||||
assert.equal(receiveSkinSiteSession(message('checking'), frame), true)
|
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
|
||||||
assert.equal(skinSiteUser.value, null)
|
assert.equal(skinSiteUser.value, null)
|
||||||
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
|
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
|
||||||
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
|
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
|
||||||
receiveSkinSiteSession(message('signed-out'), frame)
|
receiveSkinSiteMessage(message('signed-out'), frame)
|
||||||
assert.equal(skinSiteUser.value, null)
|
assert.equal(skinSiteUser.value, null)
|
||||||
assert.equal(skinSiteStatus.value, 'signed-out')
|
assert.equal(skinSiteStatus.value, 'signed-out')
|
||||||
receiveSkinSiteSession(message('error'), frame)
|
receiveSkinSiteMessage(message('error'), frame)
|
||||||
assert.equal(skinSiteStatus.value, 'error')
|
assert.equal(skinSiteStatus.value, 'error')
|
||||||
resetSkinSiteSession()
|
resetSkinSiteSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('luck requests use only the connected skin site frame and validate its result', async () => {
|
||||||
|
const sent: Array<{ data: unknown; targetOrigin: string }> = []
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: unknown, targetOrigin: string) {
|
||||||
|
sent.push({ data, targetOrigin })
|
||||||
|
},
|
||||||
|
} as unknown as Window
|
||||||
|
const sessionMessage = {
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'lucky-user', username: 'Lucky' },
|
||||||
|
},
|
||||||
|
} as MessageEvent
|
||||||
|
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
receiveSkinSiteMessage(sessionMessage, frame)
|
||||||
|
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
|
||||||
|
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: automaticPlayersRequest.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
const result = requestSkinSiteLuck()
|
||||||
|
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
|
||||||
|
const request = sent.at(-1)?.data as { type: string; requestId: string }
|
||||||
|
assert.equal(request.type, 'starlight-skin-luck-request')
|
||||||
|
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
|
||||||
|
assert.equal(
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-luck-result',
|
||||||
|
requestId: request.requestId,
|
||||||
|
ok: true,
|
||||||
|
luck: 88,
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(await result, 88)
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('player requests validate identities and replace the collection atomically', async () => {
|
||||||
|
const sent: Array<{ data: unknown; targetOrigin: string }> = []
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: unknown, targetOrigin: string) {
|
||||||
|
sent.push({ data, targetOrigin })
|
||||||
|
},
|
||||||
|
} as unknown as Window
|
||||||
|
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'skin-user', username: 'Skin User' },
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
const automaticRequest = sent.at(-1)?.data as { requestId: string }
|
||||||
|
assert.equal(skinSitePlayersStatus.value, 'checking')
|
||||||
|
assert.equal(
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: automaticRequest.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [
|
||||||
|
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
||||||
|
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.deepEqual(skinSitePlayers.value, [
|
||||||
|
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
||||||
|
])
|
||||||
|
assert.equal(skinSitePlayersStatus.value, 'ready')
|
||||||
|
|
||||||
|
const retry = requestSkinSitePlayers()
|
||||||
|
const retryRequest = sent.at(-1)?.data as { requestId: string }
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: retryRequest.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
assert.deepEqual(await retry, [])
|
||||||
|
assert.deepEqual(skinSitePlayers.value, [])
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
|||||||
@ -3,29 +3,195 @@ import { readonly, ref } from 'vue'
|
|||||||
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||||
export type SkinSiteUser = { uuid: string; username: string }
|
export type SkinSiteUser = { uuid: string; username: string }
|
||||||
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
||||||
|
export type SkinSitePlayer = { uuid: string; name: string; isMojang: boolean }
|
||||||
|
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||||
|
|
||||||
const user = ref<SkinSiteUser | null>(null)
|
const user = ref<SkinSiteUser | null>(null)
|
||||||
const status = ref<SkinSiteStatus>('signed-out')
|
const status = ref<SkinSiteStatus>('signed-out')
|
||||||
|
const players = ref<SkinSitePlayer[]>([])
|
||||||
|
const playersStatus = ref<SkinSitePlayersStatus>('idle')
|
||||||
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||||
|
let connectedFrame: Window | null = null
|
||||||
|
let luckRequestSequence = 0
|
||||||
|
let playersRequestSequence = 0
|
||||||
|
|
||||||
|
type PendingLuckRequest = {
|
||||||
|
resolve: (luck: number) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
|
||||||
|
type PendingPlayersRequest = {
|
||||||
|
resolve: (players: SkinSitePlayer[]) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
|
||||||
|
|
||||||
export const skinSiteUser = readonly(user)
|
export const skinSiteUser = readonly(user)
|
||||||
export const skinSiteStatus = readonly(status)
|
export const skinSiteStatus = readonly(status)
|
||||||
|
export const skinSitePlayers = readonly(players)
|
||||||
|
export const skinSitePlayersStatus = readonly(playersStatus)
|
||||||
export const skinSiteFrameUrl = readonly(frameUrl)
|
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||||
|
|
||||||
|
function rejectPendingLuckRequests(message: string) {
|
||||||
|
for (const request of pendingLuckRequests.values()) {
|
||||||
|
clearTimeout(request.timeout)
|
||||||
|
request.reject(new Error(message))
|
||||||
|
}
|
||||||
|
pendingLuckRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPendingPlayersRequests(message: string) {
|
||||||
|
for (const request of pendingPlayersRequests.values()) {
|
||||||
|
clearTimeout(request.timeout)
|
||||||
|
request.reject(new Error(message))
|
||||||
|
}
|
||||||
|
pendingPlayersRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSkinSiteFrame(frame: Window | null) {
|
||||||
|
if (connectedFrame === frame) return
|
||||||
|
rejectPendingLuckRequests('The skin site connection changed.')
|
||||||
|
rejectPendingPlayersRequests('The skin site connection changed.')
|
||||||
|
connectedFrame = frame
|
||||||
|
}
|
||||||
|
|
||||||
export function openSkinSiteLogin() {
|
export function openSkinSiteLogin() {
|
||||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSkinSiteSession() {
|
export function resetSkinSiteSession() {
|
||||||
|
rejectPendingLuckRequests('The skin site session ended.')
|
||||||
|
rejectPendingPlayersRequests('The skin site session ended.')
|
||||||
user.value = null
|
user.value = null
|
||||||
status.value = 'signed-out'
|
status.value = 'signed-out'
|
||||||
|
players.value = []
|
||||||
|
playersStatus.value = 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) {
|
export function requestSkinSitePlayers() {
|
||||||
|
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
|
||||||
|
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
playersStatus.value = 'checking'
|
||||||
|
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
|
||||||
|
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingPlayersRequests.delete(requestId)
|
||||||
|
playersStatus.value = 'error'
|
||||||
|
reject(new Error('The skin site player request timed out.'))
|
||||||
|
}, 12_000)
|
||||||
|
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
|
||||||
|
try {
|
||||||
|
connectedFrame?.postMessage(
|
||||||
|
{ type: 'starlight-skin-players-request', requestId },
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingPlayersRequests.delete(requestId)
|
||||||
|
playersStatus.value = 'error'
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestSkinSiteLuck() {
|
||||||
|
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
|
||||||
|
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingLuckRequests.delete(requestId)
|
||||||
|
reject(new Error('The skin site luck request timed out.'))
|
||||||
|
}, 12_000)
|
||||||
|
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
|
||||||
|
try {
|
||||||
|
connectedFrame?.postMessage(
|
||||||
|
{ type: 'starlight-skin-luck-request', requestId },
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingLuckRequests.delete(requestId)
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
|
||||||
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
|
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
|
||||||
const data = event.data
|
const data = event.data
|
||||||
if (!data || data.type !== 'starlight-skin-session') return false
|
if (!data || typeof data !== 'object') return false
|
||||||
|
|
||||||
|
if (data.type === 'starlight-skin-players-result') {
|
||||||
|
if (typeof data.requestId !== 'string') return false
|
||||||
|
const pending = pendingPlayersRequests.get(data.requestId)
|
||||||
|
if (!pending) return false
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingPlayersRequests.delete(data.requestId)
|
||||||
|
if (data.ok === true && Array.isArray(data.players)) {
|
||||||
|
const nextPlayers = data.players.filter(
|
||||||
|
(player: unknown): player is SkinSitePlayer =>
|
||||||
|
typeof player === 'object' &&
|
||||||
|
player !== null &&
|
||||||
|
typeof (player as SkinSitePlayer).uuid === 'string' &&
|
||||||
|
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
|
||||||
|
typeof (player as SkinSitePlayer).name === 'string' &&
|
||||||
|
(player as SkinSitePlayer).name.length > 0 &&
|
||||||
|
(player as SkinSitePlayer).name.length <= 64 &&
|
||||||
|
typeof (player as SkinSitePlayer).isMojang === 'boolean',
|
||||||
|
)
|
||||||
|
players.value = nextPlayers
|
||||||
|
playersStatus.value = 'ready'
|
||||||
|
pending.resolve(nextPlayers)
|
||||||
|
} else {
|
||||||
|
playersStatus.value = 'error'
|
||||||
|
pending.reject(
|
||||||
|
new Error(
|
||||||
|
typeof data.error === 'string' && data.error
|
||||||
|
? data.error.slice(0, 300)
|
||||||
|
: 'The skin site returned an invalid player list.',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'starlight-skin-luck-result') {
|
||||||
|
if (typeof data.requestId !== 'string') return false
|
||||||
|
const pending = pendingLuckRequests.get(data.requestId)
|
||||||
|
if (!pending) return false
|
||||||
|
clearTimeout(pending.timeout)
|
||||||
|
pendingLuckRequests.delete(data.requestId)
|
||||||
|
if (
|
||||||
|
data.ok === true &&
|
||||||
|
typeof data.luck === 'number' &&
|
||||||
|
Number.isFinite(data.luck) &&
|
||||||
|
data.luck >= 0 &&
|
||||||
|
data.luck <= 100
|
||||||
|
) {
|
||||||
|
pending.resolve(data.luck)
|
||||||
|
} else {
|
||||||
|
pending.reject(
|
||||||
|
new Error(
|
||||||
|
typeof data.error === 'string' && data.error
|
||||||
|
? data.error.slice(0, 300)
|
||||||
|
: 'The skin site returned an invalid luck result.',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== 'starlight-skin-session') return false
|
||||||
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||||
|
let refreshPlayers = false
|
||||||
if (data.status === 'signed-in') {
|
if (data.status === 'signed-in') {
|
||||||
if (
|
if (
|
||||||
!data.user ||
|
!data.user ||
|
||||||
@ -36,13 +202,24 @@ export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null
|
|||||||
data.user.username.length > 256
|
data.user.username.length > 256
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
|
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
|
||||||
|
rejectPendingPlayersRequests('The skin site account changed.')
|
||||||
|
players.value = []
|
||||||
|
playersStatus.value = 'idle'
|
||||||
|
}
|
||||||
user.value = { uuid: data.user.uuid, username: data.user.username }
|
user.value = { uuid: data.user.uuid, username: data.user.username }
|
||||||
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
||||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
|
||||||
}
|
}
|
||||||
|
refreshPlayers = true
|
||||||
} else {
|
} else {
|
||||||
|
rejectPendingLuckRequests('The skin site session is unavailable.')
|
||||||
|
rejectPendingPlayersRequests('The skin site session is unavailable.')
|
||||||
user.value = null
|
user.value = null
|
||||||
|
players.value = []
|
||||||
|
playersStatus.value = 'idle'
|
||||||
}
|
}
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
|
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the authentication servers are reachable, throwing an exception if
|
* Check if the StarLight authentication server is reachable, throwing an
|
||||||
* not reachable.
|
* exception if it is not reachable.
|
||||||
*/
|
*/
|
||||||
export async function check_reachable() {
|
export async function check_reachable() {
|
||||||
await invoke('plugin:auth|check_reachable')
|
await invoke('plugin:auth|check_reachable')
|
||||||
|
|||||||
@ -1,8 +1,27 @@
|
|||||||
{
|
{
|
||||||
|
"app.home.luck.title": { "message": "Daily luck index" },
|
||||||
|
"app.home.luck.get": { "message": "Get with one click" },
|
||||||
|
"app.home.luck.login-to-get": { "message": "Sign in to get it" },
|
||||||
|
"app.home.luck.loading": { "message": "Asking the little sprite..." },
|
||||||
|
"app.home.luck.error": {
|
||||||
|
"message": "The little sprite did not answer. Please try again later."
|
||||||
|
},
|
||||||
|
"app.home.luck.out-of": { "message": "out of 100" },
|
||||||
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
|
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
|
||||||
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
|
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
|
||||||
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
|
"minecraft-account.skin-site.sync-error": {
|
||||||
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
|
"message": "Could not verify the skin site session. Retrying automatically."
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.players": { "message": "Skin site players ({count})" },
|
||||||
|
"minecraft-account.skin-site.players.retry": { "message": "Retry" },
|
||||||
|
"minecraft-account.skin-site.players.empty": {
|
||||||
|
"message": "No player profiles are attached to this skin site account."
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.players.error": {
|
||||||
|
"message": "Could not refresh the player list. Previously loaded players are kept."
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.player.ready": { "message": "Ready to launch" },
|
||||||
|
"minecraft-account.skin-site.player.synced": { "message": "From skin site" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "Starlight Mine: Chromatic Realms"
|
"message": "Starlight Mine: Chromatic Realms"
|
||||||
},
|
},
|
||||||
@ -10146,8 +10165,11 @@
|
|||||||
"minecraft-account.offline-mode": {
|
"minecraft-account.offline-mode": {
|
||||||
"message": "Offline mode"
|
"message": "Offline mode"
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.description": {
|
"minecraft-account.offline-mode.description.no-internet": {
|
||||||
"message": "Only offline accounts are available. You can launch fully downloaded instances."
|
"message": "It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below."
|
||||||
|
},
|
||||||
|
"minecraft-account.offline-mode.description.server-unavailable": {
|
||||||
|
"message": "Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status."
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.refresh": {
|
"minecraft-account.offline-mode.refresh": {
|
||||||
"message": "Refresh connection status"
|
"message": "Refresh connection status"
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
{
|
{
|
||||||
|
"app.home.luck.title": { "message": "每日幸运指数" },
|
||||||
|
"app.home.luck.get": { "message": "一键获取" },
|
||||||
|
"app.home.luck.login-to-get": { "message": "登录后获取" },
|
||||||
|
"app.home.luck.loading": { "message": "正在询问小精灵..." },
|
||||||
|
"app.home.luck.error": { "message": "小精灵暂时没有回应,请稍后重试。" },
|
||||||
|
"app.home.luck.out-of": { "message": "满分 100" },
|
||||||
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
|
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
|
||||||
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
|
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
|
||||||
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
|
"minecraft-account.skin-site.sync-error": {
|
||||||
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
|
"message": "暂时无法验证皮肤站登录状态,将自动重试。"
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.players": { "message": "皮肤站玩家({count})" },
|
||||||
|
"minecraft-account.skin-site.players.retry": { "message": "重试" },
|
||||||
|
"minecraft-account.skin-site.players.empty": { "message": "这个皮肤站账号还没有玩家。" },
|
||||||
|
"minecraft-account.skin-site.players.error": {
|
||||||
|
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.player.ready": { "message": "可启动" },
|
||||||
|
"minecraft-account.skin-site.player.synced": { "message": "来自皮肤站" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光矿井:彩域"
|
"message": "星光矿井:彩域"
|
||||||
},
|
},
|
||||||
@ -10227,8 +10242,11 @@
|
|||||||
"minecraft-account.offline-mode": {
|
"minecraft-account.offline-mode": {
|
||||||
"message": "离线模式"
|
"message": "离线模式"
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.description": {
|
"minecraft-account.offline-mode.description.no-internet": {
|
||||||
"message": "仅可使用离线账号,并且只能启动已完整下载的实例。这通常是因为你的电脑无法连接到Mojang的认证服务器,请检查网络连接。如果你正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
|
"message": "看起来本机似乎并没有连接到互联网,您目前只能启动已完整下载的实例,且极可能无法连接到StarLight服务器,请检查网络连接。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
|
||||||
|
},
|
||||||
|
"minecraft-account.offline-mode.description.server-unavailable": {
|
||||||
|
"message": "您目前网络正常,但无法连接到StarLight验证服务器;这可能是由于StarLight服务器正在维护,或者您配置的代理存在问题。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态;若该操作不生效,请前往StarLight交流群,联系服务器管理确认服务器的维护状态。"
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.refresh": {
|
"minecraft-account.offline-mode.refresh": {
|
||||||
"message": "刷新连接状态"
|
"message": "刷新连接状态"
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
{
|
{
|
||||||
|
"app.home.luck.title": { "message": "每日幸運指數" },
|
||||||
|
"app.home.luck.get": { "message": "一鍵獲取" },
|
||||||
|
"app.home.luck.login-to-get": { "message": "登入後獲取" },
|
||||||
|
"app.home.luck.loading": { "message": "正在詢問小精靈..." },
|
||||||
|
"app.home.luck.error": { "message": "小精靈暫時沒有回應,請稍後重試。" },
|
||||||
|
"app.home.luck.out-of": { "message": "滿分 100" },
|
||||||
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
|
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
|
||||||
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
|
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
|
||||||
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
|
"minecraft-account.skin-site.sync-error": {
|
||||||
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
|
"message": "暫時無法驗證皮膚站登入狀態,將自動重試。"
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.players": { "message": "皮膚站玩家({count})" },
|
||||||
|
"minecraft-account.skin-site.players.retry": { "message": "重試" },
|
||||||
|
"minecraft-account.skin-site.players.empty": { "message": "這個皮膚站帳號還沒有玩家。" },
|
||||||
|
"minecraft-account.skin-site.players.error": {
|
||||||
|
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
|
||||||
|
},
|
||||||
|
"minecraft-account.skin-site.player.ready": { "message": "可啟動" },
|
||||||
|
"minecraft-account.skin-site.player.synced": { "message": "來自皮膚站" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光礦井:彩域"
|
"message": "星光礦井:彩域"
|
||||||
},
|
},
|
||||||
@ -9873,8 +9888,11 @@
|
|||||||
"minecraft-account.offline-mode": {
|
"minecraft-account.offline-mode": {
|
||||||
"message": "離線模式"
|
"message": "離線模式"
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.description": {
|
"minecraft-account.offline-mode.description.no-internet": {
|
||||||
"message": "僅可使用離線帳號,並且只能啟動已完整下載的實例。"
|
"message": "看起來本機似乎並未連線到網際網路,您目前只能啟動已完整下載的實例,且極可能無法連線到 StarLight 伺服器,請檢查網路連線。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態。"
|
||||||
|
},
|
||||||
|
"minecraft-account.offline-mode.description.server-unavailable": {
|
||||||
|
"message": "您目前網路正常,但無法連線到 StarLight 驗證伺服器;這可能是因為 StarLight 伺服器正在維護,或者您設定的代理存在問題。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態;若該操作沒有作用,請前往 StarLight 交流群組,聯絡伺服器管理員確認維護狀態。"
|
||||||
},
|
},
|
||||||
"minecraft-account.offline-mode.refresh": {
|
"minecraft-account.offline-mode.refresh": {
|
||||||
"message": "刷新連接狀態"
|
"message": "刷新連接狀態"
|
||||||
|
|||||||
@ -35,7 +35,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the authentication servers are reachable.
|
/// Checks if the StarLight authentication server is reachable.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_reachable() -> Result<()> {
|
pub async fn check_reachable() -> Result<()> {
|
||||||
minecraft_auth::check_reachable().await?;
|
minecraft_auth::check_reachable().await?;
|
||||||
|
|||||||
@ -1,98 +1,254 @@
|
|||||||
// Runs inside the embedded skin site only. The JWT never leaves that origin.
|
// Runs inside the embedded skin site only. The JWT never leaves that origin.
|
||||||
(() => {
|
;(() => {
|
||||||
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
|
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
|
||||||
|
|
||||||
const launcherOrigins = new Set([
|
const launcherOrigins = new Set([
|
||||||
'http://localhost:5201',
|
'http://localhost:5201',
|
||||||
'http://tauri.localhost',
|
'http://tauri.localhost',
|
||||||
'https://tauri.localhost',
|
'https://tauri.localhost',
|
||||||
'tauri://localhost',
|
'tauri://localhost',
|
||||||
])
|
])
|
||||||
let parentOrigin
|
let parentOrigin
|
||||||
let lastToken
|
let lastToken
|
||||||
let lastCheck = 0
|
let lastCheck = 0
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let pending
|
let pending
|
||||||
let snapshot = { status: 'checking', user: null }
|
const pendingLuck = new Map()
|
||||||
|
const pendingPlayers = new Map()
|
||||||
|
let snapshot = { status: 'checking', user: null }
|
||||||
|
|
||||||
function publish(status, user = null) {
|
function publish(status, user = null) {
|
||||||
snapshot = { status, user }
|
snapshot = { status, user }
|
||||||
if (parentOrigin) {
|
if (parentOrigin) {
|
||||||
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
|
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSession(force = false) {
|
function publishLuck(requestId, result) {
|
||||||
if (!parentOrigin) return
|
if (!parentOrigin) return
|
||||||
let token
|
window.parent.postMessage(
|
||||||
try {
|
{ type: 'starlight-skin-luck-result', requestId, ...result },
|
||||||
token = localStorage.getItem('loginToken') || ''
|
parentOrigin,
|
||||||
} catch {
|
)
|
||||||
publish('error')
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
const changed = token !== lastToken
|
|
||||||
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
|
|
||||||
const revision = ++generation
|
|
||||||
pending?.abort()
|
|
||||||
pending = undefined
|
|
||||||
lastToken = token
|
|
||||||
lastCheck = Date.now()
|
|
||||||
if (!token) {
|
|
||||||
publish('signed-out')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (changed) publish('checking')
|
|
||||||
const controller = new AbortController()
|
|
||||||
pending = controller
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
||||||
try {
|
|
||||||
const response = await fetch('/starlight/user', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
signal: controller.signal,
|
|
||||||
cache: 'no-store',
|
|
||||||
redirect: 'error',
|
|
||||||
})
|
|
||||||
const body = response.ok ? await response.json() : null
|
|
||||||
// A logout or account switch must win over an older in-flight response.
|
|
||||||
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
|
|
||||||
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
|
|
||||||
publish('signed-out')
|
|
||||||
} else if (
|
|
||||||
response.ok && typeof body?.payload?.uuid === 'string' &&
|
|
||||||
typeof body.payload.username === 'string' && body.payload.username.length > 0
|
|
||||||
) {
|
|
||||||
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
|
|
||||||
} else {
|
|
||||||
publish('error')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (revision === generation) publish('error')
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (revision === generation) pending = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', (event) => {
|
function publishPlayers(requestId, result) {
|
||||||
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
|
if (!parentOrigin) return
|
||||||
if (event.data?.type !== 'starlight-skin-session-connect') return
|
window.parent.postMessage(
|
||||||
parentOrigin = event.origin
|
{ type: 'starlight-skin-players-result', requestId, ...result },
|
||||||
publish(snapshot.status, snapshot.user)
|
parentOrigin,
|
||||||
void checkSession(true)
|
)
|
||||||
})
|
}
|
||||||
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
|
|
||||||
window.addEventListener('storage', () => void checkSession())
|
async function requestPlayers(requestId) {
|
||||||
let timer = setInterval(() => void checkSession(), 1000)
|
if (pendingPlayers.has(requestId)) return
|
||||||
window.addEventListener('pagehide', () => {
|
let token
|
||||||
clearInterval(timer)
|
try {
|
||||||
++generation
|
token = localStorage.getItem('loginToken') || ''
|
||||||
pending?.abort()
|
} catch {
|
||||||
pending = undefined
|
publishPlayers(requestId, { ok: false, error: 'Unable to read the skin site session.' })
|
||||||
})
|
return
|
||||||
window.addEventListener('pageshow', (event) => {
|
}
|
||||||
if (!event.persisted) return
|
if (!token) {
|
||||||
timer = setInterval(() => void checkSession(), 1000)
|
publish('signed-out')
|
||||||
void checkSession(true)
|
publishPlayers(requestId, { ok: false, error: 'Sign in to the skin site first.' })
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
pendingPlayers.set(requestId, controller)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/starlight/skin/player', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
if (localStorage.getItem('loginToken') !== token) {
|
||||||
|
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) publish('signed-out')
|
||||||
|
const rawPlayers = Array.isArray(body?.payload) ? body.payload : null
|
||||||
|
const players = rawPlayers?.slice(0, 100).flatMap((player) => {
|
||||||
|
if (
|
||||||
|
typeof player?.uuid !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{32,36}$/i.test(player.uuid) ||
|
||||||
|
typeof player?.name !== 'string' ||
|
||||||
|
player.name.length < 1 ||
|
||||||
|
player.name.length > 64
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
|
||||||
|
})
|
||||||
|
if (response.ok && players) {
|
||||||
|
publishPlayers(requestId, { ok: true, players })
|
||||||
|
} else {
|
||||||
|
publishPlayers(requestId, {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
typeof body?.errorMessage === 'string' && body.errorMessage
|
||||||
|
? body.errorMessage.slice(0, 300)
|
||||||
|
: 'The skin site returned an invalid player list.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingPlayers.delete(requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLuck(requestId) {
|
||||||
|
if (pendingLuck.has(requestId)) return
|
||||||
|
let token
|
||||||
|
try {
|
||||||
|
token = localStorage.getItem('loginToken') || ''
|
||||||
|
} catch {
|
||||||
|
publishLuck(requestId, { ok: false, error: 'Unable to read the skin site session.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
publish('signed-out')
|
||||||
|
publishLuck(requestId, { ok: false, error: 'Sign in to the skin site first.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
pendingLuck.set(requestId, controller)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/starlight/luck', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
if (localStorage.getItem('loginToken') !== token) {
|
||||||
|
publishLuck(requestId, { ok: false, error: 'The skin site account changed.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) publish('signed-out')
|
||||||
|
const luck = Number(body?.payload?.luck)
|
||||||
|
if (response.ok && Number.isFinite(luck) && luck >= 0 && luck <= 100) {
|
||||||
|
publishLuck(requestId, { ok: true, luck })
|
||||||
|
} else {
|
||||||
|
publishLuck(requestId, {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
typeof body?.errorMessage === 'string' && body.errorMessage
|
||||||
|
? body.errorMessage.slice(0, 300)
|
||||||
|
: 'The skin site returned an invalid luck result.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
publishLuck(requestId, { ok: false, error: 'Unable to reach the luck service.' })
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
pendingLuck.delete(requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSession(force = false) {
|
||||||
|
if (!parentOrigin) return
|
||||||
|
let token
|
||||||
|
try {
|
||||||
|
token = localStorage.getItem('loginToken') || ''
|
||||||
|
} catch {
|
||||||
|
publish('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const changed = token !== lastToken
|
||||||
|
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
|
||||||
|
const revision = ++generation
|
||||||
|
pending?.abort()
|
||||||
|
pending = undefined
|
||||||
|
lastToken = token
|
||||||
|
lastCheck = Date.now()
|
||||||
|
if (!token) {
|
||||||
|
publish('signed-out')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (changed) publish('checking')
|
||||||
|
const controller = new AbortController()
|
||||||
|
pending = controller
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/starlight/user', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const body = response.ok ? await response.json() : null
|
||||||
|
// A logout or account switch must win over an older in-flight response.
|
||||||
|
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
|
||||||
|
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
|
||||||
|
publish('signed-out')
|
||||||
|
} else if (
|
||||||
|
response.ok &&
|
||||||
|
typeof body?.payload?.uuid === 'string' &&
|
||||||
|
typeof body.payload.username === 'string' &&
|
||||||
|
body.payload.username.length > 0
|
||||||
|
) {
|
||||||
|
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
|
||||||
|
} else {
|
||||||
|
publish('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (revision === generation) publish('error')
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (revision === generation) pending = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
|
||||||
|
if (event.data?.type === 'starlight-skin-session-connect') {
|
||||||
|
parentOrigin = event.origin
|
||||||
|
publish(snapshot.status, snapshot.user)
|
||||||
|
void checkSession(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.data?.type === 'starlight-skin-luck-request' &&
|
||||||
|
parentOrigin === event.origin &&
|
||||||
|
typeof event.data.requestId === 'string' &&
|
||||||
|
/^skin-luck-\d+-\d+$/.test(event.data.requestId)
|
||||||
|
) {
|
||||||
|
void requestLuck(event.data.requestId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.data?.type === 'starlight-skin-players-request' &&
|
||||||
|
parentOrigin === event.origin &&
|
||||||
|
typeof event.data.requestId === 'string' &&
|
||||||
|
/^skin-players-\d+-\d+$/.test(event.data.requestId)
|
||||||
|
) {
|
||||||
|
void requestPlayers(event.data.requestId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
|
||||||
|
window.addEventListener('storage', () => void checkSession())
|
||||||
|
let timer = setInterval(() => void checkSession(), 1000)
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
clearInterval(timer)
|
||||||
|
++generation
|
||||||
|
pending?.abort()
|
||||||
|
pending = undefined
|
||||||
|
for (const controller of pendingLuck.values()) controller.abort()
|
||||||
|
pendingLuck.clear()
|
||||||
|
for (const controller of pendingPlayers.values()) controller.abort()
|
||||||
|
pendingPlayers.clear()
|
||||||
|
})
|
||||||
|
window.addEventListener('pageshow', (event) => {
|
||||||
|
if (!event.persisted) return
|
||||||
|
timer = setInterval(() => void checkSession(), 1000)
|
||||||
|
void checkSession(true)
|
||||||
|
})
|
||||||
})()
|
})()
|
||||||
|
|||||||
@ -5,63 +5,206 @@ const test = require('node:test')
|
|||||||
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
|
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
|
||||||
|
|
||||||
function harness(origin = 'https://skin.starlight.cool') {
|
function harness(origin = 'https://skin.starlight.cool') {
|
||||||
let token = null
|
let token = null
|
||||||
const listeners = {}, messages = [], requests = []
|
const listeners = {},
|
||||||
let tick
|
messages = [],
|
||||||
let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
|
requests = []
|
||||||
const parent = { postMessage(data, target) { messages.push({ data, target }) } }
|
let tick
|
||||||
const context = {
|
let result = async () => ({
|
||||||
location: { origin },
|
ok: true,
|
||||||
window: { parent, addEventListener(type, callback) { listeners[type] = callback } },
|
status: 200,
|
||||||
localStorage: { getItem() { return token } },
|
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
|
||||||
fetch(...args) { requests.push(args); return result(...args) },
|
})
|
||||||
AbortController, Date, setTimeout, clearTimeout,
|
const parent = {
|
||||||
setInterval(callback) { tick = callback; return 1 }, clearInterval() {},
|
postMessage(data, target) {
|
||||||
}
|
messages.push({ data, target })
|
||||||
vm.runInNewContext(source, context)
|
},
|
||||||
return {
|
}
|
||||||
messages, requests, listeners, parent,
|
const context = {
|
||||||
token(value) { token = value }, result(value) { result = value },
|
location: { origin },
|
||||||
async tick() { tick?.(); await new Promise(setImmediate) },
|
window: {
|
||||||
async connect(origin = 'http://localhost:5201', source = parent) {
|
parent,
|
||||||
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
|
addEventListener(type, callback) {
|
||||||
await new Promise(setImmediate)
|
listeners[type] = callback
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
localStorage: {
|
||||||
|
getItem() {
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch(...args) {
|
||||||
|
requests.push(args)
|
||||||
|
return result(...args)
|
||||||
|
},
|
||||||
|
AbortController,
|
||||||
|
Date,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval(callback) {
|
||||||
|
tick = callback
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
clearInterval() {},
|
||||||
|
}
|
||||||
|
vm.runInNewContext(source, context)
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
requests,
|
||||||
|
listeners,
|
||||||
|
parent,
|
||||||
|
token(value) {
|
||||||
|
token = value
|
||||||
|
},
|
||||||
|
result(value) {
|
||||||
|
result = value
|
||||||
|
},
|
||||||
|
async tick() {
|
||||||
|
tick?.()
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
},
|
||||||
|
async connect(origin = 'http://localhost:5201', source = parent) {
|
||||||
|
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
},
|
||||||
|
async message(data, origin = 'http://localhost:5201', source = parent) {
|
||||||
|
listeners.message?.({ source, origin, data })
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
|
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
|
||||||
assert.equal(harness('https://evil.example').listeners.message, undefined)
|
assert.equal(harness('https://evil.example').listeners.message, undefined)
|
||||||
const h = harness(); h.token('test-token')
|
const h = harness()
|
||||||
await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {})
|
h.token('test-token')
|
||||||
assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0)
|
await h.connect('https://evil.example')
|
||||||
await h.connect()
|
await h.connect('http://localhost:5201', {})
|
||||||
assert.equal(h.requests[0][0], '/starlight/user')
|
assert.equal(h.requests.length, 0)
|
||||||
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
|
assert.equal(h.messages.length, 0)
|
||||||
assert.equal(h.requests[0][1].redirect, 'error')
|
await h.connect()
|
||||||
assert.equal(h.messages.at(-1).data.status, 'signed-in')
|
assert.equal(h.requests[0][0], '/starlight/user')
|
||||||
assert.ok(h.messages.every(m => m.target === 'http://localhost:5201'))
|
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
|
||||||
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
assert.equal(h.requests[0][1].redirect, 'error')
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-in')
|
||||||
|
assert.ok(h.messages.every((m) => m.target === 'http://localhost:5201'))
|
||||||
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('logout, invalid token, network error and account switching replace old state', async () => {
|
test('logout, invalid token, network error and account switching replace old state', async () => {
|
||||||
const h = harness(); await h.connect()
|
const h = harness()
|
||||||
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
await h.connect()
|
||||||
h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One')
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
h.result(async () => ({ ok: false, status: 401 }))
|
h.token('first')
|
||||||
h.token('expired'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
await h.tick()
|
||||||
h.result(async () => { throw Error('offline') })
|
assert.equal(h.messages.at(-1).data.user.username, 'One')
|
||||||
h.token('network-error'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'error')
|
h.result(async () => ({ ok: false, status: 401 }))
|
||||||
h.token(null); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
h.token('expired')
|
||||||
|
await h.tick()
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
h.result(async () => {
|
||||||
|
throw Error('offline')
|
||||||
|
})
|
||||||
|
h.token('network-error')
|
||||||
|
await h.tick()
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'error')
|
||||||
|
h.token(null)
|
||||||
|
await h.tick()
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a delayed response cannot restore the user after logout', async () => {
|
test('a delayed response cannot restore the user after logout', async () => {
|
||||||
const h = harness(); let finish
|
const h = harness()
|
||||||
h.result(() => new Promise(resolve => { finish = resolve }))
|
let finish
|
||||||
h.token('first'); await h.connect()
|
h.result(
|
||||||
h.token(null); await h.tick()
|
() =>
|
||||||
finish({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
|
new Promise((resolve) => {
|
||||||
await new Promise(setImmediate)
|
finish = resolve
|
||||||
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
}),
|
||||||
assert.ok(!h.messages.some(m => m.data.status === 'signed-in'))
|
)
|
||||||
|
h.token('first')
|
||||||
|
await h.connect()
|
||||||
|
h.token(null)
|
||||||
|
await h.tick()
|
||||||
|
finish({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
|
||||||
|
})
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
assert.ok(!h.messages.some((m) => m.data.status === 'signed-in'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('luck requests stay on the skin origin and return only the validated score', async () => {
|
||||||
|
const h = harness()
|
||||||
|
h.token('test-token')
|
||||||
|
await h.connect()
|
||||||
|
h.result(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ payload: { luck: 73 } }),
|
||||||
|
}))
|
||||||
|
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-1-1' })
|
||||||
|
const request = h.requests.at(-1)
|
||||||
|
assert.equal(request[0], '/starlight/luck')
|
||||||
|
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
|
||||||
|
assert.equal(request[1].redirect, 'error')
|
||||||
|
const message = h.messages.at(-1)
|
||||||
|
assert.equal(message.target, 'http://localhost:5201')
|
||||||
|
assert.equal(message.data.type, 'starlight-skin-luck-result')
|
||||||
|
assert.equal(message.data.requestId, 'skin-luck-1-1')
|
||||||
|
assert.equal(message.data.ok, true)
|
||||||
|
assert.equal(message.data.luck, 73)
|
||||||
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('luck requests reject untrusted parents, missing sessions, and invalid scores', async () => {
|
||||||
|
const h = harness()
|
||||||
|
await h.connect()
|
||||||
|
const before = h.requests.length
|
||||||
|
await h.message(
|
||||||
|
{ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-1' },
|
||||||
|
'https://evil.example',
|
||||||
|
)
|
||||||
|
assert.equal(h.requests.length, before)
|
||||||
|
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-2' })
|
||||||
|
assert.equal(h.requests.length, before)
|
||||||
|
assert.equal(h.messages.at(-1).data.ok, false)
|
||||||
|
|
||||||
|
h.token('test-token')
|
||||||
|
h.result(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ payload: { luck: 101 } }),
|
||||||
|
}))
|
||||||
|
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-3' })
|
||||||
|
assert.equal(h.messages.at(-1).data.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('player requests return a sanitized complete player collection without exposing the token', async () => {
|
||||||
|
const h = harness()
|
||||||
|
h.token('test-token')
|
||||||
|
await h.connect()
|
||||||
|
h.result(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
payload: [
|
||||||
|
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
|
||||||
|
{ uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true },
|
||||||
|
{ uuid: 'bad', name: 'Ignored', isMojang: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' })
|
||||||
|
const request = h.requests.at(-1)
|
||||||
|
assert.equal(request[0], '/starlight/skin/player')
|
||||||
|
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
|
||||||
|
const message = h.messages.at(-1)
|
||||||
|
assert.equal(message.data.type, 'starlight-skin-players-result')
|
||||||
|
assert.equal(message.data.ok, true)
|
||||||
|
assert.equal(message.data.players.length, 2)
|
||||||
|
assert.equal(message.data.players[1].isMojang, true)
|
||||||
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -14,16 +14,13 @@ use crate::state::{
|
|||||||
};
|
};
|
||||||
pub use crate::state::{MinecraftDeviceLoginFlow, MinecraftDeviceLoginPoll};
|
pub use crate::state::{MinecraftDeviceLoginFlow, MinecraftDeviceLoginPoll};
|
||||||
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
|
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
|
||||||
use crate::util::mojang::{mojang_service_url, should_use_mojang_mirror};
|
|
||||||
|
const STARLIGHT_SESSION_SERVER_URL: &str = "https://skin.starlight.cool/yggdrasil/sessionserver/session/minecraft/hasJoined";
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn check_reachable() -> crate::Result<()> {
|
pub async fn check_reachable() -> crate::Result<()> {
|
||||||
let url = mojang_service_url(
|
|
||||||
"https://sessionserver.mojang.com/session/minecraft/hasJoined",
|
|
||||||
should_use_mojang_mirror(),
|
|
||||||
);
|
|
||||||
let resp = INSECURE_REQWEST_CLIENT
|
let resp = INSECURE_REQWEST_CLIENT
|
||||||
.get(url.as_ref())
|
.get(STARLIGHT_SESSION_SERVER_URL)
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user