diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts new file mode 100644 index 000000000..3b59aa452 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.test.ts @@ -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>().mockResolvedValue() + const closeWindow = vi.fn<() => Promise>().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>().mockResolvedValue() + const scope = effectScope() + + scope.run(() => useOnboardingAuthentication({ + closeRequestId, + closeWindow, + isAuthenticated: shallowRef(false), + needsLogin: shallowRef(false), + onCloseError: vi.fn(), + startLogin: vi.fn<() => Promise>().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>() + .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>().mockResolvedValue(), + })) + + await controls!.closeOnboardingWindow() + await controls!.closeOnboardingWindow() + + expect(closeWindow).toHaveBeenCalledTimes(2) + expect(onCloseError).toHaveBeenCalledTimes(1) + scope.stop() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts new file mode 100644 index 000000000..1464a8433 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-onboarding-authentication.ts @@ -0,0 +1,63 @@ +import type { Ref } from 'vue' + +import { watch } from 'vue' + +interface UseOnboardingAuthenticationOptions { + closeRequestId: Readonly> + closeWindow: () => Promise + isAuthenticated: Readonly> + needsLogin: Ref + onCloseError: (error: unknown) => void + startLogin: () => Promise +} + +interface OnboardingAuthenticationControls { + closeOnboardingWindow: () => Promise +} + +/** + * 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 { + 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 } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue index f8a4fab4e..87db88be6 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue @@ -6,9 +6,10 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' import { useTheme } from '@proj-airi/ui' import { storeToRefs } from 'pinia' -import { computed, watch } from 'vue' +import { computed } from 'vue' import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa' +import { useOnboardingAuthentication } from '../composables/use-onboarding-authentication' const authStore = useAuthStore() const { needsLogin, isAuthenticated } = storeToRefs(authStore) @@ -17,40 +18,13 @@ const { closeRequestId } = storeToRefs(onboardingStore) const { isDark } = useTheme() const startLogin = useElectronEventaInvoke(electronAuthStartLogin) const closeWindow = useElectronEventaInvoke(electronOnboardingClose) -let closing = false - -async function closeOnboardingWindow() { - if (closing) - return - - closing = true - 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 { closeOnboardingWindow } = useOnboardingAuthentication({ + closeRequestId, + closeWindow, + isAuthenticated, + needsLogin, + onCloseError: error => console.error('[Onboarding] Failed to close the onboarding window.', error), + startLogin, }) const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')