diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 96d8b5c54..dcb003db5 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -196,20 +196,6 @@ export function AuthenticatedClient({ setServerSelection(next); setLoginStatus(`已选择 ${preset} 服务器`); } - // 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。 - useEffect(() => { - if (!clientServerSelectionEnabled) return; - let disposed = false; - void hydrateClientServerSelection().then((selection) => { - if (disposed) return; - setServerSelection(selection); - setCustomServerUrl(selection.customBaseUrl); - }); - return () => { - disposed = true; - }; - }, []); - useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -238,8 +224,14 @@ export function AuthenticatedClient({ setAuthCheckError(''); setAuthCheckStage('refresh'); try { + // 服务器选择先于认证读取落盘值:Rust 按 `expectedApiBaseUrl` 判定凭据归属,竞态下用 + // 渠道默认值会被当成「凭据属于另一个 origin」而误报未登录,自定义服务器每次启动都要重登。 + const selection = await hydrateClientServerSelection(); + if (!isActiveRun()) return; + setServerSelection(selection); + setCustomServerUrl(selection.customBaseUrl); const state = await withAuthCheckTimeout( - readClientAuthState(getClientServerBaseUrl()), + readClientAuthState(getClientServerBaseUrl(selection)), AUTH_CHECK_REQUEST_TIMEOUT_MS, '检查登录状态超时,请检查服务器地址和网络后重试', ); diff --git a/apps/ai-game-creator-shell/tests/clientServerSelectionHydration.test.tsx b/apps/ai-game-creator-shell/tests/clientServerSelectionHydration.test.tsx new file mode 100644 index 000000000..9edc899f0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientServerSelectionHydration.test.tsx @@ -0,0 +1,68 @@ +/** @vitest-environment jsdom */ +/** + * 启动恢复登录态必须使用已落盘的服务器选择。 + * + * 症状:本地 debug 选了 custom 服务器并登录成功后,重启客户端又被要求重新输密码。 + * 凭据并没有丢:Rust 侧按 `expectedApiBaseUrl` 与凭据归属 origin 比对,不一致就只报告 + * 未登录、不跨 origin 续期。因此渲染层必须在发起 `read_client_auth_state` 之前先读完 + * `read_workspace_preferences`,否则预期地址会停在渠道默认值,自定义服务器的会话永远恢复不了。 + */ +import { cleanup, render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(async () => undefined), +})); + +import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; +import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; + +const CUSTOM_SERVER = 'https://custom.example.com'; + +describe('启动时用已落盘的服务器选择恢复登录态', () => { + afterEach(() => { + cleanup(); + resetPlatformSessionForTests(); + resetTauriRuntime(); + }); + + it('自定义服务器的会话恢复带自定义 origin,而不是渠道默认地址', async () => { + const authCalls: Array<{ + command: string; + args?: Record; + }> = []; + installTauriRuntime({ + core: { + invoke: async (command, args) => { + authCalls.push({ command, args }); + if (command === 'read_workspace_preferences') { + return { + clientServerSelection: { + preset: 'custom', + customBaseUrl: CUSTOM_SERVER, + }, + }; + } + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + return null; + }, + }, + }); + + render( + createElement(AuthenticatedClient, { + children: () => createElement('main', { 'aria-label': '已登录' }), + }), + ); + + await screen.findByRole('main', { name: '登录' }); + const authCall = authCalls.find( + (call) => call.command === 'read_client_auth_state', + ); + expect(authCall?.args).toEqual({ expectedApiBaseUrl: CUSTOM_SERVER }); + }); +});