fix(stage-tamagotchi): prevent auxiliary renderer request storms (#2304)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user