Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
| 变量 | 用途 | 默认值/行为 |
| --- | --- | --- |
| `ASTRBOT_DESKTOP_CLIENT` | 标记桌面客户端环境 | 打包态启动后端时写入 `1` |
| `ASTRBOT_DESKTOP_MANAGED` | 标记后端由桌面进程托管 | 桌面端启动后端时写入 `1`(包括开发态与打包态);桌面免密会话必须同时具备此标记 |
| `ASTRBOT_DESKTOP_SESSION_SECRET` | 桌面原生层与托管后端之间的内部会话密钥 | 每次桌面进程启动时随机生成 256 位值,仅通过子进程环境和本机回环请求传递,不落盘且不应由用户设置 |
| `ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH` | 桌面端写给后端启动器的 heartbeat 文件路径 | 打包态默认写到 `ASTRBOT_ROOT/data/backend-startup-heartbeat.json` |

### 局域网访问 WebUI
Expand Down
131 changes: 131 additions & 0 deletions scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { runInNewContext } from 'node:vm';

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

const flushAsyncWork = () => new Promise((resolve) => setImmediate(resolve));

function runBootstrap(source, authResults) {
const values = new Map();
const invocations = [];
const intervals = [];
const localStorage = {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
},
removeItem(key) {
values.delete(String(key));
},
clear() {
values.clear();
},
};
const location = {
href: 'http://127.0.0.1:6185/#/auth/login',
origin: 'http://127.0.0.1:6185',
hash: '#/auth/login',
assign() {},
replace() {},
toString() {
return this.href;
},
};
const window = {
__TAURI_INTERNALS__: {
async invoke(command, payload = {}) {
invocations.push({ command, payload });
if (command === 'desktop_bridge_get_auth_token') {
return authResults.shift() ?? {
ok: false,
reason: 'No desktop auth response.',
};
}
if (command === 'plugin:event|listen') {
throw new Error('event bridge is not configured in this test');
}
return { ok: true, reason: null };
},
},
localStorage,
location,
open: () => null,
setInterval(handler, delay) {
intervals.push({ handler, delay });
return intervals.length;
},
};
class MockElement {}
class MockAnchor extends MockElement {}
const document = { addEventListener() {} };
const quietConsole = { warn() {}, error() {}, log() {} };

runInNewContext(
source
.replace('{TRAY_RESTART_BACKEND_EVENT}', 'astrbot://tray-restart-backend')
.replace('{CHAT_TRANSPORT_MODE_STORAGE_KEY}', 'chat_transport_mode')
.replace('{CHAT_TRANSPORT_MODE_WEBSOCKET}', 'websocket'),
{
window,
document,
URL,
Element: MockElement,
HTMLAnchorElement: MockAnchor,
console: quietConsole,
process: { env: { NODE_ENV: 'production' } },
},
);

return { window, localStorage, invocations, intervals };
}

test('bridge bootstrap defines astrbotAppUpdater methods', async () => {
const source = await readFile(bootstrapPath, 'utf8');

Expand All @@ -18,6 +97,58 @@ test('bridge bootstrap defines astrbotAppUpdater methods', async () => {
assert.match(source, /installAppUpdate:\s*\(\)\s*=>/);
});

test('bridge bootstrap owns desktop passwordless authentication lifecycle', async () => {
const source = await readFile(bootstrapPath, 'utf8');

assert.match(source, /GET_AUTH_TOKEN:\s*'desktop_bridge_get_auth_token'/);
assert.match(source, /refreshAuthSession:\s*refreshDesktopAuthSession/);
assert.match(source, /localStorage\?\.setItem\(TOKEN_STORAGE_KEY, token\)/);
assert.match(source, /localStorage\?\.setItem\(USER_STORAGE_KEY, username\)/);
assert.match(source, /void refreshDesktopAuthSession\(\);/);
assert.match(
source,
/window\.setInterval\(refreshDesktopAuthSession, DESKTOP_AUTH_REFRESH_INTERVAL_MS\)/,
);
});

test('bridge bootstrap automatically authenticates 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' },
]);

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

runtime.localStorage.removeItem('token');
await flushAsyncWork();
await flushAsyncWork();
assert.equal(runtime.localStorage.getItem('token'), 'second-jwt');
assert.ok(
runtime.invocations.filter(
({ command }) => command === 'desktop_bridge_get_auth_token',
).length >= 2,
);
});

