mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(stage-tamagotchi): wait for silent Steam sign-in before opening enroll
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
getWebApiTicket,
|
||||
initSteam,
|
||||
} from '../steam/client'
|
||||
import { trySteamSignIn } from './auth'
|
||||
import { startSteamSignInFromUserGesture, trySteamSignIn } from './auth'
|
||||
import { exchangeSteamTicketForTokens } from './steam-sign-in'
|
||||
|
||||
vi.mock('../steam/client', () => ({
|
||||
@@ -20,6 +20,14 @@ vi.mock('./steam-sign-in', () => ({
|
||||
exchangeSteamTicketForTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./http-server/http/auth', () => ({
|
||||
startLoopbackServer: vi.fn(async () => ({
|
||||
port: 43123,
|
||||
close: vi.fn(),
|
||||
result: new Promise(() => {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
// NOTICE:
|
||||
// The main-process auth module imports Electron's `shell` runtime boundary.
|
||||
// This Vitest suite runs in Node and must not load the native Electron runtime.
|
||||
@@ -129,4 +137,64 @@ describe('trySteamSignIn', () => {
|
||||
})
|
||||
await first
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642770345
|
||||
it('opens enrollment after silent startup finishes when onboarding clicked during in-flight exchange (PR #1966)', async () => {
|
||||
let finishExchange: ((result: SteamExchangeResult) => void) | undefined
|
||||
exchangeSteamTicketForTokensMock.mockImplementation(async () => {
|
||||
return await new Promise<SteamExchangeResult>((resolve) => {
|
||||
finishExchange = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const windowAuthManager = {
|
||||
registerWindow: vi.fn(),
|
||||
broadcastAuthCallback: vi.fn(),
|
||||
broadcastAuthError: vi.fn(),
|
||||
}
|
||||
const { shell } = await import('electron')
|
||||
const openExternal = vi.mocked(shell.openExternal)
|
||||
openExternal.mockResolvedValue()
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// steamSignInInFlight from silent startup used to return immediately for a
|
||||
// concurrent user gesture (openBrowserOnNeedsEnrollment=true). Onboarding
|
||||
// still closed after IPC, so no enroll tab appeared.
|
||||
//
|
||||
// We fixed this by waiting for the in-flight silent attempt, then running
|
||||
// the user gesture so openExternal can open /enroll.
|
||||
const silent = trySteamSignIn(windowAuthManager)
|
||||
await vi.waitFor(() => {
|
||||
expect(exchangeSteamTicketForTokensMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const userClick = startSteamSignInFromUserGesture(windowAuthManager)
|
||||
|
||||
finishExchange?.({
|
||||
ok: false,
|
||||
kind: 'needs_enrollment',
|
||||
reason: 'Steam account is not linked — enrollment required',
|
||||
enrollToken: 'tok-silent',
|
||||
authUiUrl: 'https://accounts.airi.build/ui',
|
||||
})
|
||||
await silent
|
||||
|
||||
// Second exchange (user gesture) still pending until we resolve it.
|
||||
await vi.waitFor(() => {
|
||||
expect(exchangeSteamTicketForTokensMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
finishExchange?.({
|
||||
ok: false,
|
||||
kind: 'needs_enrollment',
|
||||
reason: 'Steam account is not linked — enrollment required',
|
||||
enrollToken: 'tok-user',
|
||||
authUiUrl: 'https://accounts.airi.build/ui',
|
||||
})
|
||||
await userClick
|
||||
|
||||
expect(openExternal).toHaveBeenCalledTimes(1)
|
||||
expect(String(openExternal.mock.calls[0]?.[0])).toContain('/enroll')
|
||||
expect(String(openExternal.mock.calls[0]?.[0])).toContain('tok-user')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ let closeLoopback: (() => void) | null = null
|
||||
let signingInFlight = false
|
||||
/** Serializes Steam ticket exchange + enrollment handoff across entry points. */
|
||||
let steamSignInInFlight = false
|
||||
/** Resolves when the current in-flight Steam sign-in finishes (success or fail). */
|
||||
let steamSignInInFlightDone: Promise<void> = Promise.resolve()
|
||||
|
||||
export type { TokenExchangeResult }
|
||||
|
||||
@@ -65,6 +67,14 @@ export async function trySteamSignIn(windowAuthManager: WindowAuthManager): Prom
|
||||
await startSteamSignIn(windowAuthManager, { openBrowserOnNeedsEnrollment: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* User-gesture Steam sign-in (onboarding / island Sign in). Unlinked SteamIDs
|
||||
* open the enrollment browser; linked IDs exchange tokens silently.
|
||||
*/
|
||||
export async function startSteamSignInFromUserGesture(windowAuthManager: WindowAuthManager): Promise<void> {
|
||||
await startSteamSignIn(windowAuthManager, { openBrowserOnNeedsEnrollment: true })
|
||||
}
|
||||
|
||||
export function createWindowAuthManagerService(): WindowAuthManager {
|
||||
const authContexts = new Set<MainContext>()
|
||||
|
||||
@@ -225,11 +235,25 @@ async function startSteamSignIn(
|
||||
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642770345
|
||||
// Ticket exchange must be single-flight: concurrent calls would each mint an
|
||||
// enrollment token and createEnrollmentToken() deletes the prior unused row.
|
||||
//
|
||||
// NOTICE:
|
||||
// Duplicate *silent* startup attempts may still be dropped. A *user gesture*
|
||||
// (onboarding / island Sign in) must wait for the in-flight attempt, then run
|
||||
// — otherwise onboarding closes after IPC returns with no browser opened.
|
||||
if (steamSignInInFlight) {
|
||||
log.warn('Ignoring concurrent Steam sign-in while another exchange is in flight')
|
||||
return
|
||||
if (!options.openBrowserOnNeedsEnrollment) {
|
||||
log.warn('Ignoring concurrent Steam sign-in while another exchange is in flight')
|
||||
return
|
||||
}
|
||||
log.warn('Waiting for in-flight Steam sign-in before opening enrollment')
|
||||
await steamSignInInFlightDone
|
||||
return startSteamSignIn(windowAuthManager, options)
|
||||
}
|
||||
|
||||
let releaseInFlight!: () => void
|
||||
steamSignInInFlightDone = new Promise<void>((resolve) => {
|
||||
releaseInFlight = resolve
|
||||
})
|
||||
steamSignInInFlight = true
|
||||
try {
|
||||
const ticketResult = await getWebApiTicket()
|
||||
@@ -270,6 +294,7 @@ async function startSteamSignIn(
|
||||
}
|
||||
finally {
|
||||
steamSignInInFlight = false
|
||||
releaseInFlight()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +335,7 @@ export function createAuthService(params: {
|
||||
// AIRI account. Plain OIDC only runs when Steam is not available.
|
||||
const initResult = await initSteam()
|
||||
if (initResult.ok) {
|
||||
await startSteamSignIn(params.windowAuthManager, { openBrowserOnNeedsEnrollment: true })
|
||||
await startSteamSignInFromUserGesture(params.windowAuthManager)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user