Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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\}/);
});
5 changes: 5 additions & 0 deletions scripts/prepare-resources/desktop-bridge-checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions scripts/prepare-resources/desktop-bridge-expectations.mjs
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 = [
Expand Down Expand Up @@ -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];
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/app_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<()>> = OnceLock::new();
Expand All @@ -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<tauri::Wry>) {
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<OsString> {
Expand Down
45 changes: 40 additions & 5 deletions src-tauri/src/bridge/desktop.rs
Original file line number Diff line number Diff line change
@@ -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<String> = OnceLock::new();
static DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT: OnceLock<DesktopBridgeChatTransportContract> =
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
})
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

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<F>(webview: &tauri::Webview<tauri::Wry>, event_name: &str, log: F)
pub fn inject_desktop_bridge<F>(webview: &tauri::Webview<tauri::Wry>, 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}"));
}
}
Expand Down
40 changes: 40 additions & 0 deletions src-tauri/src/bridge_bootstrap.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
(() => {
if (typeof window === 'undefined') return;

const existingTrayRestartState = window.__astrbotDesktopTrayRestartState;
if (
window.astrbotDesktop &&
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -740,6 +779,7 @@
installNavigationBridges();
void listenToTrayRestartBackendEvent();
patchLocalStorageBridgeSync();
ensureDefaultChatTransportMode();
void syncAuthToken();
void syncShellLocale();
})();
4 changes: 4 additions & 0 deletions src-tauri/src/desktop_bridge_chat_transport_contract.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"storageKey": "chat.transportMode",
"websocketValue": "websocket"
}