test('bridge bootstrap preserves password login fallback for older backends', async () => {
const source = await readFile(bootstrapPath, 'utf8');
const runtime = runBootstrap(source, [
{ ok: false, reason: 'Desktop passwordless authentication is unavailable.' },
]);

await flushAsyncWork();
await flushAsyncWork();
assert.equal(runtime.localStorage.getItem('token'), null);
assert.equal(runtime.window.location.hash, '#/auth/login');
});

test('bridge bootstrap transport placeholders are backed by the shared contract', async () => {
const [source, rawContract] = await Promise.all([
readFile(bootstrapPath, 'utf8'),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tauri-build = { version = "2.0", features = [] }

[dependencies]
chrono = { version = "0.4", features = ["clock"] }
getrandom = "0.3"
home = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/app_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub(crate) const ASTRBOT_ROOT_ENV: &str = "ASTRBOT_ROOT";
pub(crate) const BACKEND_TIMEOUT_ENV: &str = "ASTRBOT_BACKEND_TIMEOUT_MS";
pub(crate) const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS: u64 = 15 * 60 * 1000;
pub(crate) const GRACEFUL_RESTART_REQUEST_TIMEOUT_MS: u64 = 2_500;
pub(crate) const DESKTOP_AUTH_REQUEST_TIMEOUT_MS: u64 = 2_500;
pub(crate) const GRACEFUL_RESTART_START_TIME_TIMEOUT_MS: u64 = 1_800;
pub(crate) const GRACEFUL_RESTART_POLL_INTERVAL_MS: u64 = 350;
pub(crate) const GRACEFUL_STOP_TIMEOUT_MS: u64 = 10_000;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/app_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ pub(crate) fn run() {
.invoke_handler(tauri::generate_handler![
crate::bridge::commands::desktop_bridge_is_desktop_runtime,
crate::bridge::commands::desktop_bridge_get_backend_state,
crate::bridge::commands::desktop_bridge_get_auth_token,
crate::bridge::commands::desktop_bridge_set_auth_token,
crate::bridge::commands::desktop_bridge_set_shell_locale,
crate::bridge::commands::desktop_bridge_get_app_update_channel,
Expand Down
14 changes: 13 additions & 1 deletion src-tauri/src/app_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{
};
use tauri::menu::{CheckMenuItem, MenuItem};

use crate::{backend, exit_state, DEFAULT_BACKEND_URL};
use crate::{backend, desktop_auth::DesktopSessionSecret, exit_state, DEFAULT_BACKEND_URL};

#[derive(Clone)]
pub(crate) struct TrayMenuState {
Expand Down Expand Up @@ -45,6 +45,7 @@ pub(crate) struct BackendState {
pub(crate) child: Mutex<Option<Child>>,
pub(crate) backend_url: String,
pub(crate) restart_auth_token: Mutex<Option<String>>,
pub(crate) desktop_session_secret: DesktopSessionSecret,
pub(crate) startup_loading_mode: Mutex<Option<&'static str>>,
pub(crate) log_rotator_stop: Mutex<Option<Arc<AtomicBool>>>,
pub(crate) exit_state: Mutex<exit_state::ExitStateMachine>,
Expand All @@ -67,6 +68,15 @@ pub(crate) struct BackendBridgeResult {
pub(crate) reason: Option<String>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesktopAuthBridgeResult {
pub(crate) ok: bool,
pub(crate) token: Option<String>,
pub(crate) username: Option<String>,
pub(crate) reason: Option<String>,
}

pub(crate) struct AtomicFlagGuard<'a> {
flag: &'a AtomicBool,
}
Expand Down Expand Up @@ -100,6 +110,8 @@ impl Default for BackendState {
DEFAULT_BACKEND_URL,
),
restart_auth_token: Mutex::new(None),
desktop_session_secret: DesktopSessionSecret::generate()
.expect("failed to generate secure desktop session secret"),
startup_loading_mode: Mutex::new(None),
log_rotator_stop: Mutex::new(None),
exit_state: Mutex::new(exit_state::ExitStateMachine::default()),
Expand Down
Loading