fix(stage-tamagotchi): wait for silent Steam sign-in before opening enroll

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-07-28 15:10:45 +08:00
co-authored by Cursor
parent 3555f131c1
commit 9e3e11b489
2 changed files with 97 additions and 4 deletions
@@ -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
}