From a49015f348321ac90b13ebef67e5aa229d0a69e3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 13 Aug 2026 18:01:48 +0800 Subject: [PATCH] fix: prevent blank screen after desktop auth --- ...bridge-bootstrap-updater-contract.test.mjs | 91 +++++++++++++++---- src-tauri/src/bridge_bootstrap.js | 29 +++++- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs index 9d92f228..85b44a6f 100644 --- a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs +++ b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs @@ -11,11 +11,8 @@ const chatTransportContractPath = new URL( const flushAsyncWork = () => new Promise((resolve) => setImmediate(resolve)); -function runBootstrap(source, authResults) { - const values = new Map(); - const invocations = []; - const intervals = []; - const localStorage = { +function createStorage(values) { + return { getItem(key) { return values.has(key) ? values.get(key) : null; }, @@ -29,12 +26,28 @@ function runBootstrap(source, authResults) { values.clear(); }, }; +} + +function runBootstrap(source, authResults, sharedState = {}) { + const localValues = sharedState.localValues || new Map(); + const sessionValues = sharedState.sessionValues || new Map(); + const navigation = sharedState.navigation || { reloads: 0 }; + sharedState.localValues = localValues; + sharedState.sessionValues = sessionValues; + sharedState.navigation = navigation; + const invocations = []; + const intervals = []; + const localStorage = createStorage(localValues); + const sessionStorage = createStorage(sessionValues); const location = { href: 'http://127.0.0.1:6185/#/auth/login', origin: 'http://127.0.0.1:6185', hash: '#/auth/login', assign() {}, replace() {}, + reload() { + navigation.reloads += 1; + }, toString() { return this.href; }, @@ -60,6 +73,7 @@ function runBootstrap(source, authResults) { }, }, localStorage, + sessionStorage, location, open: () => null, setInterval(handler, delay) { @@ -69,7 +83,17 @@ function runBootstrap(source, authResults) { }; class MockElement {} class MockAnchor extends MockElement {} - const document = { addEventListener() {} }; + const document = { + addEventListener() {}, + getElementById(id) { + if (id !== 'app') return null; + return { + hasChildNodes() { + return sharedState.appMounted === true; + }, + }; + }, + }; const quietConsole = { warn() {}, error() {}, log() {} }; runInNewContext( @@ -88,7 +112,14 @@ function runBootstrap(source, authResults) { }, ); - return { window, localStorage, invocations, intervals }; + return { + window, + localStorage, + sessionStorage, + invocations, + intervals, + navigation, + }; } test('bridge bootstrap defines astrbotAppUpdater methods', async () => { @@ -115,27 +146,47 @@ test('bridge bootstrap owns desktop passwordless authentication lifecycle', asyn ); }); -test('bridge bootstrap automatically authenticates and reacquires a removed token', async () => { +test('bridge bootstrap reloads once after initial auth and reacquires a removed token', async () => { const source = await readFile(bootstrapPath, 'utf8'); - const runtime = runBootstrap(source, [ - { ok: true, token: 'first-jwt', username: 'astrbot' }, - { ok: true, token: 'second-jwt', username: 'astrbot' }, - ]); + const sharedState = {}; + const firstRuntime = runBootstrap( + source, + [{ ok: true, token: 'first-jwt', username: 'astrbot' }], + sharedState, + ); await flushAsyncWork(); await flushAsyncWork(); - assert.equal(runtime.localStorage.getItem('token'), 'first-jwt'); - assert.equal(runtime.localStorage.getItem('user'), 'astrbot'); - assert.equal(runtime.window.location.hash, '/welcome'); - assert.equal(runtime.intervals.length, 1); - assert.equal(runtime.intervals[0].delay, 6 * 60 * 60 * 1000); + assert.equal(firstRuntime.localStorage.getItem('token'), 'first-jwt'); + assert.equal(firstRuntime.localStorage.getItem('user'), 'astrbot'); + assert.equal(firstRuntime.navigation.reloads, 1); + assert.equal(firstRuntime.window.location.hash, '#/auth/login'); + assert.equal(firstRuntime.intervals.length, 1); + assert.equal(firstRuntime.intervals[0].delay, 6 * 60 * 60 * 1000); + + const reloadedRuntime = runBootstrap( + source, + [ + { ok: true, token: 'second-jwt', username: 'astrbot' }, + { ok: true, token: 'third-jwt', username: 'astrbot' }, + ], + sharedState, + ); + await flushAsyncWork(); + await flushAsyncWork(); + assert.equal(reloadedRuntime.localStorage.getItem('token'), 'second-jwt'); + assert.equal(reloadedRuntime.navigation.reloads, 1); + assert.equal(reloadedRuntime.window.location.hash, '#/auth/login'); - runtime.localStorage.removeItem('token'); + sharedState.appMounted = true; + reloadedRuntime.localStorage.removeItem('token'); await flushAsyncWork(); await flushAsyncWork(); - assert.equal(runtime.localStorage.getItem('token'), 'second-jwt'); + assert.equal(reloadedRuntime.localStorage.getItem('token'), 'third-jwt'); + assert.equal(reloadedRuntime.navigation.reloads, 1); + assert.equal(reloadedRuntime.window.location.hash, '/welcome'); assert.ok( - runtime.invocations.filter( + reloadedRuntime.invocations.filter( ({ command }) => command === 'desktop_bridge_get_auth_token', ).length >= 2, ); diff --git a/src-tauri/src/bridge_bootstrap.js b/src-tauri/src/bridge_bootstrap.js index e202e8ce..65a581e2 100644 --- a/src-tauri/src/bridge_bootstrap.js +++ b/src-tauri/src/bridge_bootstrap.js @@ -152,6 +152,8 @@ const TOKEN_STORAGE_KEY = 'token'; const USER_STORAGE_KEY = 'user'; const SHELL_LOCALE_STORAGE_KEY = 'astrbot-locale'; + const DESKTOP_AUTH_BOOTSTRAP_RELOAD_KEY = + 'astrbot:desktop-auth-bootstrap-reloaded:v1'; const DESKTOP_AUTH_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // Values are injected from the shared desktop bridge transport contract. const CHAT_TRANSPORT = Object.freeze({ @@ -183,6 +185,20 @@ locale: value, }); + const shouldReloadAfterDesktopAuthBootstrap = () => { + try { + const storage = window.sessionStorage; + if (!storage) return false; + if (storage.getItem(DESKTOP_AUTH_BOOTSTRAP_RELOAD_KEY) === 'true') { + return false; + } + storage.setItem(DESKTOP_AUTH_BOOTSTRAP_RELOAD_KEY, 'true'); + return true; + } catch { + return false; + } + }; + let desktopAuthRefreshPromise = null; const refreshDesktopAuthSession = () => { if (desktopAuthRefreshPromise) { @@ -213,7 +229,18 @@ } await syncAuthToken(token); - if (/^#\/auth\/(?:login|setup)(?:[/?]|$)/.test(window.location.hash || '')) { + // The dashboard waits for its initial router navigation before mounting Vue. + // Changing the auth hash while that navigation is still in flight can leave + // #app empty in WKWebView. Reload once after persisting the desktop session + // so the dashboard starts with a stable token instead. + if (shouldReloadAfterDesktopAuthBootstrap()) { + window.location.reload(); + return result; + } + if ( + document.getElementById('app')?.hasChildNodes() && + /^#\/auth\/(?:login|setup)(?:[/?]|$)/.test(window.location.hash || '') + ) { window.location.hash = '/welcome'; } return result;