Skip to content

Commit 0cde3bf

Browse files
[codex] default desktop chat transport to websocket (AstrBotDevs#121)
* chore(version): sync desktop version to v4.23.0-beta.1 * fix: add backend startup heartbeat liveness probe (AstrBotDevs#114) * fix: add backend startup heartbeat liveness probe * fix: tighten startup heartbeat validation * refactor: centralize startup heartbeat metadata * fix: surface heartbeat invalidation sooner * fix: harden startup heartbeat parsing * fix: warn on stop-time heartbeat failures * refactor: simplify startup heartbeat control flow * refactor: flatten readiness heartbeat helpers * refactor: clarify heartbeat helper responsibilities * docs: clarify startup heartbeat path coupling * fix: harden startup heartbeat coordination * fix: make startup heartbeat checks monotonic * fix: clean up heartbeat test and exit handling * fix: default desktop chat transport to websocket * fix: respect existing desktop transport preference * fix: harden desktop transport bootstrap * fix: centralize desktop transport contract * fix: harden desktop bridge transport injection --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 8856b21 commit 0cde3bf

7 files changed

Lines changed: 167 additions & 7 deletions

scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
33
import { readFile } from 'node:fs/promises';
44

55
const bootstrapPath = new URL('../../src-tauri/src/bridge_bootstrap.js', import.meta.url);
6+
const chatTransportContractPath = new URL(
7+
'../../src-tauri/src/desktop_bridge_chat_transport_contract.json',
8+
import.meta.url,
9+
);
610

711
test('bridge bootstrap defines astrbotAppUpdater methods', async () => {
812
const source = await readFile(bootstrapPath, 'utf8');
@@ -13,3 +17,17 @@ test('bridge bootstrap defines astrbotAppUpdater methods', async () => {
1317
assert.match(source, /checkForAppUpdate:\s*\(\)\s*=>/);
1418
assert.match(source, /installAppUpdate:\s*\(\)\s*=>/);
1519
});
20+
21+
test('bridge bootstrap transport placeholders are backed by the shared contract', async () => {
22+
const [source, rawContract] = await Promise.all([
23+
readFile(bootstrapPath, 'utf8'),
24+
readFile(chatTransportContractPath, 'utf8'),
25+
]);
26+
const contract = JSON.parse(rawContract);
27+
28+
assert.equal(typeof contract.storageKey, 'string');
29+
assert.equal(typeof contract.websocketValue, 'string');
30+
assert.match(source, /if \(typeof window === 'undefined'\) return;/);
31+
assert.match(source, /\{CHAT_TRANSPORT_MODE_STORAGE_KEY\}/);
32+
assert.match(source, /\{CHAT_TRANSPORT_MODE_WEBSOCKET\}/);
33+
});

scripts/prepare-resources/desktop-bridge-checks.test.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ test('getDesktopBridgeExpectations returns stable expectation metadata', () => {
1616
assert.ok(expectations.length > 0);
1717
assert.ok(expectations.some((expectation) => expectation.required === true));
1818
assert.ok(expectations.some((expectation) => expectation.required === false));
19+
assert.ok(expectations.some((expectation) => expectation.label === 'chat transport preference read'));
20+
assert.ok(expectations.some((expectation) => expectation.label === 'chat transport preference write'));
21+
assert.ok(
22+
expectations.some((expectation) => expectation.label === 'standalone chat transport preference read'),
23+
);
1924

2025
for (const expectation of expectations) {
2126
assert.equal(Array.isArray(expectation.filePath), true);

scripts/prepare-resources/desktop-bridge-expectations.mjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,34 @@
1+
import { readFileSync } from 'node:fs';
2+
3+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4+
5+
const chatTransportContractPath = new URL(
6+
'../../src-tauri/src/desktop_bridge_chat_transport_contract.json',
7+
import.meta.url,
8+
);
9+
const chatTransportContract = JSON.parse(readFileSync(chatTransportContractPath, 'utf8'));
10+
const CHAT_TRANSPORT_MODE_STORAGE_KEY = chatTransportContract.storageKey;
11+
const CHAT_TRANSPORT_MODE_WEBSOCKET = chatTransportContract.websocketValue;
12+
13+
if (
14+
typeof CHAT_TRANSPORT_MODE_STORAGE_KEY !== 'string' ||
15+
!CHAT_TRANSPORT_MODE_STORAGE_KEY ||
16+
typeof CHAT_TRANSPORT_MODE_WEBSOCKET !== 'string' ||
17+
!CHAT_TRANSPORT_MODE_WEBSOCKET
18+
) {
19+
throw new Error(
20+
'desktop bridge chat transport contract must define non-empty string storageKey and websocketValue fields',
21+
);
22+
}
23+
24+
const CHAT_TRANSPORT_STORAGE_KEY_PATTERN = escapeRegex(CHAT_TRANSPORT_MODE_STORAGE_KEY);
25+
const CHAT_TRANSPORT_WEBSOCKET_PATTERN = escapeRegex(CHAT_TRANSPORT_MODE_WEBSOCKET);
26+
const CHAT_TRANSPORT_READ_HINT =
27+
`Expected chat UI to read localStorage["${CHAT_TRANSPORT_MODE_STORAGE_KEY}"] ` +
28+
`and recognize "${CHAT_TRANSPORT_MODE_WEBSOCKET}".`;
29+
const CHAT_TRANSPORT_WRITE_HINT =
30+
`Expected chat UI to persist transport mode via localStorage.setItem("${CHAT_TRANSPORT_MODE_STORAGE_KEY}", ...).`;
31+
132
const DESKTOP_BRIDGE_PATTERNS = {
233
trayRestartGuard: /if\s*\(\s*!desktopBridge\s*\?\.\s*onTrayRestartBackend\s*\)\s*\{/,
334
trayRestartPromptInvoke:
@@ -10,6 +41,12 @@ const DESKTOP_BRIDGE_PATTERNS = {
1041
/const\s+runtimeInfo\s*=\s*await\s+getDesktopRuntimeInfo\s*\(\s*\)\s*;?[\s\S]*?isDesktopReleaseMode\.value\s*=\s*runtimeInfo\.isDesktopRuntime/,
1142
desktopReleaseModeFlag: /\bisDesktopReleaseMode\b/,
1243
desktopRuntimeProbeWarn: /console\.warn\([\s\S]*desktop runtime/i,
44+
chatTransportPreferenceRead: new RegExp(
45+
`localStorage\\.getItem\\(["']${CHAT_TRANSPORT_STORAGE_KEY_PATTERN}["']\\)[\\s\\S]*?["']${CHAT_TRANSPORT_WEBSOCKET_PATTERN}["']`,
46+
),
47+
chatTransportPreferenceWrite: new RegExp(
48+
`localStorage\\.setItem\\(["']${CHAT_TRANSPORT_STORAGE_KEY_PATTERN}["']\\s*,`,
49+
),
1350
};
1451

1552
const DESKTOP_BRIDGE_EXPECTATIONS = [
@@ -62,6 +99,27 @@ const DESKTOP_BRIDGE_EXPECTATIONS = [
6299
hint: 'Expected warning log when desktop runtime detection fails.',
63100
required: false,
64101
},
102+
{
103+
filePath: ['src', 'components', 'chat', 'Chat.vue'],
104+
pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceRead,
105+
label: 'chat transport preference read',
106+
hint: CHAT_TRANSPORT_READ_HINT,
107+
required: true,
108+
},
109+
{
110+
filePath: ['src', 'components', 'chat', 'Chat.vue'],
111+
pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceWrite,
112+
label: 'chat transport preference write',
113+
hint: CHAT_TRANSPORT_WRITE_HINT,
114+
required: true,
115+
},
116+
{
117+
filePath: ['src', 'components', 'chat', 'StandaloneChat.vue'],
118+
pattern: DESKTOP_BRIDGE_PATTERNS.chatTransportPreferenceRead,
119+
label: 'standalone chat transport preference read',
120+
hint: CHAT_TRANSPORT_READ_HINT,
121+
required: true,
122+
},
65123
];
66124

67125
export const getDesktopBridgeExpectations = () => [...DESKTOP_BRIDGE_EXPECTATIONS];

src-tauri/src/app_helpers.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tauri::{AppHandle, Manager};
77

88
use crate::{
99
backend, bridge, logging, runtime_paths, window, BackendState, LaunchPlan, DESKTOP_LOG_FILE,
10-
DESKTOP_LOG_MAX_BYTES, LOG_BACKUP_COUNT, TRAY_RESTART_BACKEND_EVENT,
10+
DESKTOP_LOG_MAX_BYTES, LOG_BACKUP_COUNT,
1111
};
1212

1313
static DESKTOP_LOG_WRITE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -19,7 +19,7 @@ pub(crate) fn navigate_main_window_to_backend(app_handle: &AppHandle) -> Result<
1919
}
2020

2121
pub(crate) fn inject_desktop_bridge(webview: &tauri::Webview<tauri::Wry>) {
22-
bridge::desktop::inject_desktop_bridge(webview, TRAY_RESTART_BACKEND_EVENT, append_desktop_log);
22+
bridge::desktop::inject_desktop_bridge(webview, append_desktop_log);
2323
}
2424

2525
pub(crate) fn backend_path_override() -> Option<OsString> {

src-tauri/src/bridge/desktop.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,60 @@
11
use std::sync::OnceLock;
22

3+
use serde::Deserialize;
34
use url::Url;
45

5-
use crate::bridge::origin_policy;
6+
use crate::{bridge::origin_policy, TRAY_RESTART_BACKEND_EVENT};
67

78
static DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE: &str = include_str!("../bridge_bootstrap.js");
9+
static DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT_TEMPLATE: &str =
10+
include_str!("../desktop_bridge_chat_transport_contract.json");
811
static DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: OnceLock<String> = OnceLock::new();
12+
static DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT: OnceLock<DesktopBridgeChatTransportContract> =
13+
OnceLock::new();
914

10-
fn desktop_bridge_bootstrap_script(event_name: &str) -> &'static str {
15+
#[derive(Deserialize)]
16+
#[serde(rename_all = "camelCase")]
17+
struct DesktopBridgeChatTransportContract {
18+
storage_key: String,
19+
websocket_value: String,
20+
}
21+
22+
fn desktop_bridge_chat_transport_contract() -> &'static DesktopBridgeChatTransportContract {
23+
DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT.get_or_init(|| {
24+
let contract: DesktopBridgeChatTransportContract =
25+
serde_json::from_str(DESKTOP_BRIDGE_CHAT_TRANSPORT_CONTRACT_TEMPLATE)
26+
.expect("desktop bridge chat transport contract must be valid JSON");
27+
28+
assert!(
29+
!contract.storage_key.is_empty(),
30+
"desktop bridge chat transport contract storageKey must be non-empty"
31+
);
32+
assert!(
33+
!contract.websocket_value.is_empty(),
34+
"desktop bridge chat transport contract websocketValue must be non-empty"
35+
);
36+
37+
contract
38+
})
39+
}
40+
41+
fn desktop_bridge_bootstrap_script() -> &'static str {
1142
DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT
1243
.get_or_init(|| {
13-
DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE.replace("{TRAY_RESTART_BACKEND_EVENT}", event_name)
44+
let contract = desktop_bridge_chat_transport_contract();
45+
DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE
46+
.replace("{TRAY_RESTART_BACKEND_EVENT}", TRAY_RESTART_BACKEND_EVENT)
47+
.replace("{CHAT_TRANSPORT_MODE_STORAGE_KEY}", &contract.storage_key)
48+
.replace("{CHAT_TRANSPORT_MODE_WEBSOCKET}", &contract.websocket_value)
1449
})
1550
.as_str()
1651
}
1752

18-
pub fn inject_desktop_bridge<F>(webview: &tauri::Webview<tauri::Wry>, event_name: &str, log: F)
53+
pub fn inject_desktop_bridge<F>(webview: &tauri::Webview<tauri::Wry>, log: F)
1954
where
2055
F: Fn(&str),
2156
{
22-
if let Err(error) = webview.eval(desktop_bridge_bootstrap_script(event_name)) {
57+
if let Err(error) = webview.eval(desktop_bridge_bootstrap_script()) {
2358
log(&format!("failed to inject desktop bridge script: {error}"));
2459
}
2560
}

src-tauri/src/bridge_bootstrap.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
(() => {
2+
if (typeof window === 'undefined') return;
3+
24
const existingTrayRestartState = window.__astrbotDesktopTrayRestartState;
35
if (
46
window.astrbotDesktop &&
@@ -148,6 +150,11 @@
148150

149151
const TOKEN_STORAGE_KEY = 'token';
150152
const SHELL_LOCALE_STORAGE_KEY = 'astrbot-locale';
153+
// Values are injected from the shared desktop bridge transport contract.
154+
const CHAT_TRANSPORT = Object.freeze({
155+
STORAGE_KEY: '{CHAT_TRANSPORT_MODE_STORAGE_KEY}',
156+
WEBSOCKET: '{CHAT_TRANSPORT_MODE_WEBSOCKET}',
157+
});
151158
const STORAGE_SYNC_PATCHED_FLAG = '__astrbotDesktopStorageSyncPatched';
152159
const LEGACY_TOKEN_SYNC_PATCHED_FLAG = '__astrbotDesktopTokenSyncPatched';
153160

@@ -201,6 +208,12 @@
201208
error,
202209
});
203210
};
211+
const warnDefaultChatTransportModeError = (phase, error) => {
212+
devWarn('[astrbotDesktop] failed to seed default chat transport mode', {
213+
phase,
214+
error,
215+
});
216+
};
204217

205218
const normalizeExternalHttpUrl = (rawUrl) => {
206219
if (rawUrl instanceof URL) {
@@ -697,6 +710,32 @@
697710
} catch {}
698711
};
699712

713+
const ensureDefaultChatTransportMode = () => {
714+
let storage;
715+
try {
716+
storage = window.localStorage;
717+
} catch (error) {
718+
warnDefaultChatTransportModeError('storage', error);
719+
return;
720+
}
721+
if (!storage) return;
722+
723+
let existingTransportMode;
724+
try {
725+
existingTransportMode = storage.getItem(CHAT_TRANSPORT.STORAGE_KEY);
726+
} catch (error) {
727+
warnDefaultChatTransportModeError('read', error);
728+
return;
729+
}
730+
if (existingTransportMode !== null) return;
731+
732+
try {
733+
storage.setItem(CHAT_TRANSPORT.STORAGE_KEY, CHAT_TRANSPORT.WEBSOCKET);
734+
} catch (error) {
735+
warnDefaultChatTransportModeError('write', error);
736+
}
737+
};
738+
700739
window.astrbotDesktop = {
701740
__tauriBridge: true,
702741
isDesktop: true,
@@ -740,6 +779,7 @@
740779
installNavigationBridges();
741780
void listenToTrayRestartBackendEvent();
742781
patchLocalStorageBridgeSync();
782+
ensureDefaultChatTransportMode();
743783
void syncAuthToken();
744784
void syncShellLocale();
745785
})();
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"storageKey": "chat.transportMode",
3+
"websocketValue": "websocket"
4+
}

0 commit comments

Comments
 (0)