fix(stage-tamagotchi): allow sign-in on the first attempt (#2482)

This commit is contained in:
Lovehsigure_520
2026-09-08 17:56:23 +08:00
committed by GitHub
parent ef9f122d50
commit f679616c34
3 changed files with 159 additions and 35 deletions
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, shallowRef } from 'vue'
import { useOnboardingAuthentication } from './use-onboarding-authentication'
describe('useOnboardingAuthentication', () => {
it('keeps the initiating window open until the first sign-in completes', async () => {
const isAuthenticated = shallowRef(false)
const needsLogin = shallowRef(false)
const closeRequestId = shallowRef(0)
const startLogin = vi.fn<() => Promise<void>>().mockResolvedValue()
const closeWindow = vi.fn<() => Promise<void>>().mockResolvedValue()
const scope = effectScope()
// ROOT CAUSE:
//
// The onboarding window closed as soon as the main process opened the browser.
// The main process later sent the token callback to that closed renderer, so the first sign-in was lost.
// The window must stay open until synchronized authentication state confirms the completed sign-in.
scope.run(() => useOnboardingAuthentication({
closeRequestId,
closeWindow,
isAuthenticated,
needsLogin,
onCloseError: vi.fn(),
startLogin,
}))
needsLogin.value = true
await nextTick()
await Promise.resolve()
expect(startLogin).toHaveBeenCalledTimes(1)
expect(needsLogin.value).toBe(false)
expect(closeWindow).not.toHaveBeenCalled()
isAuthenticated.value = true
await nextTick()
expect(closeWindow).toHaveBeenCalledTimes(1)
scope.stop()
})
it('closes when another renderer publishes a close request', async () => {
const closeRequestId = shallowRef(0)
const closeWindow = vi.fn<() => Promise<void>>().mockResolvedValue()
const scope = effectScope()
scope.run(() => useOnboardingAuthentication({
closeRequestId,
closeWindow,
isAuthenticated: shallowRef(false),
needsLogin: shallowRef(false),
onCloseError: vi.fn(),
startLogin: vi.fn<() => Promise<void>>().mockResolvedValue(),
}))
closeRequestId.value += 1
await nextTick()
expect(closeWindow).toHaveBeenCalledTimes(1)
scope.stop()
})
it('allows a close retry after Electron rejects the first request', async () => {
const closeWindow = vi.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('window unavailable'))
.mockResolvedValue()
const onCloseError = vi.fn()
const scope = effectScope()
const controls = scope.run(() => useOnboardingAuthentication({
closeRequestId: shallowRef(0),
closeWindow,
isAuthenticated: shallowRef(false),
needsLogin: shallowRef(false),
onCloseError,
startLogin: vi.fn<() => Promise<void>>().mockResolvedValue(),
}))
await controls!.closeOnboardingWindow()
await controls!.closeOnboardingWindow()
expect(closeWindow).toHaveBeenCalledTimes(2)
expect(onCloseError).toHaveBeenCalledTimes(1)
scope.stop()
})
})
@@ -0,0 +1,63 @@
import type { Ref } from 'vue'
import { watch } from 'vue'
interface UseOnboardingAuthenticationOptions {
closeRequestId: Readonly<Ref<number>>
closeWindow: () => Promise<unknown>
isAuthenticated: Readonly<Ref<boolean>>
needsLogin: Ref<boolean>
onCloseError: (error: unknown) => void
startLogin: () => Promise<void>
}
interface OnboardingAuthenticationControls {
closeOnboardingWindow: () => Promise<void>
}
/**
* Coordinates sign-in and window closure for the standalone onboarding renderer.
*
* The renderer that starts the external sign-in remains alive until synchronized
* authentication state confirms completion. Close requests are deduplicated while
* the Electron close operation is in flight.
*/
export function useOnboardingAuthentication(options: UseOnboardingAuthenticationOptions): OnboardingAuthenticationControls {
let closing = false
/** Closes the onboarding window once and permits a retry after a failed close. */
async function closeOnboardingWindow(): Promise<void> {
if (closing)
return
closing = true
try {
await options.closeWindow()
}
catch (error) {
closing = false
options.onCloseError(error)
}
}
// The shared action publishes a close request from the renderer that finishes
// authentication. This renderer remains the sole owner of the Electron close
// side effect. The auth check also handles a window mounted after the request.
watch([options.isAuthenticated, options.closeRequestId], ([authenticated, requestId], previous) => {
const previousRequestId = previous?.[1]
if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId))
void closeOnboardingWindow()
}, { immediate: true })
// The onboarding window is a separate Electron renderer with its own Pinia
// instance. It must initiate login itself and stay alive for the token callback.
watch(options.needsLogin, async (needsLogin) => {
if (!needsLogin || options.isAuthenticated.value)
return
await options.startLogin()
options.needsLogin.value = false
})
return { closeOnboardingWindow }
}
@@ -6,9 +6,10 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useTheme } from '@proj-airi/ui' import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue' import { computed } from 'vue'
import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa' import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa'
import { useOnboardingAuthentication } from '../composables/use-onboarding-authentication'
const authStore = useAuthStore() const authStore = useAuthStore()
const { needsLogin, isAuthenticated } = storeToRefs(authStore) const { needsLogin, isAuthenticated } = storeToRefs(authStore)
@@ -17,40 +18,13 @@ const { closeRequestId } = storeToRefs(onboardingStore)
const { isDark } = useTheme() const { isDark } = useTheme()
const startLogin = useElectronEventaInvoke(electronAuthStartLogin) const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
const closeWindow = useElectronEventaInvoke(electronOnboardingClose) const closeWindow = useElectronEventaInvoke(electronOnboardingClose)
let closing = false const { closeOnboardingWindow } = useOnboardingAuthentication({
closeRequestId,
async function closeOnboardingWindow() { closeWindow,
if (closing) isAuthenticated,
return needsLogin,
onCloseError: error => console.error('[Onboarding] Failed to close the onboarding window.', error),
closing = true startLogin,
try {
await closeWindow()
}
catch (error) {
closing = false
console.error('[Onboarding] Failed to close the onboarding window.', error)
}
}
// The shared action publishes a close request from the renderer that finishes
// authentication. This renderer remains the sole owner of the Electron close
// side effect. The auth check also handles a window mounted after the request.
watch([isAuthenticated, closeRequestId], ([authenticated, requestId], previous) => {
const previousRequestId = previous?.[1]
if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId))
void closeOnboardingWindow()
}, { immediate: true })
// The onboarding window is a separate Electron process with its own Pinia instance.
// When step-welcome sets needsLogin=true, we must invoke the IPC login from here
// since the controls-island watcher only exists in the main window.
watch(needsLogin, async (val) => {
if (val && !isAuthenticated.value) {
await startLogin()
needsLogin.value = false
await closeOnboardingWindow()
}
}) })
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white') const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')