feat: 完善 StarLight 皮肤站集成

This commit is contained in:
2026-09-14 11:29:48 +08:00
parent 9dd2b013af
commit fbcaed528b
15 changed files with 1105 additions and 615 deletions

View File

@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
import {
ChangeSkinIcon,
CompassIcon,
GlobeIcon,
DownloadIcon,
ExternalIcon,
FlaskConicalIcon,
@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { hideAllPoppers } from 'floating-vue'
import { listen } from '@tauri-apps/api/event'
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os'
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 { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
@ -730,10 +729,6 @@ const messages = defineMessages({
id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector',
},
starlightSkin: {
id: 'app.navigation.starlight-skin',
defaultMessage: '斯达莱特',
},
library: {
id: 'app.navigation.library',
defaultMessage: 'Library',
@ -2237,13 +2232,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.starlightSkin)"
data-onboarding-id="nav-starlight-skin"
to="/starlight-skin"
>
<GlobeIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library"

View 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

View File

@ -1,35 +1,70 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
import {
openSkinSiteLogin,
requestSkinSiteLuck,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
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 SKIN_HOME_URL = 'https://skin.starlight.cool/'
const luck = ref<number | null>(null)
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 {
await openUrl(SKIN_PROFILE_URL)
} catch (error) {
handleError(error)
luck.value = await requestSkinSiteLuck()
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function openSkinSite() {
try {
await openUrl(SKIN_HOME_URL)
} catch (error) {
handleError(error)
function handlePrimaryAction() {
if (signedIn.value) {
void fetchLuck()
return
}
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>
<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"
>
<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">
{{ formatMessage(messages.widgetTitle) }}
</h2>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
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"
@click="openProfile"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.luckyIndex) }}
</span>
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
</button>
<div class="flex flex-col gap-3">
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
<LuckyCloverIcon
class="lucky-clover lucky-clover-loading size-7 shrink-0"
aria-hidden="true"
/>
<span>{{ formatMessage(messages.loading) }}</span>
</div>
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
<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
type="button"
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"
@click="openSkinSite"
>
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
<span>{{ formatMessage(messages.jump) }}</span>
</button>
<ButtonStyled color="brand" class="w-full">
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
</button>
</ButtonStyled>
</div>
</section>
</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>

View File

@ -12,6 +12,55 @@
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</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">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<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"
>
<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>
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
@ -44,12 +101,6 @@
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline && skinSiteUser">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addSkinGameAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
@ -161,12 +212,6 @@
</div>
</template>
<div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addSkinGameAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
@ -177,99 +222,6 @@
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
<div
v-for="savedLogin in savedYggdrasilLogins"
:key="`${savedLogin.api_root}:${savedLogin.login}`"
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
>
<button
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
:disabled="loginDisabled"
@click="selectSavedYggdrasilLogin(savedLogin)"
>
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeSavedLogin)"
:disabled="loginDisabled"
@click="removeSavedYggdrasilLogin(savedLogin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.accountLabel) }}
<StyledInput
v-model="yggdrasilLogin"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.accountPlaceholder)"
autocomplete="username"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.passwordLabel) }}
<StyledInput
v-model="yggdrasilPassword"
type="password"
:disabled="loginDisabled"
autocomplete="current-password"
@keyup.enter="addYggdrasilAccount()"
/>
</label>
<Checkbox
v-model="rememberYggdrasilPassword"
:disabled="loginDisabled"
:label="formatMessage(messages.rememberPassword)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<LogInIcon v-else />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
<div class="flex min-w-[22rem] flex-col gap-2">
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<RadioButtonIcon v-else />
{{ profile.name }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
@ -287,11 +239,8 @@ import {
Accordion,
Avatar,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@ -303,21 +252,21 @@ import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import {
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
get_default_user,
get_yggdrasil_password,
list_yggdrasil_saved_logins,
login as loginToMinecraft,
remove_user,
set_default_user,
set_yggdrasil_password,
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
@ -329,7 +278,7 @@ import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { offline, refreshBrowserOffline } = useNetworkStatus()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
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 accounts: Ref<MinecraftCredential[]> = ref([])
@ -412,21 +347,11 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
const pendingYggdrasilFlowId = ref<string | undefined>()
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
const yggdrasilFormValid = computed(
() =>
yggdrasilApiRoot.value.trim().length > 0 &&
yggdrasilLogin.value.trim().length > 0 &&
yggdrasilPassword.value.length > 0,
)
const launcherAccountIds = computed(() => new Set(accounts.value.map((account) => account.profile.id)))
async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
function createSkinHeadDataUrl(textureUrl: string) {
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) {
await remove_user(account.profile.id).catch(handleError)
await refreshValues()
@ -946,18 +692,43 @@ const messages = defineMessages({
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
addSkinGameAccount: {
id: 'minecraft-account.add-skin-game-account',
defaultMessage: 'Add skin site game account',
skinSitePlayers: {
id: 'minecraft-account.skin-site.players',
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: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',
},
offlineModeDescription: {
id: 'minecraft-account.offline-mode.description',
offlineModeNoInternetDescription: {
id: 'minecraft-account.offline-mode.description.no-internet',
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: {
id: 'minecraft-account.offline-mode.refresh',
@ -983,58 +754,6 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-badge',
defaultMessage: 'StarLight skin',
},
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
defaultMessage: 'Account or email',
},
accountPlaceholder: {
id: 'minecraft-account.third-party-modal.account-placeholder',
defaultMessage: 'Enter your account or email',
},
passwordLabel: {
id: 'minecraft-account.third-party-modal.password',
defaultMessage: 'Password',
},
rememberPassword: {
id: 'minecraft-account.third-party-modal.remember-password',
defaultMessage: 'Save this login on this device',
},
savedLogins: {
id: 'minecraft-account.third-party-modal.saved-logins',
defaultMessage: 'Saved logins',
},
removeSavedLogin: {
id: 'minecraft-account.third-party-modal.remove-saved-login',
defaultMessage: 'Remove saved login',
},
signInButton: {
id: 'minecraft-account.third-party-modal.sign-in',
defaultMessage: 'Sign in',
},
selectProfileTitle: {
id: 'minecraft-account.third-party-profile.title',
defaultMessage: 'Select a profile',
},
selectProfileDescription: {
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
offlineAccount: {
id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account',

View File

@ -3,8 +3,9 @@ import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteSession,
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
} from '@/composables/skin-site-session'
@ -22,14 +23,13 @@ let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
frame.value?.contentWindow?.postMessage(
{ type: 'starlight-skin-session-connect' },
SKIN_SITE_ORIGIN,
)
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
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(() => {
@ -43,6 +43,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
</script>

View File

@ -3,10 +3,15 @@ import test from 'node:test'
import {
openSkinSiteLogin,
receiveSkinSiteSession,
receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSitePlayers,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} 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',
})
assert.equal(
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
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.equal(skinSiteStatus.value, 'signed-in')
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)
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteSession(message('signed-out'), frame)
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteSession(message('error'), frame)
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
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()
})

View File

@ -3,29 +3,195 @@ import { readonly, ref } from 'vue'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
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 status = ref<SkinSiteStatus>('signed-out')
const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle')
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 skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players)
export const skinSitePlayersStatus = readonly(playersStatus)
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() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
export function resetSkinSiteSession() {
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
user.value = null
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
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
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
@ -36,13 +202,24 @@ export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null
data.user.username.length > 256
)
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 }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
}

View File

@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core'
// }
/**
* Check if the authentication servers are reachable, throwing an exception if
* not reachable.
* Check if the StarLight authentication server is reachable, throwing an
* exception if it is not reachable.
*/
export async function check_reachable() {
await invoke('plugin:auth|check_reachable')

View File

@ -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.checking": { "message": "Checking skin site session…" },
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
"minecraft-account.skin-site.sync-error": {
"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": {
"message": "Starlight Mine: Chromatic Realms"
},
@ -10146,8 +10165,11 @@
"minecraft-account.offline-mode": {
"message": "Offline mode"
},
"minecraft-account.offline-mode.description": {
"message": "Only offline accounts are available. You can launch fully downloaded instances."
"minecraft-account.offline-mode.description.no-internet": {
"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": {
"message": "Refresh connection status"

View File

@ -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.checking": { "message": "正在验证皮肤站登录状态…" },
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
"minecraft-account.skin-site.sync-error": {
"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": {
"message": "星光矿井:彩域"
},
@ -10227,8 +10242,11 @@
"minecraft-account.offline-mode": {
"message": "离线模式"
},
"minecraft-account.offline-mode.description": {
"message": "仅可使用离线账号并且只能启动已完整下载的实例。这通常是因为你的电脑无法连接到Mojang的认证服务器,请检查网络连接。如果正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起来本机似乎并没有连接到互联网您目前只能启动已完整下载的实例且极可能无法连接到StarLight服务器,请检查网络连接。如果正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前网络正常但无法连接到StarLight验证服务器这可能是由于StarLight服务器正在维护或者您配置的代理存在问题。如果您正在使用代理请关闭或开启代理后点击下方按钮刷新连接状态若该操作不生效请前往StarLight交流群联系服务器管理确认服务器的维护状态。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新连接状态"

View File

@ -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.checking": { "message": "正在驗證皮膚站登入狀態…" },
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
"minecraft-account.skin-site.sync-error": {
"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": {
"message": "星光礦井:彩域"
},
@ -9873,8 +9888,11 @@
"minecraft-account.offline-mode": {
"message": "離線模式"
},
"minecraft-account.offline-mode.description": {
"message": "僅可使用離線帳號,並且只能啟動已完整下載的實例。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起來本機似乎並未連線到網際網路,您目前只能啟動已完整下載的實例,且極可能無法連線到 StarLight 伺服器,請檢查網路連線。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前網路正常,但無法連線到 StarLight 驗證伺服器;這可能是因為 StarLight 伺服器正在維護,或者您設定的代理存在問題。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態;若該操作沒有作用,請前往 StarLight 交流群組,聯絡伺服器管理員確認維護狀態。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新連接狀態"

View File

@ -35,7 +35,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build()
}
/// Checks if the authentication servers are reachable.
/// Checks if the StarLight authentication server is reachable.
#[tauri::command]
pub async fn check_reachable() -> Result<()> {
minecraft_auth::check_reachable().await?;

View File

@ -1,98 +1,254 @@
// 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([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
let snapshot = { status: 'checking', user: null }
const launcherOrigins = new Set([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
const pendingLuck = new Map()
const pendingPlayers = new Map()
let snapshot = { status: 'checking', user: null }
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
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
}
}
function publishLuck(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-luck-result', requestId, ...result },
parentOrigin,
)
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
if (event.data?.type !== 'starlight-skin-session-connect') return
parentOrigin = event.origin
publish(snapshot.status, snapshot.user)
void checkSession(true)
})
// 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
})
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
timer = setInterval(() => void checkSession(), 1000)
void checkSession(true)
})
function publishPlayers(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-players-result', requestId, ...result },
parentOrigin,
)
}
async function requestPlayers(requestId) {
if (pendingPlayers.has(requestId)) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishPlayers(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
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)
})
})()

View File

@ -5,63 +5,206 @@ const test = require('node:test')
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
function harness(origin = 'https://skin.starlight.cool') {
let token = null
const listeners = {}, messages = [], requests = []
let tick
let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
const parent = { postMessage(data, target) { messages.push({ data, target }) } }
const context = {
location: { origin },
window: { parent, addEventListener(type, callback) { 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)
},
}
let token = null
const listeners = {},
messages = [],
requests = []
let tick
let result = async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
})
const parent = {
postMessage(data, target) {
messages.push({ data, target })
},
}
const context = {
location: { origin },
window: {
parent,
addEventListener(type, callback) {
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 () => {
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness(); h.token('test-token')
await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer 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'))
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness()
h.token('test-token')
await h.connect('https://evil.example')
await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0)
assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer 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 () => {
const h = harness(); await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
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')
const h = harness()
await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first')
await h.tick()
assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
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 () => {
const h = harness(); let finish
h.result(() => new Promise(resolve => { finish = resolve }))
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'))
const h = harness()
let finish
h.result(
() =>
new Promise((resolve) => {
finish = resolve
}),
)
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'))
})

View File

@ -14,16 +14,13 @@ use crate::state::{
};
pub use crate::state::{MinecraftDeviceLoginFlow, MinecraftDeviceLoginPoll};
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]
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
.get(url.as_ref())
.get(STARLIGHT_SESSION_SERVER_URL)
.timeout(Duration::from_secs(5))
.send()
.await?;