mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
refactor(stage-tamagotchi): with WindowAuthManager
This commit is contained in:
parent
438dd9d5a1
commit
4c9564f4b9
12 changed files with 94 additions and 48 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export * from './app'
|
||||
export * from './auth'
|
||||
export * from './auto-updater'
|
||||
export * from './powerMonitor'
|
||||
export * from './screen'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
6
pnpm-lock.yaml
generated
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue