merge: 合并远端 main 分支

This commit is contained in:
2026-09-13 22:16:12 +08:00
13 changed files with 449 additions and 311 deletions

View File

@ -758,9 +758,9 @@ const messages = defineMessages({
id: 'app.account.signed-in-as',
defaultMessage: 'Signed in as',
},
playingAs: {
id: 'app.minecraft.playing-as',
defaultMessage: 'Playing as',
userInformation: {
id: 'app.minecraft.user-information',
defaultMessage: 'User information',
},
collapseSidebar: {
id: 'app.sidebar.collapse',
@ -2469,7 +2469,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<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">
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
{{ formatMessage(messages.userInformation) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />

View File

@ -1,4 +1,23 @@
<template>
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
<Avatar :src="axolotlLogo" size="36px" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
</div>
</div>
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteChecking) }}
</p>
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</p>
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon />
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<div
v-if="offline"
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
@ -17,18 +36,24 @@
v-if="accounts.length === 0"
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
>
<span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline" color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()">
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
{{ 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="showYggdrasilAccountModal()">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
{{ formatMessage(messages.addMicrosoftAccount) }}
</button>
</ButtonStyled>
</div>
@ -137,15 +162,15 @@
</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="login()">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addMicrosoftAccount) }}
{{ formatMessage(messages.addSkinGameAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
{{ formatMessage(messages.addMicrosoftAccount) }}
</button>
</ButtonStyled>
</div>
@ -262,6 +287,7 @@ import {
Accordion,
Avatar,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
@ -272,12 +298,13 @@ import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import {
@ -298,12 +325,22 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
async function goToSkinSiteLogin() {
openSkinSiteLogin()
// The login action must reveal the iframe even if Minimal Home was selected.
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError)
}
const refreshingNetwork = ref(false)
/**
@ -897,6 +934,22 @@ onUnmounted(() => {
})
const messages = defineMessages({
skinSiteSignedIn: {
id: 'minecraft-account.skin-site.signed-in',
defaultMessage: 'Signed in to StarLight Skin Site',
},
skinSiteChecking: {
id: 'minecraft-account.skin-site.checking',
defaultMessage: 'Checking skin site session…',
},
skinSiteSyncError: {
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
addSkinGameAccount: {
id: 'minecraft-account.add-skin-game-account',
defaultMessage: 'Add skin site game account',
},
offlineMode: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',
@ -916,11 +969,11 @@ const messages = defineMessages({
},
addMicrosoftAccount: {
id: 'minecraft-account.add-microsoft-account',
defaultMessage: 'Add Microsoft account',
defaultMessage: 'Add your own Minecraft account',
},
addThirdPartyAccount: {
id: 'minecraft-account.add-third-party-account',
defaultMessage: 'Add third-party account',
signInToStarlight: {
id: 'minecraft-account.sign-in-starlight',
defaultMessage: 'Sign in to StarLight Skin Site',
},
thirdPartyAccount: {
id: 'minecraft-account.third-party-account',
@ -1064,10 +1117,6 @@ const messages = defineMessages({
id: 'minecraft-account.label',
defaultMessage: 'Minecraft account',
},
signInToMinecraft: {
id: 'minecraft-account.sign-in',
defaultMessage: 'Sign in to Minecraft',
},
loginTrouble: {
id: 'minecraft-login.trouble',
defaultMessage: 'Having trouble?',

View 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>

View 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()
})

View 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
}

View File

@ -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": {
"message": "Starlight Mine: Chromatic Realms"
},
@ -5279,8 +5283,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "View Mod changes"
},
"app.minecraft.playing-as": {
"message": "Playing as"
"app.minecraft.user-information": {
"message": "User information"
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
@ -10074,13 +10078,13 @@
"message": "World is in use"
},
"minecraft-account.add-microsoft-account": {
"message": "Add Microsoft account"
"message": "Add your own Minecraft account"
},
"minecraft-account.add-offline-account": {
"message": "Add offline account"
},
"minecraft-account.add-third-party-account": {
"message": "Add third-party account"
"minecraft-account.sign-in-starlight": {
"message": "Sign in to StarLight Skin Site"
},
"minecraft-account.copy-uuid": {
"message": "Copy UUID"

View File

@ -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": {
"message": "星光矿井:彩域"
},
@ -5369,8 +5373,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 变化"
},
"app.minecraft.playing-as": {
"message": "当前游玩账号:"
"app.minecraft.user-information": {
"message": "用户信息"
},
"app.modal.install-to-play.content-required": {
"message": "需求内容"
@ -10155,13 +10159,13 @@
"message": "世界正在使用中"
},
"minecraft-account.add-microsoft-account": {
"message": "添加 Microsoft 账户"
"message": "添加自己的正版账号"
},
"minecraft-account.add-offline-account": {
"message": "添加离线账户"
},
"minecraft-account.add-third-party-account": {
"message": "添加第三方账号"
"minecraft-account.sign-in-starlight": {
"message": "登录 StarLight皮肤站"
},
"minecraft-account.copy-uuid": {
"message": "复制 UUID"

View File

@ -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": {
"message": "星光礦井:彩域"
},
@ -5120,8 +5124,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 變更"
},
"app.minecraft.playing-as": {
"message": "當前遊玩帳號:"
"app.minecraft.user-information": {
"message": "使用者資訊"
},
"app.modal.install-to-play.content-required": {
"message": "所需內容"
@ -9801,13 +9805,13 @@
"message": "世界正在使用中"
},
"minecraft-account.add-microsoft-account": {
"message": "新增 Microsoft 帳號"
"message": "新增自己的正版帳號"
},
"minecraft-account.add-offline-account": {
"message": "新增離線帳號"
},
"minecraft-account.add-third-party-account": {
"message": "新增第三方帳號"
"minecraft-account.sign-in-starlight": {
"message": "登入 StarLight皮膚站"
},
"minecraft-account.copy-uuid": {
"message": "複製 UUID"

View File

@ -1,14 +1,5 @@
<script setup lang="ts">
import {
CheckIcon,
GridIcon,
LayoutTemplateIcon,
MinimizeIcon,
MoveIcon,
PencilIcon,
PlusIcon,
RotateCounterClockwiseIcon,
} from '@modrinth/assets'
import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
@ -18,20 +9,14 @@ import {
import { computed, onUnmounted, ref } from 'vue'
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 HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
import HomeDashboard from '@/components/home/HomeDashboard.vue'
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth'
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 { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
import type { FeatureFlag, HomeLayout } from '@/store/theme'
import type { HomeLayout } from '@/store/theme'
const { handleError } = injectNotificationManager()
const route = useRoute()
@ -66,58 +51,15 @@ const messages = defineMessages({
id: 'app.home.layout.toggle',
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 })
const instances = ref<GameInstance[]>([])
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 isMinimal = computed(() => themeStore.homeLayout === 'minimal')
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
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(() => ({
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
@ -162,38 +104,6 @@ async function fetchPlayerName() {
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) {
try {
const settings = await getSettings()
@ -214,10 +124,8 @@ async function toggleHomeLayout() {
const previousLayout = themeStore.homeLayout
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
const previousEditing = dashboardEditing.value
switchingLayout.value = true
themeStore.homeLayout = nextLayout
if (nextLayout === 'minimal') dashboardEditing.value = false
try {
const settings = await getSettings()
@ -225,28 +133,14 @@ async function toggleHomeLayout() {
await setSettings(settings)
} catch (error) {
themeStore.homeLayout = previousLayout
dashboardEditing.value = previousEditing
handleError(error)
} finally {
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()
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
await loadDashboardConfig()
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
@ -267,16 +161,8 @@ onUnmounted(() => {
:selected-instance-id="themeStore.minimalHomeInstanceId"
@select="selectMinimalInstance"
/>
<div class="min-h-full">
<HomeDashboard
v-if="!isMinimal && dashboardConfig"
ref="dashboard"
:config="dashboardConfig"
:instances="instances"
:player-name="playerName"
:editing="dashboardEditing"
@change="updateDashboardConfig"
/>
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
<SkinSiteFrame v-if="!isMinimal" />
<HomeMinimal
v-else
@ -288,71 +174,6 @@ onUnmounted(() => {
/>
</div>
<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
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
data-onboarding-id="home-layout-switch"
@ -388,6 +209,17 @@ onUnmounted(() => {
</template>
<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 {
position: fixed;
z-index: 40;
@ -406,57 +238,11 @@ onUnmounted(() => {
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 {
outline: none;
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 {
position: relative;
display: grid;
@ -505,10 +291,6 @@ onUnmounted(() => {
transform: translateX(2rem);
}
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
transform: translateX(2rem);
}
.home-layout-switch-option {
position: relative;
z-index: 1;
@ -527,9 +309,7 @@ onUnmounted(() => {
}
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
.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 {
.home-layout-switch.is-minimal .home-layout-switch-minimal {
color: var(--color-accent-contrast);
}
</style>

View File

@ -1,41 +1,9 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
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
}
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="loading" class="flex flex-1 items-center justify-center">
<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 class="h-full min-h-0">
<SkinSiteFrame />
</div>
</template>

View File

@ -688,6 +688,13 @@ fn main() {
);
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| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}");

View 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)
})
})()

View 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'))
})