Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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_INSTALLATION_SOURCE` | 标记 AstrBot 匿名指标中的安装来源 | 打包态启动后端时写入 `desktop` |
| `ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH` | 桌面端写给后端启动器的 heartbeat 文件路径 | 打包态默认写到 `ASTRBOT_ROOT/data/backend-startup-heartbeat.json` |

Expand Down
165 changes: 165 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,96 @@
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') {
const authResult = authResults.shift();
if (authResult instanceof Error) {
throw authResult;
}
return authResult ?? {
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 +101,88 @@ 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 handles rejected desktop authentication bridge calls', async () => {
const source = await readFile(bootstrapPath, 'utf8');
const runtime = runBootstrap(source, [new Error('desktop auth bridge unavailable')]);

const result = await runtime.window.astrbotDesktop.refreshAuthSession();

assert.equal(result?.ok, false);
assert.equal(result?.reason, 'Error: desktop auth bridge unavailable');
assert.equal(runtime.localStorage.getItem('token'), null);
assert.equal(runtime.window.location.hash, '#/auth/login');
});

test('bridge bootstrap normalizes unexpected desktop authentication refresh errors', async () => {
const source = await readFile(bootstrapPath, 'utf8');
const invalidAuthResult = { ok: true, username: 'astrbot' };
Object.defineProperty(invalidAuthResult, 'token', {
get() {
throw new Error('unexpected token access failure');
},
});
const runtime = runBootstrap(source, [invalidAuthResult]);

const result = await runtime.window.astrbotDesktop.refreshAuthSession();

assert.equal(result?.ok, false);
assert.equal(result?.reason, 'Unable to refresh desktop authentication.');
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