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
55 changes: 55 additions & 0 deletions scripts/prepare-resources/desktop-bridge-expectations.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
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 DESKTOP_BRIDGE_PATTERNS = {
trayRestartGuard: /if\s*\(\s*!desktopBridge\s*\?\.\s*onTrayRestartBackend\s*\)\s*\{/,
trayRestartPromptInvoke:
Expand All @@ -10,6 +36,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 +94,29 @@ 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:
'Expected chat UI to read localStorage["chat.transportMode"] and recognize "websocket".',
required: true,
},
{
filePath: ['src', 'components', 'chat', 'Chat.vue'],
pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceWrite,
label: 'chat transport preference write',
hint: 'Expected chat UI to persist transport mode via localStorage.setItem("chat.transportMode", ...).',
required: true,
},
{
filePath: ['src', 'components', 'chat', 'StandaloneChat.vue'],
pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceRead,
label: 'standalone chat transport preference read',
hint:
'Expected standalone chat UI to read localStorage["chat.transportMode"] and recognize "websocket".',
required: true,
},
];

export const getDesktopBridgeExpectations = () => [...DESKTOP_BRIDGE_EXPECTATIONS];
Expand Down
25 changes: 24 additions & 1 deletion src-tauri/src/bridge/desktop.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
use std::sync::OnceLock;

use serde::Deserialize;
use url::Url;

use crate::bridge::origin_policy;

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();

#[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(|| {
serde_json::from_str(DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT_TEMPLATE)
.expect("desktop bridge chat transport contract must be valid JSON")
})
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

fn desktop_bridge_bootstrap_script(event_name: &str) -> &'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}", event_name)
.replace("{CHAT_TRANSPORT_MODE_STORAGE_KEY}", &contract.storage_key)
.replace("{CHAT_TRANSPORT_MODE_WEBSOCKET}", &contract.websocket_value)
})
.as_str()
}
Expand Down
34 changes: 34 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,26 @@
} catch {}
};

const ensureDefaultChatTransportMode = () => {
const storage = window.localStorage;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
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 +773,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"
}