feat: 嵌入 StarLight 皮肤站并同步登录状态
This commit is contained in:
@ -758,9 +758,9 @@ const messages = defineMessages({
|
|||||||
id: 'app.account.signed-in-as',
|
id: 'app.account.signed-in-as',
|
||||||
defaultMessage: 'Signed in as',
|
defaultMessage: 'Signed in as',
|
||||||
},
|
},
|
||||||
playingAs: {
|
userInformation: {
|
||||||
id: 'app.minecraft.playing-as',
|
id: 'app.minecraft.user-information',
|
||||||
defaultMessage: 'Playing as',
|
defaultMessage: 'User information',
|
||||||
},
|
},
|
||||||
collapseSidebar: {
|
collapseSidebar: {
|
||||||
id: 'app.sidebar.collapse',
|
id: 'app.sidebar.collapse',
|
||||||
@ -2469,7 +2469,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||||
<h3 class="text-base text-primary font-medium m-0">
|
<h3 class="text-base text-primary font-medium m-0">
|
||||||
{{ formatMessage(messages.playingAs) }}
|
{{ formatMessage(messages.userInformation) }}
|
||||||
</h3>
|
</h3>
|
||||||
<suspense>
|
<suspense>
|
||||||
<AccountsCard ref="accounts" />
|
<AccountsCard ref="accounts" />
|
||||||
|
|||||||
@ -1,4 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
|
||||||
|
<Avatar :src="axolotlLogo" size="36px" />
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
|
||||||
|
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.skinSiteChecking) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.skinSiteSyncError) }}
|
||||||
|
</p>
|
||||||
|
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||||||
|
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
|
<LogInIcon />
|
||||||
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
<div
|
<div
|
||||||
v-if="offline"
|
v-if="offline"
|
||||||
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"
|
||||||
@ -17,18 +36,24 @@
|
|||||||
v-if="accounts.length === 0"
|
v-if="accounts.length === 0"
|
||||||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||||
>
|
>
|
||||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||||||
<ButtonStyled v-if="!offline" color="brand">
|
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
<LogInIcon v-if="!loginDisabled" />
|
<LogInIcon v-if="!loginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
{{ formatMessage(messages.signInToMinecraft) }}
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="!offline && skinSiteUser">
|
||||||
|
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="!offline">
|
<ButtonStyled v-if="!offline">
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -137,15 +162,15 @@
|
|||||||
</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">
|
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||||
<button :disabled="loginDisabled" @click="login()">
|
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</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="showYggdrasilAccountModal()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -262,6 +287,7 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
|
Checkbox,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@ -272,12 +298,13 @@ import { useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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 ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import { openSkinSiteLogin, 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 {
|
||||||
@ -298,12 +325,22 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
import { useTheming } from '@/store/state'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const themeStore = useTheming()
|
||||||
|
|
||||||
|
async function goToSkinSiteLogin() {
|
||||||
|
openSkinSiteLogin()
|
||||||
|
// The login action must reveal the iframe even if Minimal Home was selected.
|
||||||
|
themeStore.homeLayout = 'standard'
|
||||||
|
await router.push('/').catch(handleError)
|
||||||
|
}
|
||||||
const refreshingNetwork = ref(false)
|
const refreshingNetwork = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -897,6 +934,22 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
skinSiteSignedIn: {
|
||||||
|
id: 'minecraft-account.skin-site.signed-in',
|
||||||
|
defaultMessage: 'Signed in to StarLight Skin Site',
|
||||||
|
},
|
||||||
|
skinSiteChecking: {
|
||||||
|
id: 'minecraft-account.skin-site.checking',
|
||||||
|
defaultMessage: 'Checking skin site session…',
|
||||||
|
},
|
||||||
|
skinSiteSyncError: {
|
||||||
|
id: 'minecraft-account.skin-site.sync-error',
|
||||||
|
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||||||
|
},
|
||||||
|
addSkinGameAccount: {
|
||||||
|
id: 'minecraft-account.add-skin-game-account',
|
||||||
|
defaultMessage: 'Add skin site game account',
|
||||||
|
},
|
||||||
offlineMode: {
|
offlineMode: {
|
||||||
id: 'minecraft-account.offline-mode',
|
id: 'minecraft-account.offline-mode',
|
||||||
defaultMessage: 'Offline mode',
|
defaultMessage: 'Offline mode',
|
||||||
@ -916,11 +969,11 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
addMicrosoftAccount: {
|
addMicrosoftAccount: {
|
||||||
id: 'minecraft-account.add-microsoft-account',
|
id: 'minecraft-account.add-microsoft-account',
|
||||||
defaultMessage: 'Add Microsoft account',
|
defaultMessage: 'Add your own Minecraft account',
|
||||||
},
|
},
|
||||||
addThirdPartyAccount: {
|
signInToStarlight: {
|
||||||
id: 'minecraft-account.add-third-party-account',
|
id: 'minecraft-account.sign-in-starlight',
|
||||||
defaultMessage: 'Add third-party account',
|
defaultMessage: 'Sign in to StarLight Skin Site',
|
||||||
},
|
},
|
||||||
thirdPartyAccount: {
|
thirdPartyAccount: {
|
||||||
id: 'minecraft-account.third-party-account',
|
id: 'minecraft-account.third-party-account',
|
||||||
@ -1064,10 +1117,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.label',
|
id: 'minecraft-account.label',
|
||||||
defaultMessage: 'Minecraft account',
|
defaultMessage: 'Minecraft account',
|
||||||
},
|
},
|
||||||
signInToMinecraft: {
|
|
||||||
id: 'minecraft-account.sign-in',
|
|
||||||
defaultMessage: 'Sign in to Minecraft',
|
|
||||||
},
|
|
||||||
loginTrouble: {
|
loginTrouble: {
|
||||||
id: 'minecraft-login.trouble',
|
id: 'minecraft-login.trouble',
|
||||||
defaultMessage: 'Having trouble?',
|
defaultMessage: 'Having trouble?',
|
||||||
|
|||||||
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveSkinSiteSession,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteFrameUrl,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
frameTitle: {
|
||||||
|
id: 'app.starlight-skin.frame-title',
|
||||||
|
defaultMessage: 'StarLight Skin Site',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const frame = ref<HTMLIFrameElement>()
|
||||||
|
let lastMessage = 0
|
||||||
|
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
frame.value?.contentWindow?.postMessage(
|
||||||
|
{ type: 'starlight-skin-session-connect' },
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receive(event: MessageEvent) {
|
||||||
|
if (receiveSkinSiteSession(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('message', receive)
|
||||||
|
connect()
|
||||||
|
expiryTimer = setInterval(() => {
|
||||||
|
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
||||||
|
}, 10_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('message', receive)
|
||||||
|
clearInterval(expiryTimer)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
ref="frame"
|
||||||
|
:src="skinSiteFrameUrl"
|
||||||
|
:title="formatMessage(messages.frameTitle)"
|
||||||
|
class="block h-full min-h-0 w-full border-0"
|
||||||
|
@load="connect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
receiveSkinSiteSession,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteFrameUrl,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from './skin-site-session.ts'
|
||||||
|
|
||||||
|
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
|
||||||
|
const frame = {} as Window
|
||||||
|
const message = (status: string, user?: unknown) =>
|
||||||
|
({
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: { type: 'starlight-skin-session', status, user },
|
||||||
|
}) as MessageEvent
|
||||||
|
resetSkinSiteSession()
|
||||||
|
openSkinSiteLogin()
|
||||||
|
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
|
||||||
|
const signedIn = message('signed-in', {
|
||||||
|
uuid: 'test-user',
|
||||||
|
username: '测试用户',
|
||||||
|
jwt: 'must-not-copy',
|
||||||
|
})
|
||||||
|
assert.equal(
|
||||||
|
receiveSkinSiteSession({ ...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(skinSiteUser.value, null)
|
||||||
|
assert.equal(receiveSkinSiteSession(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(skinSiteUser.value, null)
|
||||||
|
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
|
||||||
|
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
|
||||||
|
receiveSkinSiteSession(message('signed-out'), frame)
|
||||||
|
assert.equal(skinSiteUser.value, null)
|
||||||
|
assert.equal(skinSiteStatus.value, 'signed-out')
|
||||||
|
receiveSkinSiteSession(message('error'), frame)
|
||||||
|
assert.equal(skinSiteStatus.value, 'error')
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const user = ref<SkinSiteUser | null>(null)
|
||||||
|
const status = ref<SkinSiteStatus>('signed-out')
|
||||||
|
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||||
|
|
||||||
|
export const skinSiteUser = readonly(user)
|
||||||
|
export const skinSiteStatus = readonly(status)
|
||||||
|
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||||
|
|
||||||
|
export function openSkinSiteLogin() {
|
||||||
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSkinSiteSession() {
|
||||||
|
user.value = null
|
||||||
|
status.value = 'signed-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiveSkinSiteSession(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 (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||||
|
if (data.status === 'signed-in') {
|
||||||
|
if (
|
||||||
|
!data.user ||
|
||||||
|
typeof data.user.uuid !== 'string' ||
|
||||||
|
typeof data.user.username !== 'string' ||
|
||||||
|
!data.user.uuid ||
|
||||||
|
!data.user.username ||
|
||||||
|
data.user.username.length > 256
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
status.value = data.status
|
||||||
|
return true
|
||||||
|
}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"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" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "Starlight Mine: Chromatic Realms"
|
"message": "Starlight Mine: Chromatic Realms"
|
||||||
},
|
},
|
||||||
@ -5279,8 +5283,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "View Mod changes"
|
"message": "View Mod changes"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "Playing as"
|
"message": "User information"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "Content required"
|
"message": "Content required"
|
||||||
@ -10074,13 +10078,13 @@
|
|||||||
"message": "World is in use"
|
"message": "World is in use"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "Add Microsoft account"
|
"message": "Add your own Minecraft account"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "Add offline account"
|
"message": "Add offline account"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "Add third-party account"
|
"message": "Sign in to StarLight Skin Site"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "Copy UUID"
|
"message": "Copy UUID"
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"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": "添加皮肤站游戏账号" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光矿井:彩域"
|
"message": "星光矿井:彩域"
|
||||||
},
|
},
|
||||||
@ -5369,8 +5373,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "查看 Mod 变化"
|
"message": "查看 Mod 变化"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "当前游玩账号:"
|
"message": "用户信息"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "需求内容"
|
"message": "需求内容"
|
||||||
@ -10155,13 +10159,13 @@
|
|||||||
"message": "世界正在使用中"
|
"message": "世界正在使用中"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "添加 Microsoft 账户"
|
"message": "添加自己的正版账号"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "添加离线账户"
|
"message": "添加离线账户"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "添加第三方账号"
|
"message": "登录 StarLight皮肤站"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "复制 UUID"
|
"message": "复制 UUID"
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"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": "新增皮膚站遊戲帳號" },
|
||||||
"app.easteregg.color-mine.title": {
|
"app.easteregg.color-mine.title": {
|
||||||
"message": "星光礦井:彩域"
|
"message": "星光礦井:彩域"
|
||||||
},
|
},
|
||||||
@ -5120,8 +5124,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "查看 Mod 變更"
|
"message": "查看 Mod 變更"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "當前遊玩帳號:"
|
"message": "使用者資訊"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "所需內容"
|
"message": "所需內容"
|
||||||
@ -9801,13 +9805,13 @@
|
|||||||
"message": "世界正在使用中"
|
"message": "世界正在使用中"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "新增 Microsoft 帳號"
|
"message": "新增自己的正版帳號"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "新增離線帳號"
|
"message": "新增離線帳號"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "新增第三方帳號"
|
"message": "登入 StarLight皮膚站"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "複製 UUID"
|
"message": "複製 UUID"
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
|
||||||
CheckIcon,
|
|
||||||
GridIcon,
|
|
||||||
LayoutTemplateIcon,
|
|
||||||
MinimizeIcon,
|
|
||||||
MoveIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RotateCounterClockwiseIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
import {
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@ -18,20 +9,14 @@ import {
|
|||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
|
||||||
createDefaultHomeDashboard,
|
|
||||||
createHomeDashboardSaveQueue,
|
|
||||||
type HomeDashboardConfig,
|
|
||||||
normalizeHomeDashboard,
|
|
||||||
} from '@/components/home/home-dashboard'
|
|
||||||
import { getActivePlayerName } from '@/components/home/home-utils'
|
import { getActivePlayerName } from '@/components/home/home-utils'
|
||||||
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
||||||
import HomeDashboard from '@/components/home/HomeDashboard.vue'
|
|
||||||
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
||||||
|
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
||||||
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
||||||
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
||||||
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
|
||||||
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
||||||
|
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { get_default_user, users } from '@/helpers/auth'
|
import { get_default_user, users } from '@/helpers/auth'
|
||||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||||
@ -41,7 +26,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
|||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||||
import { useTheming } from '@/store/state'
|
import { useTheming } from '@/store/state'
|
||||||
import type { FeatureFlag, HomeLayout } from '@/store/theme'
|
import type { HomeLayout } from '@/store/theme'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -66,58 +51,15 @@ const messages = defineMessages({
|
|||||||
id: 'app.home.layout.toggle',
|
id: 'app.home.layout.toggle',
|
||||||
defaultMessage: 'Minimal Home',
|
defaultMessage: 'Minimal Home',
|
||||||
},
|
},
|
||||||
switchToGridWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.layout.switch-to-grid',
|
|
||||||
defaultMessage: 'Switch to grid widget layout',
|
|
||||||
},
|
|
||||||
switchToFreeWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.layout.switch-to-free',
|
|
||||||
defaultMessage: 'Switch to free widget layout',
|
|
||||||
},
|
|
||||||
widgetLayoutToggle: {
|
|
||||||
id: 'app.home.widgets.layout.toggle',
|
|
||||||
defaultMessage: 'Widget layout mode',
|
|
||||||
},
|
|
||||||
resetWidgets: {
|
|
||||||
id: 'app.home.widgets.reset-confirm',
|
|
||||||
defaultMessage: 'Restore the default widget layout?',
|
|
||||||
},
|
|
||||||
customizeWidgets: {
|
|
||||||
id: 'app.home.widgets.customize',
|
|
||||||
defaultMessage: 'Customize widgets',
|
|
||||||
},
|
|
||||||
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
|
|
||||||
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
|
|
||||||
resetWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.reset',
|
|
||||||
defaultMessage: 'Restore default widgets',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
|
||||||
|
|
||||||
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
||||||
|
|
||||||
const instances = ref<GameInstance[]>([])
|
const instances = ref<GameInstance[]>([])
|
||||||
const playerName = ref<string | null>(null)
|
const playerName = ref<string | null>(null)
|
||||||
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
|
|
||||||
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
|
|
||||||
const dashboardEditing = ref(false)
|
|
||||||
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
||||||
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
||||||
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
|
|
||||||
const switchingLayout = ref(false)
|
const switchingLayout = ref(false)
|
||||||
const dashboardSaveQueue = createHomeDashboardSaveQueue(
|
|
||||||
async (config) => {
|
|
||||||
const settings = await getSettings()
|
|
||||||
settings.home_widgets = config
|
|
||||||
await setSettings(settings)
|
|
||||||
},
|
|
||||||
(config) => {
|
|
||||||
dashboardConfig.value = config
|
|
||||||
},
|
|
||||||
handleError,
|
|
||||||
)
|
|
||||||
const floatingControlsStyle = computed(() => ({
|
const floatingControlsStyle = computed(() => ({
|
||||||
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
||||||
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
||||||
@ -162,38 +104,6 @@ async function fetchPlayerName() {
|
|||||||
playerName.value = getActivePlayerName(selectedUser, accounts)
|
playerName.value = getActivePlayerName(selectedUser, accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboardConfig() {
|
|
||||||
try {
|
|
||||||
const settings = await getSettings()
|
|
||||||
const normalized = normalizeHomeDashboard(settings.home_widgets)
|
|
||||||
if (normalized) {
|
|
||||||
dashboardConfig.value = normalized
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
|
|
||||||
dashboardConfig.value = config
|
|
||||||
settings.home_widgets = config
|
|
||||||
await setSettings(settings)
|
|
||||||
} catch (error) {
|
|
||||||
dashboardConfig.value = createDefaultHomeDashboard(
|
|
||||||
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
|
|
||||||
)
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDashboardConfig(config: HomeDashboardConfig) {
|
|
||||||
const previous = dashboardConfig.value ?? config
|
|
||||||
dashboardConfig.value = config
|
|
||||||
void dashboardSaveQueue.enqueue(config, previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDashboardConfig() {
|
|
||||||
if (!window.confirm(formatMessage(messages.resetWidgets))) return
|
|
||||||
updateDashboardConfig(createDefaultHomeDashboard())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMinimalInstance(instance: GameInstance) {
|
async function selectMinimalInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
@ -214,10 +124,8 @@ async function toggleHomeLayout() {
|
|||||||
|
|
||||||
const previousLayout = themeStore.homeLayout
|
const previousLayout = themeStore.homeLayout
|
||||||
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
||||||
const previousEditing = dashboardEditing.value
|
|
||||||
switchingLayout.value = true
|
switchingLayout.value = true
|
||||||
themeStore.homeLayout = nextLayout
|
themeStore.homeLayout = nextLayout
|
||||||
if (nextLayout === 'minimal') dashboardEditing.value = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
@ -225,28 +133,14 @@ async function toggleHomeLayout() {
|
|||||||
await setSettings(settings)
|
await setSettings(settings)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
themeStore.homeLayout = previousLayout
|
themeStore.homeLayout = previousLayout
|
||||||
dashboardEditing.value = previousEditing
|
|
||||||
handleError(error)
|
handleError(error)
|
||||||
} finally {
|
} finally {
|
||||||
switchingLayout.value = false
|
switchingLayout.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDashboardEditing() {
|
|
||||||
dashboardEditing.value = !dashboardEditing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWidgetLayout() {
|
|
||||||
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
|
|
||||||
}
|
|
||||||
|
|
||||||
function openWidgetPicker() {
|
|
||||||
dashboard.value?.openWidgetPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
const instancesLoaded = await fetchInstances()
|
const instancesLoaded = await fetchInstances()
|
||||||
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
||||||
await loadDashboardConfig()
|
|
||||||
|
|
||||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
||||||
|
|
||||||
@ -267,16 +161,8 @@ onUnmounted(() => {
|
|||||||
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
||||||
@select="selectMinimalInstance"
|
@select="selectMinimalInstance"
|
||||||
/>
|
/>
|
||||||
<div class="min-h-full">
|
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
|
||||||
<HomeDashboard
|
<SkinSiteFrame v-if="!isMinimal" />
|
||||||
v-if="!isMinimal && dashboardConfig"
|
|
||||||
ref="dashboard"
|
|
||||||
:config="dashboardConfig"
|
|
||||||
:instances="instances"
|
|
||||||
:player-name="playerName"
|
|
||||||
:editing="dashboardEditing"
|
|
||||||
@change="updateDashboardConfig"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HomeMinimal
|
<HomeMinimal
|
||||||
v-else
|
v-else
|
||||||
@ -288,71 +174,6 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-floating-controls" :style="floatingControlsStyle">
|
<div class="home-floating-controls" :style="floatingControlsStyle">
|
||||||
<template v-if="!isMinimal">
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="formatMessage(messages.addWidget)"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:aria-label="formatMessage(messages.addWidget)"
|
|
||||||
@click="openWidgetPicker"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="formatMessage(messages.resetWidgetLayout)"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:aria-label="formatMessage(messages.resetWidgetLayout)"
|
|
||||||
@click="resetDashboardConfig"
|
|
||||||
>
|
|
||||||
<RotateCounterClockwiseIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
|
||||||
"
|
|
||||||
data-onboarding-id="home-widget-customize"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:class="{ 'is-active': dashboardEditing }"
|
|
||||||
:aria-label="
|
|
||||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
|
||||||
"
|
|
||||||
:aria-pressed="dashboardEditing"
|
|
||||||
@click="toggleDashboardEditing"
|
|
||||||
>
|
|
||||||
<CheckIcon v-if="dashboardEditing" />
|
|
||||||
<PencilIcon v-else />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="
|
|
||||||
formatMessage(
|
|
||||||
isFreeWidgetLayout
|
|
||||||
? messages.switchToGridWidgetLayout
|
|
||||||
: messages.switchToFreeWidgetLayout,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
class="home-layout-switch home-widget-layout-switch"
|
|
||||||
:class="{ 'is-free': isFreeWidgetLayout }"
|
|
||||||
:aria-checked="isFreeWidgetLayout"
|
|
||||||
:aria-label="formatMessage(messages.widgetLayoutToggle)"
|
|
||||||
@click="toggleWidgetLayout"
|
|
||||||
>
|
|
||||||
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
|
|
||||||
<GridIcon />
|
|
||||||
</span>
|
|
||||||
<span class="home-layout-switch-thumb" aria-hidden="true" />
|
|
||||||
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
|
|
||||||
<MoveIcon />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<span class="home-floating-divider" aria-hidden="true" />
|
|
||||||
</template>
|
|
||||||
<button
|
<button
|
||||||
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
||||||
data-onboarding-id="home-layout-switch"
|
data-onboarding-id="home-layout-switch"
|
||||||
@ -388,6 +209,17 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.home-content {
|
||||||
|
/* The embedded site owns scrolling within the launcher viewport. */
|
||||||
|
height: calc(100dvh - var(--top-bar-height));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-content.is-minimal {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100dvh - var(--top-bar-height));
|
||||||
|
}
|
||||||
|
|
||||||
.home-floating-controls {
|
.home-floating-controls {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
@ -406,57 +238,11 @@ onUnmounted(() => {
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-floating-action {
|
|
||||||
display: flex;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color 120ms ease,
|
|
||||||
color 120ms ease,
|
|
||||||
filter 150ms ease,
|
|
||||||
transform 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:hover {
|
|
||||||
background: var(--color-button-bg);
|
|
||||||
color: var(--color-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:active {
|
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:focus-visible,
|
|
||||||
.home-layout-switch:focus-visible {
|
.home-layout-switch:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-floating-action.is-active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: var(--color-accent-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action :deep(svg) {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin: 0 0.125rem;
|
|
||||||
background: var(--color-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-layout-switch {
|
.home-layout-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -505,10 +291,6 @@ onUnmounted(() => {
|
|||||||
transform: translateX(2rem);
|
transform: translateX(2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
|
|
||||||
transform: translateX(2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-layout-switch-option {
|
.home-layout-switch-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -527,9 +309,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
||||||
.home-layout-switch.is-minimal .home-layout-switch-minimal,
|
.home-layout-switch.is-minimal .home-layout-switch-minimal {
|
||||||
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
|
|
||||||
.home-widget-layout-switch.is-free .home-widget-layout-free {
|
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,41 +1,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: {
|
|
||||||
id: 'app.starlight-skin.title',
|
|
||||||
defaultMessage: '斯达莱特',
|
|
||||||
},
|
|
||||||
frameTitle: {
|
|
||||||
id: 'app.starlight-skin.frame-title',
|
|
||||||
defaultMessage: 'StarLight Skin Site',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const SKIN_SITE_URL = 'https://skin.starlight.cool/'
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
function onLoad() {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="h-full min-h-0">
|
||||||
<div v-if="loading" class="flex flex-1 items-center justify-center">
|
<SkinSiteFrame />
|
||||||
<span class="text-secondary">{{ formatMessage(messages.title) }}…</span>
|
|
||||||
</div>
|
|
||||||
<iframe
|
|
||||||
:src="SKIN_SITE_URL"
|
|
||||||
:title="formatMessage(messages.frameTitle)"
|
|
||||||
class="h-full min-h-0 w-full flex-1 border-0"
|
|
||||||
:class="loading ? 'hidden' : ''"
|
|
||||||
@load="onLoad"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -688,6 +688,13 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
builder = builder
|
builder = builder
|
||||||
|
.plugin(
|
||||||
|
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
|
||||||
|
.js_init_script_on_all_frames(include_str!(
|
||||||
|
"skin_site_bridge.js"
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
if let Some(payload) = args.get(1) {
|
if let Some(payload) = args.get(1) {
|
||||||
tracing::info!("Handling deep link from arg {payload}");
|
tracing::info!("Handling deep link from arg {payload}");
|
||||||
|
|||||||
98
apps/app/src/skin_site_bridge.js
Normal file
98
apps/app/src/skin_site_bridge.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Runs inside the embedded skin site only. The JWT never leaves that origin.
|
||||||
|
(() => {
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})()
|
||||||
67
apps/app/src/skin_site_bridge.test.cjs
Normal file
67
apps/app/src/skin_site_bridge.test.cjs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user