refactor(stage-tamagotchi): with WindowAuthManager

This commit is contained in:
Neko Ayaka 2026-04-03 00:49:10 +08:00
parent 438dd9d5a1
commit 4c9564f4b9
No known key found for this signature in database
12 changed files with 94 additions and 48 deletions

View file

@ -66,7 +66,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
? `${deps.env.API_SERVER_URL}/api/auth/oauth2/authorize?${oidcParams.toString()}`
: '/'
if (provider === 'google' || provider === 'github') {
if (!!provider && ['google', 'github'].includes(provider)) {
const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}`
return c.redirect(socialUrl)
}

View file

@ -171,6 +171,7 @@
"electron-builder": "^26.8.1",
"electron-updater": "^6.8.3",
"electron-vite": "^5.0.0",
"get-port-please": "catalog:",
"h3": "^2.0.1-rc.19",
"less": "^4.6.4",
"mkcert": "catalog:",

View file

@ -23,6 +23,7 @@ import { createGlobalAppConfig } from './configs/global'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
import { setElectronMainDirname } from './libs/electron/location'
import { createI18n } from './libs/i18n'
import { createWindowAuthManagerService } from './services/airi/auth'
import { setupServerChannel } from './services/airi/channel-server'
import { setupMcpStdioManager } from './services/airi/mcp-servers'
import { setupPluginHost } from './services/airi/plugins'
@ -121,13 +122,15 @@ app.whenReady().then(async () => {
build: () => setupPluginHost(),
})
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
// BeatSync will create a background window to capture and process audio.
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
const onboardingWindowManager = injeca.provide('windows:onboarding', {
dependsOn: { serverChannel, i18n },
dependsOn: { serverChannel, i18n, windowAuthManager },
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
})
@ -157,7 +160,7 @@ app.whenReady().then(async () => {
})
const mainWindow = injeca.provide('windows:main', {
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager },
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
})

View file

@ -4,7 +4,11 @@ import type { BrowserWindow } from 'electron'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@proj-airi/stage-shared/auth'
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from '@proj-airi/stage-shared/auth'
import { shell } from 'electron'
import {
@ -13,7 +17,7 @@ import {
electronAuthLogout,
electronAuthStartLogin,
} from '../../../shared/eventa'
import { startLoopbackServer } from './loopback-server'
import { startLoopbackServer } from '../electron/loopback-server'
const log = useLogg('auth-service').useGlobalConfig()
@ -28,18 +32,40 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
// Active loopback server cleanup handle
let closeLoopback: (() => void) | null = null
let loginInFlight = false
const authContexts = new Set<MainContext>()
let signingInFlight = false
function emitAuthCallback(tokens: TokenExchangeResult): void {
for (const context of authContexts) {
context.emit(electronAuthCallback, tokens)
}
export interface WindowAuthManager {
registerWindow: (params: { context: MainContext, window: BrowserWindow }) => void
broadcastAuthCallback: (tokens: TokenExchangeResult) => void
broadcastAuthError: (error: string) => void
}
function emitAuthError(error: string): void {
for (const context of authContexts) {
context.emit(electronAuthCallbackError, { error })
export function createWindowAuthManagerService(): WindowAuthManager {
const authContexts = new Set<MainContext>()
function broadcastAuthCallback(tokens: TokenExchangeResult): void {
for (const context of authContexts) {
context.emit(electronAuthCallback, tokens)
}
}
function broadcastAuthError(error: string): void {
for (const context of authContexts) {
context.emit(electronAuthCallbackError, { error })
}
}
return {
registerWindow(params) {
authContexts.add(params.context)
params.window.on('closed', () => {
authContexts.delete(params.context)
})
},
broadcastAuthCallback,
broadcastAuthError,
}
}
@ -49,10 +75,11 @@ function emitAuthError(error: string): void {
export function createAuthService(params: {
context: MainContext
window: BrowserWindow
windowAuthManager: WindowAuthManager
}): void {
authContexts.add(params.context)
params.window.on('closed', () => {
authContexts.delete(params.context)
params.windowAuthManager.registerWindow({
context: params.context,
window: params.window,
})
defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => {
@ -60,14 +87,14 @@ export function createAuthService(params: {
return
}
if (loginInFlight) {
if (signingInFlight) {
log.withFields({ windowId: params.window.webContents.id }).warn('Replacing in-flight OIDC login attempt with a new request')
closeLoopback?.()
closeLoopback = null
loginInFlight = false
signingInFlight = false
}
loginInFlight = true
signingInFlight = true
try {
// Clean up any previous in-flight login
@ -110,28 +137,28 @@ export function createAuthService(params: {
.then(async ({ code, state: returnedState }) => {
if (returnedState !== state) {
log.warn('State mismatch — possible CSRF attack')
emitAuthError('State mismatch')
params.windowAuthManager.broadcastAuthError('State mismatch')
return
}
const tokens = await exchangeCode(code, codeVerifier, redirectUri)
emitAuthCallback(tokens)
params.windowAuthManager.broadcastAuthCallback(tokens)
log.log('OIDC token exchange successful')
})
.catch((err) => {
log.withError(err).error('OIDC login failed')
emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed')
log.withError(err).error('OIDC signing in failed')
params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed')
})
.finally(() => {
closeLoopback = null
loginInFlight = false
signingInFlight = false
})
}
catch (err) {
closeLoopback = null
loginInFlight = false
log.withError(err).error('Failed to start OIDC login flow')
emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed')
signingInFlight = false
log.withError(err).error('Failed to start OIDC signing in flow')
params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed')
}
})
@ -142,7 +169,7 @@ export function createAuthService(params: {
closeLoopback?.()
closeLoopback = null
loginInFlight = false
signingInFlight = false
})
}

View file

@ -1,5 +1,4 @@
export * from './app'
export * from './auth'
export * from './auto-updater'
export * from './powerMonitor'
export * from './screen'

View file

@ -2,6 +2,7 @@ import type { Rectangle } from 'electron'
import type { InferOutput } from 'valibot'
import type { I18n } from '../../libs/i18n'
import type { WindowAuthManager } from '../../services/airi/auth'
import type { ServerChannel } from '../../services/airi/channel-server'
import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
@ -57,6 +58,7 @@ export async function setupMainWindow(params: {
mcpStdioManager: McpStdioManager
i18n: I18n
onboardingWindowManager: OnboardingWindowManager
windowAuthManager: WindowAuthManager
}) {
const {
setup: setupConfig,
@ -171,6 +173,7 @@ export async function setupMainWindow(params: {
mcpStdioManager: params.mcpStdioManager,
i18n: params.i18n,
onboardingWindowManager: params.onboardingWindowManager,
windowAuthManager: params.windowAuthManager,
})
/**

View file

@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { WindowAuthManager } from '../../../services/airi/auth'
import type { ServerChannel } from '../../../services/airi/channel-server'
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
@ -14,11 +15,11 @@ import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createOnboardingService } from '../../../services/airi/onboarding'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { createAuthService } from '../../../services/electron/auth'
import { toggleWindowShow } from '../../shared'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
@ -33,6 +34,7 @@ export async function setupMainWindowElectronInvokes(params: {
mcpStdioManager: McpStdioManager
i18n: I18n
onboardingWindowManager: OnboardingWindowManager
windowAuthManager: WindowAuthManager
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@ -46,7 +48,7 @@ export async function setupMainWindowElectronInvokes(params: {
createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager })
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager })
createAuthService({ context, window: params.window })
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route))

View file

@ -1,4 +1,5 @@
import type { I18n } from '../../libs/i18n'
import type { WindowAuthManager } from '../../services/airi/auth'
import type { ServerChannel } from '../../services/airi/channel-server'
import { join, resolve } from 'node:path'
@ -14,7 +15,7 @@ import icon from '../../../../resources/icon.png?asset'
import { electronOnboardingClose } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { createAuthService } from '../../services/electron/auth'
import { createAuthService } from '../../services/airi/auth'
import { toggleWindowShow } from '../shared'
import { setupBaseWindowElectronInvokes } from '../shared/window'
@ -26,6 +27,7 @@ export interface OnboardingWindowManager {
export function setupOnboardingWindowManager(params: {
serverChannel: ServerChannel
i18n: I18n
windowAuthManager: WindowAuthManager
}): OnboardingWindowManager {
async function getOnboardingWindow(getWindow: () => Promise<BrowserWindow>) {
const window = await getWindow()
@ -72,7 +74,7 @@ export function setupOnboardingWindowManager(params: {
})
await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel })
createAuthService({ context, window: newWindow })
createAuthService({ context, window: newWindow, windowAuthManager: params.windowAuthManager })
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))

View file

@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { WindowAuthManager } from '../../../services/airi/auth'
import type { ServerChannel } from '../../../services/airi/channel-server'
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
@ -12,10 +13,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { createAuthService } from '../../../services/electron/auth'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupSettingsWindowInvokes(params: {
@ -26,6 +27,7 @@ export async function setupSettingsWindowInvokes(params: {
serverChannel: ServerChannel
mcpStdioManager: McpStdioManager
i18n: I18n
windowAuthManager: WindowAuthManager
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@ -39,7 +41,7 @@ export async function setupSettingsWindowInvokes(params: {
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow })
createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager })
createAuthService({ context, window: params.settingsWindow })
createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager })
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {

View file

@ -22,10 +22,10 @@ const authStore = useAuthStore()
const { isAuthenticated, user, needsLogin, credits } = storeToRefs(authStore)
const context = useElectronEventaContext()
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
const startSigningIn = useElectronEventaInvoke(electronAuthStartLogin)
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const loggingIn = ref(false)
const signingIn = ref(false)
const userName = computed(() => user.value?.name)
const userAvatar = computed(() => user.value?.image)
@ -35,28 +35,28 @@ function handleClick() {
openSettings({ route: '/settings/account' })
}
else {
doLogin()
doSigningIn()
}
}
function doLogin() {
loggingIn.value = true
startLogin()
function doSigningIn() {
signingIn.value = true
startSigningIn()
}
// Clear loading state on callback or error from main process.
// No cleanup needed this component lives for the window's lifetime.
context.value.on(electronAuthCallback, () => {
loggingIn.value = false
signingIn.value = false
})
context.value.on(electronAuthCallbackError, () => {
loggingIn.value = false
signingIn.value = false
})
// React to needsLogin from other components (e.g. onboarding)
watch(needsLogin, (val) => {
if (val && !isAuthenticated.value) {
doLogin()
doSigningIn()
needsLogin.value = false
}
})
@ -64,13 +64,13 @@ watch(needsLogin, (val) => {
// Clear loading when authenticated
watch(isAuthenticated, (val) => {
if (val)
loggingIn.value = false
signingIn.value = false
})
</script>
<template>
<!-- Logging in state -->
<div v-if="loggingIn && !isAuthenticated" flex="~ col gap-1.5" mb-1.5>
<!-- Signing in state -->
<div v-if="signingIn && !isAuthenticated" flex="~ col gap-1.5" mb-1.5>
<div
flex="~ items-center gap-3"
rounded-xl px-3 py-2.5

6
pnpm-lock.yaml generated
View file

@ -186,6 +186,9 @@ catalogs:
es-toolkit:
specifier: 1.43.0
version: 1.43.0
get-port-please:
specifier: ^3.2.0
version: 3.2.0
histoire:
specifier: 1.0.0-beta.1
version: 1.0.0-beta.1
@ -1448,6 +1451,9 @@ importers:
electron-vite:
specifier: ^5.0.0
version: 5.0.0(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please:
specifier: 'catalog:'
version: 3.2.0
h3:
specifier: ^2.0.1-rc.19
version: 2.0.1-rc.19(crossws@0.4.4(patch_hash=4d79ec736d10d2a81a9e2a31b067d43f0b6665122267981e652ab9923d165958)(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d)))

View file

@ -87,6 +87,7 @@ catalog:
electron: ^40.8.3
embla-carousel-vue: ^9.0.0-rc01
es-toolkit: 1.43.0
get-port-please: ^3.2.0
histoire: 1.0.0-beta.1
hono: 4.11.3
hono-rate-limiter: ^0.5.3