diff --git a/docs/environment-variables.md b/docs/environment-variables.md
index 8d6c16b5..69a85a0b 100644
--- a/docs/environment-variables.md
+++ b/docs/environment-variables.md
@@ -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` |
diff --git a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs
index 45dc0ab7..9d92f228 100644
--- a/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs
+++ b/scripts/prepare-resources/bridge-bootstrap-updater-contract.test.mjs
@@ -1,6 +1,7 @@
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(
@@ -8,6 +9,88 @@ const chatTransportContractPath = new URL(
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');
@@ -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'),
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 8a78bbdf..030a4014 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -61,6 +61,7 @@ name = "astrbot-desktop-tauri"
version = "4.27.2"
dependencies = [
"chrono",
+ "getrandom 0.3.4",
"home",
"semver",
"serde",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 858fb46d..1bd3d74e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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"
diff --git a/src-tauri/src/app_constants.rs b/src-tauri/src/app_constants.rs
index fc8ec743..19cc5f6a 100644
--- a/src-tauri/src/app_constants.rs
+++ b/src-tauri/src/app_constants.rs
@@ -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;
diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs
index 7dfe2ffd..0894b5c3 100644
--- a/src-tauri/src/app_runtime.rs
+++ b/src-tauri/src/app_runtime.rs
@@ -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,
diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs
index fac9f58a..6f187ea5 100644
--- a/src-tauri/src/app_types.rs
+++ b/src-tauri/src/app_types.rs
@@ -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 {
@@ -45,6 +45,7 @@ pub(crate) struct BackendState {
pub(crate) child: Mutex