diff --git a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs index cfbbe9b..45dc0ab 100644 --- a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs +++ b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs @@ -3,6 +3,10 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; const bootstrapPath = new URL('../../src-tauri/src/bridge_bootstrap.js', import.meta.url); +const chatTransportContractPath = new URL( + '../../src-tauri/src/desktop_bridge_chat_transport_contract.json', + import.meta.url, +); test('bridge bootstrap defines astrbotAppUpdater methods', async () => { const source = await readFile(bootstrapPath, 'utf8'); @@ -13,3 +17,17 @@ test('bridge bootstrap defines astrbotAppUpdater methods', async () => { assert.match(source, /checkForAppUpdate:\s*\(\)\s*=>/); assert.match(source, /installAppUpdate:\s*\(\)\s*=>/); }); + +test('bridge bootstrap transport placeholders are backed by the shared contract', async () => { + const [source, rawContract] = await Promise.all([ + readFile(bootstrapPath, 'utf8'), + readFile(chatTransportContractPath, 'utf8'), + ]); + const contract = JSON.parse(rawContract); + + assert.equal(typeof contract.storageKey, 'string'); + assert.equal(typeof contract.websocketValue, 'string'); + assert.match(source, /if \(typeof window === 'undefined'\) return;/); + assert.match(source, /\{CHAT_TRANSPORT_MODE_STORAGE_KEY\}/); + assert.match(source, /\{CHAT_TRANSPORT_MODE_WEBSOCKET\}/); +}); diff --git a/scripts/prepare-resources/desktop-bridge-checks.test.mjs b/scripts/prepare-resources/desktop-bridge-checks.test.mjs index 3827c94..42aea12 100644 --- a/scripts/prepare-resources/desktop-bridge-checks.test.mjs +++ b/scripts/prepare-resources/desktop-bridge-checks.test.mjs @@ -16,6 +16,11 @@ test('getDesktopBridgeExpectations returns stable expectation metadata', () => { assert.ok(expectations.length > 0); assert.ok(expectations.some((expectation) => expectation.required === true)); assert.ok(expectations.some((expectation) => expectation.required === false)); + assert.ok(expectations.some((expectation) => expectation.label === 'chat transport preference read')); + assert.ok(expectations.some((expectation) => expectation.label === 'chat transport preference write')); + assert.ok( + expectations.some((expectation) => expectation.label === 'standalone chat transport preference read'), + ); for (const expectation of expectations) { assert.equal(Array.isArray(expectation.filePath), true); diff --git a/scripts/prepare-resources/desktop-bridge-expectations.mjs b/scripts/prepare-resources/desktop-bridge-expectations.mjs index 19fcf76..e62ab27 100644 --- a/scripts/prepare-resources/desktop-bridge-expectations.mjs +++ b/scripts/prepare-resources/desktop-bridge-expectations.mjs @@ -1,3 +1,34 @@ +import { readFileSync } from 'node:fs'; + +const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const chatTransportContractPath = new URL( + '../../src-tauri/src/desktop_bridge_chat_transport_contract.json', + import.meta.url, +); +const chatTransportContract = JSON.parse(readFileSync(chatTransportContractPath, 'utf8')); +const CHAT_TRANSPORT_MODE_STORAGE_KEY = chatTransportContract.storageKey; +const CHAT_TRANSPORT_MODE_WEBSOCKET = chatTransportContract.websocketValue; + +if ( + typeof CHAT_TRANSPORT_MODE_STORAGE_KEY !== 'string' || + !CHAT_TRANSPORT_MODE_STORAGE_KEY || + typeof CHAT_TRANSPORT_MODE_WEBSOCKET !== 'string' || + !CHAT_TRANSPORT_MODE_WEBSOCKET +) { + throw new Error( + 'desktop bridge chat transport contract must define non-empty string storageKey and websocketValue fields', + ); +} + +const CHAT_TRANSPORT_STORAGE_KEY_PATTERN = escapeRegex(CHAT_TRANSPORT_MODE_STORAGE_KEY); +const CHAT_TRANSPORT_WEBSOCKET_PATTERN = escapeRegex(CHAT_TRANSPORT_MODE_WEBSOCKET); +const CHAT_TRANSPORT_READ_HINT = + `Expected chat UI to read localStorage["${CHAT_TRANSPORT_MODE_STORAGE_KEY}"] ` + + `and recognize "${CHAT_TRANSPORT_MODE_WEBSOCKET}".`; +const CHAT_TRANSPORT_WRITE_HINT = + `Expected chat UI to persist transport mode via localStorage.setItem("${CHAT_TRANSPORT_MODE_STORAGE_KEY}", ...).`; + const DESKTOP_BRIDGE_PATTERNS = { trayRestartGuard: /if\s*\(\s*!desktopBridge\s*\?\.\s*onTrayRestartBackend\s*\)\s*\{/, trayRestartPromptInvoke: @@ -10,6 +41,12 @@ const DESKTOP_BRIDGE_PATTERNS = { /const\s+runtimeInfo\s*=\s*await\s+getDesktopRuntimeInfo\s*\(\s*\)\s*;?[\s\S]*?isDesktopReleaseMode\.value\s*=\s*runtimeInfo\.isDesktopRuntime/, desktopReleaseModeFlag: /\bisDesktopReleaseMode\b/, desktopRuntimeProbeWarn: /console\.warn\([\s\S]*desktop runtime/i, + chatTransportPreferenceRead: new RegExp( + `localStorage\\.getItem\\(["']${CHAT_TRANSPORT_STORAGE_KEY_PATTERN}["']\\)[\\s\\S]*?["']${CHAT_TRANSPORT_WEBSOCKET_PATTERN}["']`, + ), + chatTransportPreferenceWrite: new RegExp( + `localStorage\\.setItem\\(["']${CHAT_TRANSPORT_STORAGE_KEY_PATTERN}["']\\s*,`, + ), }; const DESKTOP_BRIDGE_EXPECTATIONS = [ @@ -62,6 +99,27 @@ const DESKTOP_BRIDGE_EXPECTATIONS = [ hint: 'Expected warning log when desktop runtime detection fails.', required: false, }, + { + filePath: ['src', 'components', 'chat', 'Chat.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceRead, + label: 'chat transport preference read', + hint: CHAT_TRANSPORT_READ_HINT, + required: true, + }, + { + filePath: ['src', 'components', 'chat', 'Chat.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceWrite, + label: 'chat transport preference write', + hint: CHAT_TRANSPORT_WRITE_HINT, + required: true, + }, + { + filePath: ['src', 'components', 'chat', 'StandaloneChat.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceRead, + label: 'standalone chat transport preference read', + hint: CHAT_TRANSPORT_READ_HINT, + required: true, + }, ]; export const getDesktopBridgeExpectations = () => [...DESKTOP_BRIDGE_EXPECTATIONS]; diff --git a/src-tauri/src/app_helpers.rs b/src-tauri/src/app_helpers.rs index 11e3730..99ea83c 100644 --- a/src-tauri/src/app_helpers.rs +++ b/src-tauri/src/app_helpers.rs @@ -7,7 +7,7 @@ use tauri::{AppHandle, Manager}; use crate::{ backend, bridge, logging, runtime_paths, window, BackendState, LaunchPlan, DESKTOP_LOG_FILE, - DESKTOP_LOG_MAX_BYTES, LOG_BACKUP_COUNT, TRAY_RESTART_BACKEND_EVENT, + DESKTOP_LOG_MAX_BYTES, LOG_BACKUP_COUNT, }; static DESKTOP_LOG_WRITE_LOCK: OnceLock> = OnceLock::new(); @@ -19,7 +19,7 @@ pub(crate) fn navigate_main_window_to_backend(app_handle: &AppHandle) -> Result< } pub(crate) fn inject_desktop_bridge(webview: &tauri::Webview) { - bridge::desktop::inject_desktop_bridge(webview, TRAY_RESTART_BACKEND_EVENT, append_desktop_log); + bridge::desktop::inject_desktop_bridge(webview, append_desktop_log); } pub(crate) fn backend_path_override() -> Option { diff --git a/src-tauri/src/bridge/desktop.rs b/src-tauri/src/bridge/desktop.rs index 0ddedf0..7493c3f 100644 --- a/src-tauri/src/bridge/desktop.rs +++ b/src-tauri/src/bridge/desktop.rs @@ -1,25 +1,60 @@ use std::sync::OnceLock; +use serde::Deserialize; use url::Url; -use crate::bridge::origin_policy; +use crate::{bridge::origin_policy, TRAY_RESTART_BACKEND_EVENT}; static DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE: &str = include_str!("../bridge_bootstrap.js"); +static DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT_TEMPLATE: &str = + include_str!("../desktop_bridge_chat_transport_contract.json"); static DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: OnceLock = OnceLock::new(); +static DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT: OnceLock = + OnceLock::new(); -fn desktop_bridge_bootstrap_script(event_name: &str) -> &'static str { +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopBridgeChatTransportContract { + storage_key: String, + websocket_value: String, +} + +fn desktop_bridge_chat_transport_contract() -> &'static DesktopBridgeChatTransportContract { + DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT.get_or_init(|| { + let contract: DesktopBridgeChatTransportContract = + serde_json::from_str(DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT_TEMPLATE) + .expect("desktop bridge chat transport contract must be valid JSON"); + + assert!( + !contract.storage_key.is_empty(), + "desktop bridge chat transport contract storageKey must be non-empty" + ); + assert!( + !contract.websocket_value.is_empty(), + "desktop bridge chat transport contract websocketValue must be non-empty" + ); + + contract + }) +} + +fn desktop_bridge_bootstrap_script() -> &'static str { DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT .get_or_init(|| { - DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE.replace("{TRAY_RESTART_BACKEND_EVENT}", event_name) + let contract = desktop_bridge_chat_transport_contract(); + DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE + .replace("{TRAY_RESTART_BACKEND_EVENT}", TRAY_RESTART_BACKEND_EVENT) + .replace("{CHAT_TRANSPORT_MODE_STORAGE_KEY}", &contract.storage_key) + .replace("{CHAT_TRANSPORT_MODE_WEBSOCKET}", &contract.websocket_value) }) .as_str() } -pub fn inject_desktop_bridge(webview: &tauri::Webview, event_name: &str, log: F) +pub fn inject_desktop_bridge(webview: &tauri::Webview, log: F) where F: Fn(&str), { - if let Err(error) = webview.eval(desktop_bridge_bootstrap_script(event_name)) { + if let Err(error) = webview.eval(desktop_bridge_bootstrap_script()) { log(&format!("failed to inject desktop bridge script: {error}")); } } diff --git a/src-tauri/src/bridge_bootstrap.js b/src-tauri/src/bridge_bootstrap.js index 7a81cd1..fe1cdcb 100644 --- a/src-tauri/src/bridge_bootstrap.js +++ b/src-tauri/src/bridge_bootstrap.js @@ -1,4 +1,6 @@ (() => { + if (typeof window === 'undefined') return; + const existingTrayRestartState = window.__astrbotDesktopTrayRestartState; if ( window.astrbotDesktop && @@ -148,6 +150,11 @@ const TOKEN_STORAGE_KEY = 'token'; const SHELL_LOCALE_STORAGE_KEY = 'astrbot-locale'; + // Values are injected from the shared desktop bridge transport contract. + const CHAT_TRANSPORT = Object.freeze({ + STORAGE_KEY: '{CHAT_TRANSPORT_MODE_STORAGE_KEY}', + WEBSOCKET: '{CHAT_TRANSPORT_MODE_WEBSOCKET}', + }); const STORAGE_SYNC_PATCHED_FLAG = '__astrbotDesktopStorageSyncPatched'; const LEGACY_TOKEN_SYNC_PATCHED_FLAG = '__astrbotDesktopTokenSyncPatched'; @@ -201,6 +208,12 @@ error, }); }; + const warnDefaultChatTransportModeError = (phase, error) => { + devWarn('[astrbotDesktop] failed to seed default chat transport mode', { + phase, + error, + }); + }; const normalizeExternalHttpUrl = (rawUrl) => { if (rawUrl instanceof URL) { @@ -697,6 +710,32 @@ } catch {} }; + const ensureDefaultChatTransportMode = () => { + let storage; + try { + storage = window.localStorage; + } catch (error) { + warnDefaultChatTransportModeError('storage', error); + return; + } + if (!storage) return; + + let existingTransportMode; + try { + existingTransportMode = storage.getItem(CHAT_TRANSPORT.STORAGE_KEY); + } catch (error) { + warnDefaultChatTransportModeError('read', error); + return; + } + if (existingTransportMode !== null) return; + + try { + storage.setItem(CHAT_TRANSPORT.STORAGE_KEY, CHAT_TRANSPORT.WEBSOCKET); + } catch (error) { + warnDefaultChatTransportModeError('write', error); + } + }; + window.astrbotDesktop = { __tauriBridge: true, isDesktop: true, @@ -740,6 +779,7 @@ installNavigationBridges(); void listenToTrayRestartBackendEvent(); patchLocalStorageBridgeSync(); + ensureDefaultChatTransportMode(); void syncAuthToken(); void syncShellLocale(); })(); diff --git a/src-tauri/src/desktop_bridge_chat_transport_contract.json b/src-tauri/src/desktop_bridge_chat_transport_contract.json new file mode 100644 index 0000000..77f4a84 --- /dev/null +++ b/src-tauri/src/desktop_bridge_chat_transport_contract.json @@ -0,0 +1,4 @@ +{ + "storageKey": "chat.transportMode", + "websocketValue": "websocket" +}