fix(stage-tamagotchi): prevent auxiliary renderer request storms (#2304)

This commit is contained in:
Neko
2026-08-16 22:08:18 +08:00
committed by GitHub
parent d768f5a334
commit 73c05d37eb
41 changed files with 772 additions and 171 deletions
+5 -1
View File
@@ -2,6 +2,7 @@
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -35,6 +36,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const syncedPinia = usePiniaSynced()
const primaryColor = computed(() => {
return isDark.value
@@ -74,7 +76,8 @@ watch(settings.themeColorsHueDynamic, () => {
onMounted(async () => {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
@@ -93,6 +96,7 @@ onMounted(async () => {
})
onUnmounted(() => {
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})
+4
View File
@@ -8,6 +8,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -34,6 +35,8 @@ configureAnalyticsAdapter(async (options) => {
})
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
const routeRecords = setupLayouts(routes as RouteRecordRaw[])
@@ -60,6 +63,7 @@ window.addEventListener('unhandledrejection', (event) => {
installDeepLinks(router)
createApp(App)
.use(synced.vue)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
@@ -25,4 +25,24 @@ describe('withHashRoute', () => {
const result = withHashRoute({ url: 'file:////home/workspace/project/index.html' }, '/test/inner-test')
expect(result).toEqual({ url: `file:////home/workspace/project/index.html#/test/inner-test` })
})
it('adds query options before the hash route for development URLs', () => {
expect(withHashRoute({ url: 'http://localhost:5173' }, '/about', {
query: { 'synced-leader': 'false' },
})).toEqual({
url: 'http://localhost:5173/?synced-leader=false#/about',
})
})
it('passes query options to Electron for packaged renderer URLs', () => {
expect(withHashRoute({ file: '/opt/airi/renderer/index.html' }, '/settings', {
query: { 'synced-leader': 'false' },
})).toEqual({
file: '/opt/airi/renderer/index.html',
options: {
hash: '/settings',
query: { 'synced-leader': 'false' },
},
})
})
})
@@ -92,9 +92,19 @@ export async function load(window: BrowserWindow, url: string | { url: string, o
}
/**
* A helper function to construct URL with hash route, which is commonly used in our app since we are using hash-based routing in renderer.
* Adds a hash route and optional query to an Electron renderer location.
*
* @example
* withHashRoute({ url: 'http://localhost:5173' }, '/about', {
* query: { 'synced-leader': 'false' },
* })
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
*/
export function withHashRoute(baseUrl: string | { url: string } | { file: string }, hashRoute: string) {
export function withHashRoute(
baseUrl: string | { url: string } | { file: string },
hashRoute: string,
options: Pick<LoadFileOptions, 'query'> = {},
) {
if (typeof baseUrl === 'object' && 'url' in baseUrl) {
// trim `/` suffix
const baseURLinURL = new URL(baseUrl.url)
@@ -103,12 +113,15 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string
const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
baseURLinURL.pathname = trimmedPathname
for (const [key, value] of Object.entries(options.query ?? {}))
baseURLinURL.searchParams.set(key, value)
baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
}
if (typeof baseUrl === 'object' && 'file' in baseUrl) {
return { file: `${baseUrl.file}`, options: { hash: hashRoute } } satisfies { file: string, options?: LoadFileOptions }
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
}
// trim `/` suffix
@@ -118,6 +131,9 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string
const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
baseURLinURL.pathname = trimmedPathname
for (const [key, value] of Object.entries(options.query ?? {}))
baseURLinURL.searchParams.set(key, value)
baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
@@ -44,7 +44,9 @@ export function setupAboutWindowReusable(params: {
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', {
query: { 'synced-leader': 'false' },
}))
return window
}).getWindow
@@ -344,7 +344,9 @@ export function setupCaptionWindowManager(params: {
const cleanupGetAttached = defineInvokeHandler(context, captionGetIsFollowingWindow, async () => isFollowing)
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption', {
query: { 'synced-leader': 'false' },
}))
try {
context.emit(captionIsFollowingWindowChanged, isFollowing)
@@ -44,7 +44,12 @@ export function setupChatWindowReusableFunc(params: {
i18n: params.i18n,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
}).getWindow
@@ -137,7 +137,9 @@ export async function setupDashboardWindow(params: {
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard', {
query: { 'synced-leader': 'false' },
}))
/**
* This is a know issue (or expected behavior maybe) to Electron.
@@ -123,6 +123,7 @@ export async function setupDesktopOverlayWindow(params: {
isDesktopOverlayPollHeartbeatEnabled()
? `/desktop-overlay?${desktopOverlayPollHeartbeatQueryParam}=1`
: '/desktop-overlay',
{ query: { 'synced-leader': 'false' } },
),
)
@@ -50,7 +50,9 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager {
})
protectPrivilegedWindowNavigation(window)
await load(window, withHashRoute(rendererBase, route))
await load(window, withHashRoute(rendererBase, route, {
query: { 'synced-leader': 'false' },
}))
return window
})
@@ -55,7 +55,12 @@ export function setupEditorWindowManager(params: {
i18n: params.i18n,
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(rendererBase, '/editor'))
await load(window, withHashRoute(rendererBase, '/editor', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
})
@@ -66,7 +66,9 @@ export async function setupInlayWindow(params: {
await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n })
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay', {
query: { 'synced-leader': 'false' },
}))
return window
}
@@ -29,7 +29,7 @@ import icon from '../../../../resources/icon.png?asset'
import { electronStartDraggingWindow } from '../../../shared/eventa'
import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { protectPrivilegedWindowNavigation, transparentWindowConfig } from '../shared'
import { setupMainWindowElectronInvokes } from './rpc/index.electron'
@@ -186,7 +186,9 @@ export async function setupMainWindow(params: {
onboardingWindowManager: params.onboardingWindowManager,
})
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', {
query: { 'synced-leader': 'true' },
}))
/**
* This is a know issue (or expected behavior maybe) to Electron.
@@ -47,7 +47,9 @@ export function setupNoticeWindowManager(params: {
async function loadNoticeRoute(window: BrowserWindow, payload: RequestWindowPayload & { id: string }) {
const routeWithId = `${payload.route}?id=${payload.id}`
await load(window, withHashRoute(rendererBase, routeWithId))
await load(window, withHashRoute(rendererBase, routeWithId, {
query: { 'synced-leader': 'false' },
}))
}
const manager = createReferencedWindowManager({
@@ -74,7 +74,9 @@ export function setupOnboardingWindowManager(params: {
await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel })
createAuthService({ context, window: newWindow })
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding', {
query: { 'synced-leader': 'false' },
}))
newWindow.on('closed', () => {
for (const cb of closeCallbacks) {
@@ -78,7 +78,9 @@ export function setupSettingsWindowReusableFunc(params: {
spotlightWindow: params.spotlightWindow,
})
await load(window, withHashRoute(rendererBase, currentRoute))
await load(window, withHashRoute(rendererBase, currentRoute, {
query: { 'synced-leader': 'false' },
}))
window.on('closed', () => {
if (settingsContext)
@@ -140,7 +140,12 @@ export function setupSpotlightWindowManager(params: {
showNotification(payload.body, () => void openChatWindowFromNotification())
})
await load(window, withHashRoute(rendererBase, '/spotlight'))
await load(window, withHashRoute(rendererBase, '/spotlight', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
})
@@ -440,7 +440,9 @@ export function setupWidgetsWindowManager(params: {
}
async function loadWithRoute(window: BrowserWindow, route: string) {
await load(window, withHashRoute(rendererBase, route))
await load(window, withHashRoute(rendererBase, route, {
query: { 'synced-leader': 'false' },
}))
currentRoute = route
}
+27 -29
View File
@@ -62,7 +62,7 @@ import {
useTamagotchiMcpToolsStore,
useTamagotchiPluginToolsStore,
} from './stores/tools'
import { resolveInitialWindowRoutePath } from './window-route'
import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context'
const { isDark: dark } = useTheme()
const settingsStore = useSettings()
@@ -73,20 +73,17 @@ const chatSessionStore = useChatSessionStore()
const context = useElectronEventaContext()
const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const initialWindowRoutePath = resolveInitialWindowRoutePath(route.path)
const windowContext = resolveRendererWindowContext()
const initialRoutePath = resolveInitialRendererRoutePath(route.path)
useChatStore()
const builtinToolsStore = useTamagotchiBuiltinToolsStore()
const mcpToolsStore = useTamagotchiMcpToolsStore()
const pluginToolsStore = useTamagotchiPluginToolsStore()
const syncedPinia = usePiniaSynced()
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
const isEditorWindowRoute = initialWindowRoutePath === '/editor'
const isSpotlightWindow = initialRoutePath === '/spotlight'
const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/')
// Every renderer participates in leader election. Keep provider state ready in
// auxiliary windows so a newly elected leader can execute chat actions after
// the previous window closes.
useAuthProviderSync()
async function refreshPluginRuntimeTools() {
@@ -98,8 +95,8 @@ async function refreshPluginRuntimeTools() {
}
}
// Every renderer creates the runtime tool stores because every renderer can
// become the leader. Only the leader discovers tools and keeps executors.
// Every renderer creates the runtime tool stores for synchronized state. Only
// the main Stage renderer discovers tools and keeps executors.
const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
chatSessionStore.setCloudSyncOwnership(isLeader)
if (!isLeader)
@@ -139,9 +136,8 @@ function createFullStageRuntime() {
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
const isAuxiliaryChatRoute = initialWindowRoutePath === '/chat'
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
const isWidgetsWindowRoute = () => route.path === '/widgets'
const usesGodotStage = initialRoutePath === '/' || initialRoutePath.startsWith('/settings')
const isWidgetsWindow = initialRoutePath === '/widgets'
function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) {
if (state.state === 'running') {
@@ -155,12 +151,13 @@ function createFullStageRuntime() {
usePerfTracerBridgeStore()
initializeStageThreeRuntimeTraceBridge()
// The main process returns the callback only to the renderer that started
// sign-in. Each login-capable window listens locally, while the synchronized
// auth action still executes once in the main Stage leader.
initializeElectronAuthCallbackBridge()
void stageWindowLifecycleStore.initializeWindowLifecycleBridge()
watch(() => route.path, () => {
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main')
}, { immediate: true })
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindow ? 'client' : 'main')
// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers.
pluginHostInspectorStore.setBridge({
@@ -217,13 +214,14 @@ function createFullStageRuntime() {
async initialize() {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
if (isGodotStageRoute()) {
if (usesGodotStage) {
try {
syncGodotStageRenderer(await getGodotStageStatus())
}
@@ -241,12 +239,10 @@ function createFullStageRuntime() {
token: serverChannelConfig.authToken || undefined,
possibleEvents: ['ui:configure'],
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
if (!isAuxiliaryChatRoute) {
contextBridgeStore.initialize()
if (!isWidgetsWindowRoute()) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
contextBridgeStore.initialize()
if (!isWidgetsWindow) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost())
@@ -264,13 +260,15 @@ function createFullStageRuntime() {
inferencePreload.triggerPreload()
},
dispose() {
if (!isAuxiliaryChatRoute)
contextBridgeStore.dispose()
cardStore.disposeRuntime()
contextBridgeStore.dispose()
},
}
}
const fullStageRuntime = isSpotlightWindowRoute || isEditorWindowRoute ? null : createFullStageRuntime()
const fullStageRuntime = windowContext.stageRuntime === 'full'
? createFullStageRuntime()
: null
const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale)
@@ -279,7 +277,7 @@ watch(dark, () => updateThemeColor(), { immediate: true })
watch(route, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
if (isSettingsWindowRoute) {
if (isSettingsWindow) {
context.value.on(electronSettingsNavigate, (event) => {
const targetRoute = event?.body?.route
if (!targetRoute || route.fullPath === targetRoute) {
@@ -324,7 +322,7 @@ onUnmounted(() => {
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster />
</ToasterRoot>
<ResizeHandler v-if="!isSpotlightWindowRoute" />
<ResizeHandler v-if="!isSpotlightWindow" />
<RouterView />
</template>
+4 -1
View File
@@ -18,6 +18,7 @@ import { handleHotUpdate, routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import { resolveRendererWindowContext } from './window-context'
import '@unocss/reset/tailwind.css'
import 'splitpanes/dist/splitpanes.css'
@@ -45,7 +46,9 @@ configureAnalyticsAdapter(async (options) => {
})
const pinia = createPinia()
const synced = setupSynced()
const synced = setupSynced({
leadership: resolveRendererWindowContext().leadership,
})
pinia.use(synced.pinia)
pinia.use(piniaPluginTracing)
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context'
describe('resolveInitialRendererRoutePath', () => {
// ROOT CAUSE:
//
// Vue Router reports `/` during App setup, before it hydrates the hash
// route. Window-specific setup therefore used the main-window behavior in
// widgets and settings renderers.
//
// https://github.com/moeru-ai/airi/pull/2304
it('uses the hash route before Vue Router hydrates', () => {
expect(resolveInitialRendererRoutePath('/', '#/widgets')).toBe('/widgets')
expect(resolveInitialRendererRoutePath('/', '#/settings/providers?source=tray')).toBe('/settings/providers')
})
it('uses the router path when no hash route exists', () => {
expect(resolveInitialRendererRoutePath('/settings/data', '')).toBe('/settings/data')
})
})
describe('resolveRendererWindowContext', () => {
it('assigns synchronized leadership from the explicit query', () => {
expect(resolveRendererWindowContext('?synced-leader=true')).toMatchObject({
leadership: 'leader-only',
})
expect(resolveRendererWindowContext('?synced-leader=false')).toMatchObject({
leadership: 'follower-only',
})
})
it('uses the full Stage runtime unless the query selects the minimal runtime', () => {
expect(resolveRendererWindowContext('?synced-leader=true').stageRuntime).toBe('full')
expect(resolveRendererWindowContext('?synced-leader=false').stageRuntime).toBe('full')
expect(resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal').stageRuntime).toBe('minimal')
})
it('rejects a renderer URL without an explicit leadership query', () => {
expect(() => resolveRendererWindowContext('')).toThrow('Missing synced-leader query')
expect(() => resolveRendererWindowContext('?synced-leader=unknown')).toThrow('Invalid synced-leader query: unknown')
expect(() => resolveRendererWindowContext('?synced-leader=false&stage-runtime=unknown')).toThrow('Invalid stage-runtime query: unknown')
})
})
@@ -0,0 +1,51 @@
import type { LeadershipMode } from '@proj-airi/stage-ui/libs/pinia'
/** Describes the synchronization and Stage runtime policy for one renderer. */
export interface RendererWindowContext {
/** Determines whether this renderer can own synchronized actions. */
leadership: LeadershipMode
/** Determines whether this renderer initializes Stage integrations. */
stageRuntime: 'full' | 'minimal'
}
function normalizeRoutePath(routePath: string) {
const [path = ''] = routePath.split(/[?#]/)
return path || '/'
}
/**
* Resolves the initial renderer route before Vue Router hydrates the hash.
*
* @example
* resolveInitialRendererRoutePath('/', '#/widgets?source=tray')
* // => '/widgets'
*/
export function resolveInitialRendererRoutePath(routePath: string, hash = globalThis.location?.hash ?? ''): string {
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
return normalizeRoutePath(hashPath || routePath)
}
/**
* Resolves renderer ownership from the query that the main process supplies.
*
* @example
* resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal')
* // => { leadership: 'follower-only', stageRuntime: 'minimal' }
*/
export function resolveRendererWindowContext(search = globalThis.location?.search ?? ''): RendererWindowContext {
const query = new URLSearchParams(search)
const syncedLeader = query.get('synced-leader')
if (syncedLeader === null)
throw new TypeError('Missing synced-leader query')
if (syncedLeader !== 'true' && syncedLeader !== 'false')
throw new TypeError(`Invalid synced-leader query: ${syncedLeader}`)
const stageRuntime = query.get('stage-runtime')
if (stageRuntime !== null && stageRuntime !== 'minimal')
throw new TypeError(`Invalid stage-runtime query: ${stageRuntime}`)
return {
leadership: syncedLeader === 'true' ? 'leader-only' : 'follower-only',
stageRuntime: stageRuntime === 'minimal' ? 'minimal' : 'full',
}
}
@@ -1,13 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveInitialWindowRoutePath } from './window-route'
describe('resolveInitialWindowRoutePath', () => {
it('uses the hash route before Vue Router hydrates', () => {
expect(resolveInitialWindowRoutePath('/', '#/chat?source=tray')).toBe('/chat')
})
it('uses the router path when no hash route exists', () => {
expect(resolveInitialWindowRoutePath('/settings/data', '')).toBe('/settings/data')
})
})
@@ -1,10 +0,0 @@
function normalizeRoutePath(routePath: string) {
const [path = ''] = routePath.split(/[?#]/)
return path || '/'
}
/** Resolves the initial hash route before Vue Router hydrates `route.path`. */
export function resolveInitialWindowRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') {
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
return normalizeRoutePath(hashPath || routePath)
}
+3 -1
View File
@@ -89,7 +89,8 @@ watch(settings.themeColorsHueDynamic, () => {
onMounted(async () => {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
@@ -110,6 +111,7 @@ onMounted(async () => {
onUnmounted(() => {
stopLeadershipListener()
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})