@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +38 -2
- package/cordis.patch.yml +6 -0
- package/dist/client.js +6 -0
- package/package.json +83 -4
- package/personal-assistant.plugin.json +31 -0
- package/personal-assistant.plugin.schema.json +43 -0
- package/runtime/assistant-runtime.mjs +317 -0
- package/runtime/check-manifest.mjs +21 -0
- package/runtime/cloud-browser-cdp.mjs +313 -0
- package/runtime/cloud-browser.mjs +972 -0
- package/runtime/cloud-execution.mjs +320 -0
- package/runtime/cloud-session-channel.mjs +45 -0
- package/runtime/computer-agentd.mjs +488 -0
- package/runtime/desktop-clipboard-limit.mjs +2 -0
- package/runtime/desktop-launcher.mjs +106 -0
- package/runtime/desktop-permit.mjs +42 -0
- package/runtime/desktop-session.mjs +840 -0
- package/runtime/desktop-ws.mjs +371 -0
- package/runtime/prepare-dsh-profile.mjs +17 -0
- package/runtime/welcome-server.mjs +144 -0
- package/scripts/assistant-runtime-launcher.sh +3 -0
- package/scripts/build-artifacts.mjs +81 -0
- package/scripts/build-client.mjs +82 -0
- package/scripts/computer-agentd-launcher.sh +3 -0
- package/scripts/deterministic_tar.py +21 -0
- package/scripts/install-local.mjs +262 -0
- package/scripts/scan-secrets.mjs +41 -0
- package/scripts/sync-public.mjs +52 -0
- package/scripts/verification/gateway-desktop-client.mjs +142 -0
- package/scripts/verification/verify-desktop-applications-linux.mjs +46 -0
- package/scripts/verification/verify-desktop-input-linux.mjs +127 -0
- package/scripts/verification/verify-desktop-pod-gateway.mjs +204 -0
- package/scripts/verify-cloud-turn.mjs +18 -0
- package/scripts/verify-computer-broker.mjs +10 -0
- package/src/automation-tools.mjs +18 -0
- package/src/bot-private-tools.mjs +23 -0
- package/src/bot-registry.mjs +157 -0
- package/src/bot-tool-policy.mjs +48 -0
- package/src/bot-tool-scope.mjs +40 -0
- package/src/client/bot-directory.mjs +23 -0
- package/src/client/components/desktop-clipboard.mjs +194 -0
- package/src/client/conversation.mjs +558 -0
- package/src/client/desktop-control.mjs +70 -0
- package/src/client/desktop-view.mjs +68 -0
- package/src/client/entry.mjs +8 -0
- package/src/client/index.mjs +693 -0
- package/src/client/markdown.mjs +130 -0
- package/src/client/native-workbench.mjs +62 -0
- package/src/cloud-computer-tools.mjs +245 -0
- package/src/cloud-files.mjs +57 -0
- package/src/cloud-model.mjs +10 -0
- package/src/cloud-session-transport.mjs +76 -0
- package/src/computer-broker-protocol.mjs +116 -0
- package/src/computer-broker.mjs +220 -0
- package/src/desktop-tools.mjs +64 -0
- package/src/dsh-session-bridge.mjs +207 -0
- package/src/dsh-web-bridge.mjs +127 -0
- package/src/host-gateway.mjs +228 -0
- package/src/host-session-channel.mjs +410 -0
- package/src/implementation-capabilities.mjs +10 -0
- package/src/index.mjs +148 -0
- package/src/persistent-state.mjs +201 -0
- package/src/platform-manifest.mjs +24 -0
- package/src/plugin-manifest.mjs +82 -0
- package/src/schedule-manager.mjs +88 -0
- package/src/system-fetch.mjs +72 -0
- package/src/workspace-tools.mjs +22 -0
- package/src/workspace.mjs +22 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory desktop session manager for the Personal Assistant Computer.
|
|
3
|
+
*
|
|
4
|
+
* The desktop is a real X11 display (Xvfb + fluxbox); frames are captured to
|
|
5
|
+
* stdout and never written to disk. The manager is deliberately stateless
|
|
6
|
+
* across Pod restarts: a new Pod has no valid session or lease.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile as execFileCallback, spawn as spawnChild } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createDesktopLauncherServer } from './desktop-launcher.mjs';
|
|
16
|
+
import { createWelcomeServer } from './welcome-server.mjs';
|
|
17
|
+
|
|
18
|
+
const execFile = promisify(execFileCallback);
|
|
19
|
+
export const DESKTOP_WIDTH = 1280;
|
|
20
|
+
export const DESKTOP_HEIGHT = 800;
|
|
21
|
+
export const DESKTOP_MAX_FRAME_BYTES = 300 * 1024;
|
|
22
|
+
export const DESKTOP_ACTIVE_INTERVAL_MS = 80;
|
|
23
|
+
export const DESKTOP_IDLE_INTERVAL_MS = 1000;
|
|
24
|
+
export const DESKTOP_CAPTURE_FPS = 15;
|
|
25
|
+
export const DESKTOP_ACTIVITY_WINDOW_MS = 10_000;
|
|
26
|
+
export const DESKTOP_IDLE_TIMEOUT_MS = Number(process.env.AGENTKIT_DESKTOP_IDLE_TIMEOUT_MS || 10 * 60_000);
|
|
27
|
+
export const DESKTOP_IDLE_RETENTION_MS = Number(process.env.AGENTKIT_DESKTOP_IDLE_RETENTION_MS || 15 * 60_000);
|
|
28
|
+
export const DESKTOP_TEXT_COALESCE_MS = 30;
|
|
29
|
+
export const DESKTOP_CLIPBOARD_MAX_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, Math.round(Number(value))));
|
|
32
|
+
const inputError = (message, code = 'DESKTOP_INPUT_INVALID', status = 400) => Object.assign(new Error(message), { code, status });
|
|
33
|
+
const truncateUtf8 = (value, maxBytes) => {
|
|
34
|
+
const buffer = Buffer.from(String(value ?? ''), 'utf8');
|
|
35
|
+
if (buffer.length <= maxBytes) return buffer.toString('utf8');
|
|
36
|
+
// 按 UTF-8 字符边界截断,不能把多字节字符切一半。
|
|
37
|
+
let end = maxBytes;
|
|
38
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
|
|
39
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// 仅在系统默认文件缺失时使用;正常镜像逐字复制 /etc/X11/fluxbox/keys。
|
|
43
|
+
// 正文点击由 ClickFocus/clickRaises 处理并转交应用,不用 OnWindow Mouse1 抢走应用单击。
|
|
44
|
+
const desktopWindowBindings = [
|
|
45
|
+
'OnDesktop Mouse1 :HideMenus',
|
|
46
|
+
'OnDesktop Mouse3 :RootMenu',
|
|
47
|
+
'OnTitlebar Mouse1 :MacroCmd {Raise} {Focus} {ActivateTab}',
|
|
48
|
+
'OnTitlebar Move1 :StartMoving',
|
|
49
|
+
'OnTitlebar Mouse3 :WindowMenu',
|
|
50
|
+
'OnWindowBorder Move1 :StartMoving',
|
|
51
|
+
'OnLeftGrip Move1 :StartResizing bottomleft',
|
|
52
|
+
'OnRightGrip Move1 :StartResizing bottomright',
|
|
53
|
+
'OnWindow Mod1 Mouse1 :MacroCmd {Raise} {Focus} {StartMoving}',
|
|
54
|
+
'Mod1 Tab :NextWindow {groups} (workspace=[current])',
|
|
55
|
+
'Mod1 Shift Tab :PrevWindow {groups} (workspace=[current])',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
function safeText(value, name, max = 4096) {
|
|
60
|
+
const result = String(value ?? '');
|
|
61
|
+
if (!result || result.length > max || /[\u0000\r\n]/u.test(result)) throw inputError(`${name} is invalid`);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse image2pipe MJPEG without retaining oversized or incomplete frames. */
|
|
66
|
+
export function createMjpegFrameParser({ onFrame, onDrop = () => {}, maxFrameBytes = DESKTOP_MAX_FRAME_BYTES } = {}) {
|
|
67
|
+
const start = Buffer.from([0xff, 0xd8]), end = Buffer.from([0xff, 0xd9]);
|
|
68
|
+
let pending = Buffer.alloc(0), skipping = false;
|
|
69
|
+
return chunk => {
|
|
70
|
+
pending = Buffer.concat([pending, chunk]);
|
|
71
|
+
while (pending.length) {
|
|
72
|
+
if (skipping) {
|
|
73
|
+
const index = pending.indexOf(end);
|
|
74
|
+
if (index < 0) { pending = pending.subarray(-1); return; }
|
|
75
|
+
pending = pending.subarray(index + 2); skipping = false;
|
|
76
|
+
}
|
|
77
|
+
const beginning = pending.indexOf(start);
|
|
78
|
+
if (beginning < 0) { pending = pending.subarray(-1); return; }
|
|
79
|
+
if (beginning > 0) pending = pending.subarray(beginning);
|
|
80
|
+
const ending = pending.indexOf(end, 2);
|
|
81
|
+
if (ending >= 0) {
|
|
82
|
+
const length = ending + 2;
|
|
83
|
+
if (length <= maxFrameBytes) onFrame(Buffer.from(pending.subarray(0, length))); else onDrop();
|
|
84
|
+
pending = pending.subarray(length);
|
|
85
|
+
} else {
|
|
86
|
+
if (pending.length > maxFrameBytes) { onDrop(); skipping = true; pending = pending.subarray(-1); }
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createDesktopController({
|
|
94
|
+
display = process.env.DISPLAY || ':99',
|
|
95
|
+
width = DESKTOP_WIDTH,
|
|
96
|
+
height = DESKTOP_HEIGHT,
|
|
97
|
+
spawn = spawnChild,
|
|
98
|
+
exec = execFile,
|
|
99
|
+
readWindowBindings = readFile,
|
|
100
|
+
now = () => Date.now(),
|
|
101
|
+
capture = null,
|
|
102
|
+
terminal = true,
|
|
103
|
+
onStop = null,
|
|
104
|
+
onIdle = null,
|
|
105
|
+
idleTimeoutMs = DESKTOP_IDLE_TIMEOUT_MS,
|
|
106
|
+
idleRetentionMs = DESKTOP_IDLE_RETENTION_MS,
|
|
107
|
+
windowManagerReadyTimeoutMs = 5000,
|
|
108
|
+
applications = false,
|
|
109
|
+
workspace = process.env.AGENTKIT_WORKSPACE_DIR || '/workspace',
|
|
110
|
+
onLaunchBrowser = null,
|
|
111
|
+
// 云端 DSH 网页(同一 Pod 内 127.0.0.1:8081 + token)由 runtime 侧注入地址,桌面只负责打开。
|
|
112
|
+
dshWebUrl = null,
|
|
113
|
+
onLaunchDshWeb = null,
|
|
114
|
+
onLaunchError = () => {},
|
|
115
|
+
} = {}) {
|
|
116
|
+
const sessions = new Map();
|
|
117
|
+
let xvfb;
|
|
118
|
+
let wm;
|
|
119
|
+
let windowManagerFailure;
|
|
120
|
+
let terminalProcess;
|
|
121
|
+
let dockProcess, fileManagerProcess, launcher, welcome, welcomeUrl, launcherStopped = false, launcherShutdown = false;
|
|
122
|
+
let appQueue = Promise.resolve();
|
|
123
|
+
const processGroups = new Set();
|
|
124
|
+
// X11 剪贴板由 owner 进程持有;只保留最新一份写入,避免旧 selection 被读到。
|
|
125
|
+
const clipboardOwners = new Set();
|
|
126
|
+
let collector;
|
|
127
|
+
let collectorStarting;
|
|
128
|
+
let collectorStopping;
|
|
129
|
+
let displayStopping;
|
|
130
|
+
let wmConfiguration;
|
|
131
|
+
const processDiagnostics = new Map();
|
|
132
|
+
let latestFrame;
|
|
133
|
+
let capturedFrames = 0, droppedFrames = 0;
|
|
134
|
+
let collectorFailure;
|
|
135
|
+
let displayReady;
|
|
136
|
+
let lastActivityAt = 0;
|
|
137
|
+
let sequence = 0;
|
|
138
|
+
let disposed = false;
|
|
139
|
+
const pressedButtons = new Set();
|
|
140
|
+
const pressedKeys = new Set();
|
|
141
|
+
const syntheticModifiers = new Set();
|
|
142
|
+
const keyPresses = new Map();
|
|
143
|
+
let inputQueue = Promise.resolve();
|
|
144
|
+
let inputOwner;
|
|
145
|
+
let pendingText;
|
|
146
|
+
const enqueueInput = operation => {
|
|
147
|
+
const pending = inputQueue.then(operation);
|
|
148
|
+
inputQueue = pending.catch(() => {});
|
|
149
|
+
return pending;
|
|
150
|
+
};
|
|
151
|
+
// 只合并相邻文本;按键、指针、控制权切换都是屏障,不能跨屏障重排。
|
|
152
|
+
const flushPendingText = () => {
|
|
153
|
+
const batch = pendingText;
|
|
154
|
+
if (!batch) return;
|
|
155
|
+
pendingText = undefined;
|
|
156
|
+
clearTimeout(batch.timer);
|
|
157
|
+
batch.flush();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// 桌面所有子进程(Xvfb/Fluxbox/tint2/xterm/文件管理器)共用同一时区,
|
|
161
|
+
// 否则 dock 时钟、终端与浏览器里的本地时间会互相矛盾。
|
|
162
|
+
const timeZone = typeof process.env.TZ === 'string' && process.env.TZ
|
|
163
|
+
? process.env.TZ
|
|
164
|
+
: String(process.env.AGENTKIT_DESKTOP_TIME_ZONE || 'Asia/Shanghai');
|
|
165
|
+
// 桌面面向中文用户:终端、文件管理器、窗口管理器都用简体中文(LC_ALL 会盖过 LANG)。
|
|
166
|
+
const environment = Object.freeze({ ...process.env, DISPLAY: display, LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh', TZ: timeZone });
|
|
167
|
+
const trackStartupProcess = (name, child) => {
|
|
168
|
+
const diagnostic = { stderr: '', exitCode: null, signal: null };
|
|
169
|
+
processDiagnostics.set(name, diagnostic);
|
|
170
|
+
child.stderr?.on('data', chunk => { diagnostic.stderr = (diagnostic.stderr + chunk.toString('utf8')).slice(-2048); });
|
|
171
|
+
child.once?.('exit', (code, signal) => { diagnostic.exitCode = code; diagnostic.signal = signal || null; });
|
|
172
|
+
child.once?.('error', error => { diagnostic.error = { code: error.code, message: String(error.message).slice(0, 500) }; });
|
|
173
|
+
return child;
|
|
174
|
+
};
|
|
175
|
+
const stopProcess = child => new Promise(resolve => {
|
|
176
|
+
if (!child || child.exitCode !== null) return resolve();
|
|
177
|
+
let timer;
|
|
178
|
+
const finish = () => { clearTimeout(timer); resolve(); };
|
|
179
|
+
child.once('exit', finish); child.once('error', finish);
|
|
180
|
+
const kill = signal => {
|
|
181
|
+
if (processGroups.has(child) && Number.isInteger(child.pid)) {
|
|
182
|
+
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
183
|
+
} else child.kill(signal);
|
|
184
|
+
};
|
|
185
|
+
timer = setTimeout(() => { kill('SIGKILL'); finish(); }, 500);
|
|
186
|
+
kill('SIGTERM');
|
|
187
|
+
});
|
|
188
|
+
const spawnDisplay = () => {
|
|
189
|
+
if (xvfb && xvfb.exitCode === null && !xvfb.killed) return;
|
|
190
|
+
xvfb = trackStartupProcess('Xvfb', spawn('Xvfb', [display, '-screen', '0', `${width}x${height}x24`, '-nolisten', 'tcp', '-ac'], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }));
|
|
191
|
+
xvfb.once?.('exit', () => { xvfb = undefined; displayReady = undefined; });
|
|
192
|
+
};
|
|
193
|
+
const spawnWindowManager = async () => {
|
|
194
|
+
if (wm && wm.exitCode === null && !wm.killed) return;
|
|
195
|
+
if (!wmConfiguration) {
|
|
196
|
+
// 先读取再创建配置目录:权限等错误不降级,也不留下可被下次启动复用的半成品。
|
|
197
|
+
let bindings;
|
|
198
|
+
try { bindings = await readWindowBindings('/etc/X11/fluxbox/keys'); }
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
201
|
+
bindings = desktopWindowBindings;
|
|
202
|
+
}
|
|
203
|
+
// Only configuration is written. Desktop pixels remain memory-only.
|
|
204
|
+
wmConfiguration = await mkdtemp(join(tmpdir(), 'agentkit-desktop-wm-'));
|
|
205
|
+
// Fluxbox 首次启动会向 HOME/.fluxbox 复制系统默认配置;提前完整创建,
|
|
206
|
+
// 避免覆盖 -rc 指定的设置或弹出默认壁纸错误窗口。
|
|
207
|
+
await mkdir(join(wmConfiguration, '.fluxbox'));
|
|
208
|
+
await writeFile(join(wmConfiguration, '.fluxbox', 'keys'), bindings);
|
|
209
|
+
await writeFile(join(wmConfiguration, '.fluxbox', 'apps'), '');
|
|
210
|
+
// 真实桌面采用浅蓝灰背景与平面窗口装饰;菜单仍使用系统 Noto 中文字体。
|
|
211
|
+
await writeFile(join(wmConfiguration, 'style'), [
|
|
212
|
+
'background: solid', 'background.color: #dce5f0',
|
|
213
|
+
'window.title.height: 28', 'window.bevelWidth: 3',
|
|
214
|
+
'window.title.focus: flat solid', 'window.title.focus.color: #f6f9fd',
|
|
215
|
+
'window.label.focus: parentrelative', 'window.label.focus.textColor: #23334d',
|
|
216
|
+
'window.title.unfocus: flat solid', 'window.title.unfocus.color: #eaf0f7',
|
|
217
|
+
'window.label.unfocus: parentrelative', 'window.label.unfocus.textColor: #66758b',
|
|
218
|
+
'window.focus.borderColor: #8da9d0', 'window.unfocus.borderColor: #c1cddd',
|
|
219
|
+
'window.borderWidth: 1', 'window.handleWidth: 3',
|
|
220
|
+
'window.button.focus: parentrelative', 'window.button.focus.picColor: #506882',
|
|
221
|
+
'window.button.unfocus: parentrelative', 'window.button.unfocus.picColor: #8a99ad',
|
|
222
|
+
'window.button.pressed: flat solid', 'window.button.pressed.color: #cbdcf2',
|
|
223
|
+
'window.handle.focus: flat solid', 'window.handle.focus.color: #d5e1f1',
|
|
224
|
+
'window.handle.unfocus: flat solid', 'window.handle.unfocus.color: #e4ebf4',
|
|
225
|
+
'window.grip.focus: parentrelative', 'window.grip.unfocus: parentrelative',
|
|
226
|
+
'menu.title: flat solid', 'menu.title.color: #f0f5fc', 'menu.title.textColor: #23334d',
|
|
227
|
+
'menu.frame: flat solid', 'menu.frame.color: #ffffff', 'menu.frame.textColor: #33435a',
|
|
228
|
+
'menu.hilite: flat solid', 'menu.hilite.color: #dce8f9', 'menu.hilite.textColor: #1e3658',
|
|
229
|
+
'menu.borderColor: #c5d2e3', 'menu.borderWidth: 1', 'menu.bevelWidth: 6',
|
|
230
|
+
'toolbar: flat solid', 'toolbar.color: #f8fbff', 'toolbar.height: 32',
|
|
231
|
+
'toolbar.borderColor: #c5d2e3', 'toolbar.borderWidth: 1', 'toolbar.bevelWidth: 4',
|
|
232
|
+
'toolbar.clock: parentrelative', 'toolbar.clock.textColor: #40516b',
|
|
233
|
+
'toolbar.workspace: parentrelative', 'toolbar.workspace.textColor: #40516b',
|
|
234
|
+
'toolbar.button: parentrelative', 'toolbar.button.picColor: #506882',
|
|
235
|
+
'toolbar.button.pressed: flat solid', 'toolbar.button.pressed.color: #dce8f9',
|
|
236
|
+
'toolbar.iconbar.focused: flat solid', 'toolbar.iconbar.focused.color: #dfebfa',
|
|
237
|
+
'toolbar.iconbar.focused.textColor: #23334d',
|
|
238
|
+
'toolbar.iconbar.unfocused: parentrelative', 'toolbar.iconbar.unfocused.textColor: #66758b',
|
|
239
|
+
'menu.title.font: Noto Sans CJK SC-10:bold', 'menu.frame.font: Noto Sans CJK SC-10',
|
|
240
|
+
'menu.hilite.font: Noto Sans CJK SC-10:bold', 'window.font: Noto Sans CJK SC-9',
|
|
241
|
+
'toolbar.clock.font: Noto Sans CJK SC-9', 'toolbar.workspace.font: Noto Sans CJK SC-9',
|
|
242
|
+
'toolbar.iconbar.focused.font: Noto Sans CJK SC-9', 'toolbar.iconbar.unfocused.font: Noto Sans CJK SC-9', '',
|
|
243
|
+
].join('\n'));
|
|
244
|
+
let menu = '[begin] (云电脑)\n[exec] (终端) {/usr/bin/xterm -u8 -fa "DejaVu Sans Mono" -fs 12 -geometry 56x24+20+65}\n[end]\n';
|
|
245
|
+
if (applications) {
|
|
246
|
+
if (launcherShutdown) throw Object.assign(new Error('桌面入口已停止'), { code: 'DESKTOP_LAUNCH_CLOSED' });
|
|
247
|
+
launcherStopped = false;
|
|
248
|
+
launcher = await createDesktopLauncherServer({ launch: launchApplication, onError: onLaunchError });
|
|
249
|
+
// 欢迎页由本地回环桥提供:页面上的磁贴点击会走与 dock 相同的 launchApplication。
|
|
250
|
+
try {
|
|
251
|
+
welcome = await createWelcomeServer({
|
|
252
|
+
webDir: fileURLToPath(new URL('../web', import.meta.url)),
|
|
253
|
+
launch: launchApplication,
|
|
254
|
+
onError: onLaunchError,
|
|
255
|
+
dshWeb: typeof dshWebUrl === 'function' ? dshWebUrl : () => dshWebUrl,
|
|
256
|
+
});
|
|
257
|
+
welcomeUrl = welcome.url;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
welcome = undefined; welcomeUrl = undefined;
|
|
260
|
+
onLaunchError?.(error);
|
|
261
|
+
}
|
|
262
|
+
const helper = fileURLToPath(new URL('./desktop-launcher.mjs', import.meta.url));
|
|
263
|
+
const quote = value => `"${String(value).replace(/[\\"`$]/g, '\\$&')}"`;
|
|
264
|
+
const command = action => [quote(process.execPath), quote(helper), '--socket', quote(launcher.socketPath), '--action', action].join(' ');
|
|
265
|
+
const entries = [
|
|
266
|
+
['browser', '浏览器', '/usr/share/icons/hicolor/48x48/apps/chromium.png'],
|
|
267
|
+
['terminal', '终端', '/usr/share/icons/Adwaita/48x48/legacy/utilities-terminal.png'],
|
|
268
|
+
['files', '文件', '/usr/share/icons/Adwaita/48x48/places/folder.png'],
|
|
269
|
+
];
|
|
270
|
+
for (const [action, name, icon] of entries) await writeFile(join(wmConfiguration, `${action}.desktop`), `[Desktop Entry]\nType=Application\nName=${name}\nExec=${command(action)}\nIcon=${icon}\nTerminal=false\nStartupNotify=false\n`);
|
|
271
|
+
menu = '[begin] (云电脑)\n' + entries.map(([action, name]) => `[exec] (${name}) {${command(action)}}\n`).join('') + '[end]\n';
|
|
272
|
+
await writeFile(join(wmConfiguration, 'tint2rc'), [
|
|
273
|
+
'rounded = 0', 'border_width = 1', 'background_color = #f8fbff 100', 'border_color = #c5d2e3 100',
|
|
274
|
+
// 面板只放三个启动图标:不显示时钟(欢迎页已有大时钟),也不显示任务栏按钮。
|
|
275
|
+
// 宽度随内容收缩:3 个图标之外不留空白条。
|
|
276
|
+
'panel_items = L', 'panel_size = 0 56', 'panel_shrink = 1', 'panel_margin = 0 12', 'panel_padding = 12 7 12',
|
|
277
|
+
'panel_background_id = 1', 'panel_position = bottom center horizontal', 'panel_layer = top',
|
|
278
|
+
'panel_monitor = primary', 'panel_dock = 0', 'panel_window_name = AgentKit Dock',
|
|
279
|
+
'disable_transparency = 1', 'mouse_effects = 1', 'wm_menu = 0',
|
|
280
|
+
'launcher_padding = 4 0 14', 'launcher_icon_size = 36', 'launcher_background_id = 0',
|
|
281
|
+
'launcher_icon_background_id = 0', 'launcher_icon_asb = 100 0 0', 'launcher_tooltip = 1',
|
|
282
|
+
...entries.map(([action]) => `launcher_item_app = ${join(wmConfiguration, `${action}.desktop`)}`), '',
|
|
283
|
+
].join('\n'));
|
|
284
|
+
}
|
|
285
|
+
await writeFile(join(wmConfiguration, 'menu'), menu);
|
|
286
|
+
const config = `session.configVersion: 13\nsession.styleFile: ${join(wmConfiguration, 'style')}\nsession.menuFile: ${join(wmConfiguration, 'menu')}\nsession.keyFile: ${join(wmConfiguration, '.fluxbox', 'keys')}\nsession.screen0.rootCommand: /usr/bin/fbsetroot -solid '#dce5f0'\nsession.screen0.workspaces: 1\nsession.screen0.workspaceNames: 云电脑\nsession.screen0.toolbar.visible: ${applications ? 'false' : 'true'}\nsession.screen0.focusModel: ClickFocus\nsession.screen0.clickRaises: true\nsession.screen0.focusNewWindows: true\n`;
|
|
287
|
+
await writeFile(join(wmConfiguration, '.fluxbox', 'init'), config);
|
|
288
|
+
await writeFile(join(wmConfiguration, 'init'), config);
|
|
289
|
+
}
|
|
290
|
+
windowManagerFailure = undefined;
|
|
291
|
+
const child = trackStartupProcess('fluxbox', spawn('fluxbox', ['-display', display, '-rc', join(wmConfiguration, 'init'), '-no-slit'], { env: { ...environment, HOME: wmConfiguration }, stdio: ['ignore', 'ignore', 'pipe'] }));
|
|
292
|
+
wm = child;
|
|
293
|
+
child.once?.('error', error => {
|
|
294
|
+
if (wm !== child) return;
|
|
295
|
+
windowManagerFailure = Object.assign(new Error(`Fluxbox failed to start: ${error.message}`), { code: 'DESKTOP_WINDOW_MANAGER_UNAVAILABLE', status: 503, cause: error });
|
|
296
|
+
displayReady = undefined;
|
|
297
|
+
});
|
|
298
|
+
child.once?.('exit', code => {
|
|
299
|
+
if (wm !== child) return;
|
|
300
|
+
wm = undefined; displayReady = undefined;
|
|
301
|
+
windowManagerFailure = Object.assign(new Error(`Fluxbox exited before desktop shutdown (${code})`), { code: 'DESKTOP_WINDOW_MANAGER_UNAVAILABLE', status: 503 });
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
const spawnTerminal = (force = false) => {
|
|
305
|
+
if ((!terminal && !force) || (terminalProcess && terminalProcess.exitCode === null && !terminalProcess.killed)) return;
|
|
306
|
+
// Latin 使用等宽 DejaVu 主字体;中文和 emoji 由 fontconfig 使用已安装的
|
|
307
|
+
// Noto 后备字体。不要把 CJK 字体作为 -fa,或用 -fd 锁定所有宽字符,
|
|
308
|
+
// 否则分别会拉宽英文单元格、破坏彩色 emoji 后备。56 列为多窗口留出空间。
|
|
309
|
+
terminalProcess = trackStartupProcess('xterm', spawn('xterm', ['-display', display, '-u8', '-fa', 'DejaVu Sans Mono', '-fs', '12', '-xrm', 'XTerm*VT100.translations: #override\\nShift <Key>Insert: insert-selection(PRIMARY,CLIPBOARD)\\nCtrl Shift <Key>V: insert-selection(CLIPBOARD)', '-geometry', '56x24+20+65', '-title', 'AgentKit Cloud Desktop'], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }));
|
|
310
|
+
const child = terminalProcess;
|
|
311
|
+
child.once?.('exit', () => { if (terminalProcess === child) terminalProcess = undefined; });
|
|
312
|
+
return child;
|
|
313
|
+
};
|
|
314
|
+
const assertApplicationActive = signal => {
|
|
315
|
+
if (disposed || launcherStopped || signal?.aborted) throw Object.assign(new Error('桌面入口已停止'), { code: 'DESKTOP_LAUNCH_CLOSED' });
|
|
316
|
+
};
|
|
317
|
+
// 只搜索可见窗口的 `--onlyvisible` 会把「窗口还在但被最小化/收起」误判成
|
|
318
|
+
// 「没有窗口」:dock 图标点了没反应,直到桌面重启。桌面入口必须能把这种
|
|
319
|
+
// 窗口 map 回来,所以未命中可见窗口时退回全量搜索并显式恢复。
|
|
320
|
+
const searchApplicationWindows = async (args, onlyVisible) => {
|
|
321
|
+
let result;
|
|
322
|
+
try {
|
|
323
|
+
result = await exec('xdotool', ['search', ...(onlyVisible ? ['--onlyvisible'] : []), ...args], { env: environment, timeout: 1000 });
|
|
324
|
+
} catch (error) { if (error.code !== 1) throw error; }
|
|
325
|
+
return String(result?.stdout || '').trim().split('\n').filter(Boolean);
|
|
326
|
+
};
|
|
327
|
+
// Chromium 会暴露无法激活的 InputOnly 辅助窗口,逐个尝试并忽略单个失败。
|
|
328
|
+
const revealApplicationWindow = async id => {
|
|
329
|
+
await exec('xdotool', ['windowmap', id], { env: environment, timeout: 1000 }).catch(() => {});
|
|
330
|
+
await exec('xdotool', ['windowactivate', '--sync', id], { env: environment, timeout: 1000 }).catch(() => {});
|
|
331
|
+
};
|
|
332
|
+
const activateApplication = async (args, signal, beforeCommand) => {
|
|
333
|
+
const deadline = performance.now() + 2000;
|
|
334
|
+
while (performance.now() < deadline) {
|
|
335
|
+
assertApplicationActive(signal);
|
|
336
|
+
const [id] = await searchApplicationWindows(args, true);
|
|
337
|
+
if (id) { beforeCommand?.(); await exec('xdotool', ['windowactivate', '--sync', id], { env: environment, timeout: 1000 }); return; }
|
|
338
|
+
assertApplicationActive(signal);
|
|
339
|
+
// 可见窗口不存在,但应用窗口本身仍在(标题栏最小化、被 WM 收起)时
|
|
340
|
+
// map + activate 把它拉回来,再确认一次可见性。
|
|
341
|
+
const hidden = await searchApplicationWindows(args, false);
|
|
342
|
+
if (hidden.length) {
|
|
343
|
+
beforeCommand?.();
|
|
344
|
+
for (const candidate of hidden) await revealApplicationWindow(candidate);
|
|
345
|
+
assertApplicationActive(signal);
|
|
346
|
+
if ((await searchApplicationWindows(args, true)).length) return;
|
|
347
|
+
}
|
|
348
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
349
|
+
}
|
|
350
|
+
throw Object.assign(new Error('桌面应用尚未显示窗口'), { code: 'DESKTOP_APP_WINDOW_UNAVAILABLE' });
|
|
351
|
+
};
|
|
352
|
+
const launchApplication = (action, { signal, beforeCommand } = {}) => {
|
|
353
|
+
const pending = appQueue.then(async () => {
|
|
354
|
+
assertApplicationActive(signal); beforeCommand?.();
|
|
355
|
+
await ensureDisplay();
|
|
356
|
+
assertApplicationActive(signal); beforeCommand?.();
|
|
357
|
+
if (action === 'browser') {
|
|
358
|
+
if (!onLaunchBrowser) throw Object.assign(new Error('浏览器入口尚未连接'), { code: 'DESKTOP_BROWSER_UNAVAILABLE' });
|
|
359
|
+
await onLaunchBrowser({ signal });
|
|
360
|
+
// CDP 的 activateTarget 只选择标签页;应用入口还需让同一窗口获得 WM 焦点。
|
|
361
|
+
await activateApplication(['--class', '^Chromium$'], signal, beforeCommand);
|
|
362
|
+
} else if (action === 'dsh') {
|
|
363
|
+
if (!onLaunchDshWeb) throw Object.assign(new Error('云端助理网页尚未接入'), { code: 'DSH_WEB_UNAVAILABLE' });
|
|
364
|
+
await onLaunchDshWeb({ signal });
|
|
365
|
+
await activateApplication(['--class', '^Chromium$'], signal, beforeCommand);
|
|
366
|
+
} else if (action === 'terminal') {
|
|
367
|
+
if (terminalProcess && terminalProcess.exitCode === null && !terminalProcess.killed) await activateApplication(['--pid', String(terminalProcess.pid)], signal, beforeCommand);
|
|
368
|
+
else { const child = spawnTerminal(true); await activateApplication(['--pid', String(child.pid)], signal, beforeCommand); }
|
|
369
|
+
} else if (action === 'files') {
|
|
370
|
+
if (fileManagerProcess && fileManagerProcess.exitCode === null && !fileManagerProcess.killed) await activateApplication(['--class', '^Thunar$'], signal, beforeCommand);
|
|
371
|
+
else {
|
|
372
|
+
const child = trackStartupProcess('thunar', spawn('/usr/bin/dbus-run-session', ['--', '/usr/bin/thunar', workspace], { env: environment, stdio: ['ignore', 'ignore', 'pipe'], detached: true }));
|
|
373
|
+
fileManagerProcess = child; processGroups.add(child);
|
|
374
|
+
child.once?.('exit', () => { processGroups.delete(child); if (fileManagerProcess === child) fileManagerProcess = undefined; });
|
|
375
|
+
await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
|
|
376
|
+
await activateApplication(['--class', '^Thunar$'], signal, beforeCommand);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
assertApplicationActive(signal); beforeCommand?.();
|
|
380
|
+
});
|
|
381
|
+
appQueue = pending.catch(() => {});
|
|
382
|
+
return pending;
|
|
383
|
+
};
|
|
384
|
+
const stopLauncher = async ({ permanent = true } = {}) => {
|
|
385
|
+
launcherShutdown ||= permanent; launcherStopped = true;
|
|
386
|
+
const current = launcher; launcher = undefined; welcomeUrl = undefined;
|
|
387
|
+
const currentWelcome = welcome; welcome = undefined;
|
|
388
|
+
await currentWelcome?.close();
|
|
389
|
+
await current?.close();
|
|
390
|
+
};
|
|
391
|
+
const waitDisplay = async () => {
|
|
392
|
+
if (displayReady) return displayReady;
|
|
393
|
+
const began = performance.now();
|
|
394
|
+
let lastProbe;
|
|
395
|
+
displayReady = (async () => {
|
|
396
|
+
// 启动本身计入活动,避免首个清理周期把尚未就绪的桌面当成长期空闲。
|
|
397
|
+
lastActivityAt = now();
|
|
398
|
+
spawnDisplay();
|
|
399
|
+
const deadline = now() + 5000;
|
|
400
|
+
let lastError;
|
|
401
|
+
while (now() < deadline) {
|
|
402
|
+
// 1280x800 X11 visuals can make xdpyinfo exceed 32 KiB; the probe is
|
|
403
|
+
// only a readiness check and must not reject a healthy Xvfb display.
|
|
404
|
+
try { await exec('xdpyinfo', ['-display', display], { env: environment, maxBuffer: 128 * 1024 }); break; }
|
|
405
|
+
catch (error) { lastError = error; await new Promise(resolve => setTimeout(resolve, 50)); }
|
|
406
|
+
}
|
|
407
|
+
if (now() >= deadline) throw Object.assign(new Error(`Xvfb display ${display} did not become ready`), { code: 'DESKTOP_DISPLAY_UNAVAILABLE', cause: lastError });
|
|
408
|
+
await spawnWindowManager();
|
|
409
|
+
spawnTerminal();
|
|
410
|
+
// Xvfb 可连接、Fluxbox 进程存在均不能证明窗口已被 WM 接管。
|
|
411
|
+
// xprop 在属性不存在时也可能 exit 0,必须检查 WINDOW 类型的非零 XID。
|
|
412
|
+
const wmDeadline = performance.now() + windowManagerReadyTimeoutMs;
|
|
413
|
+
let wmReady = false, probeError;
|
|
414
|
+
while (performance.now() < wmDeadline) {
|
|
415
|
+
if (disposed) throw Object.assign(new Error('desktop controller is disposed'), { code: 'DESKTOP_DISPOSED' });
|
|
416
|
+
if (windowManagerFailure) throw windowManagerFailure;
|
|
417
|
+
if (!wm || wm.exitCode !== null || wm.killed) break;
|
|
418
|
+
try {
|
|
419
|
+
const result = await exec('xprop', ['-display', display, '-root', '_NET_SUPPORTING_WM_CHECK', '_NET_ACTIVE_WINDOW'], {
|
|
420
|
+
env: environment, maxBuffer: 4096, timeout: Math.max(1, Math.min(500, Math.ceil(wmDeadline - performance.now()))),
|
|
421
|
+
});
|
|
422
|
+
const output = String(result.stdout);
|
|
423
|
+
lastProbe = { stdout: output.slice(-2048), stderr: String(result.stderr || '').slice(-2048) };
|
|
424
|
+
const hasWindow = name => {
|
|
425
|
+
const value = output.match(new RegExp(`^${name}\\(WINDOW\\): window id # (0x[0-9a-f]+)\\s*$`, 'im'))?.[1];
|
|
426
|
+
return value !== undefined && BigInt(value) !== 0n;
|
|
427
|
+
};
|
|
428
|
+
wmReady = hasWindow('_NET_SUPPORTING_WM_CHECK') && (!terminal || hasWindow('_NET_ACTIVE_WINDOW'));
|
|
429
|
+
if (windowManagerFailure) throw windowManagerFailure;
|
|
430
|
+
if (wmReady) break;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
probeError = error;
|
|
433
|
+
lastProbe = { code: error.code, signal: error.signal || null, killed: Boolean(error.killed), message: String(error.message).slice(0, 500), stdout: String(error.stdout || '').slice(-2048), stderr: String(error.stderr || '').slice(-2048) };
|
|
434
|
+
}
|
|
435
|
+
if (windowManagerFailure) throw windowManagerFailure;
|
|
436
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(50, wmDeadline - performance.now()))));
|
|
437
|
+
}
|
|
438
|
+
if (!wmReady) throw Object.assign(new Error(`Fluxbox on ${display} did not publish a ready desktop within ${windowManagerReadyTimeoutMs}ms`), { code: 'DESKTOP_WINDOW_MANAGER_UNAVAILABLE', status: 503, cause: probeError });
|
|
439
|
+
if (applications) {
|
|
440
|
+
const child = trackStartupProcess('tint2', spawn('/usr/bin/tint2', ['-c', join(wmConfiguration, 'tint2rc')], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }));
|
|
441
|
+
dockProcess = child;
|
|
442
|
+
child.once?.('exit', () => { if (dockProcess === child) { dockProcess = undefined; displayReady = undefined; } });
|
|
443
|
+
await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
|
|
444
|
+
const deadline = performance.now() + 2000;
|
|
445
|
+
let visible = false;
|
|
446
|
+
while (performance.now() < deadline && child.exitCode === null && !child.killed) {
|
|
447
|
+
assertApplicationActive();
|
|
448
|
+
try { visible = Boolean(String((await exec('xdotool', ['search', '--onlyvisible', '--pid', String(child.pid)], { env: environment, timeout: 500 })).stdout).trim()); }
|
|
449
|
+
catch (error) { if (error.code !== 1) throw error; }
|
|
450
|
+
if (visible) break;
|
|
451
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
452
|
+
}
|
|
453
|
+
if (!visible) throw Object.assign(new Error('桌面应用底栏未显示'), { code: 'DESKTOP_DOCK_UNAVAILABLE' });
|
|
454
|
+
assertApplicationActive();
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
})();
|
|
458
|
+
try { await displayReady; } catch (error) {
|
|
459
|
+
// 只保存启动工具的有限诊断,不采集 env、命令行、终端内容或画面。
|
|
460
|
+
// 必须先快照再清理,否则 SIGTERM/断开 X11 会覆盖真正的失败原因。
|
|
461
|
+
error.desktopDiagnostics = { elapsedMs: Math.round(performance.now() - began), lastProbe, processes: Object.fromEntries([...processDiagnostics].map(([name, value]) => [name, { ...value }])) };
|
|
462
|
+
displayReady = undefined; await stopDisplay(); throw error;
|
|
463
|
+
}
|
|
464
|
+
return displayReady;
|
|
465
|
+
};
|
|
466
|
+
const ensureDisplay = async () => {
|
|
467
|
+
if (disposed) throw Object.assign(new Error('desktop controller is disposed'), { code: 'DESKTOP_DISPOSED' });
|
|
468
|
+
if (displayStopping) await displayStopping;
|
|
469
|
+
await waitDisplay();
|
|
470
|
+
};
|
|
471
|
+
const stopCollector = () => {
|
|
472
|
+
const child = collector;
|
|
473
|
+
if (!child) return collectorStopping || Promise.resolve();
|
|
474
|
+
collector = undefined; latestFrame = undefined;
|
|
475
|
+
const stopped = stopProcess(child);
|
|
476
|
+
collectorStopping = stopped;
|
|
477
|
+
void stopped.finally(() => { if (collectorStopping === stopped) collectorStopping = undefined; });
|
|
478
|
+
return stopped;
|
|
479
|
+
};
|
|
480
|
+
const ensureCollector = async () => {
|
|
481
|
+
if (capture) return;
|
|
482
|
+
if (collectorStarting) return collectorStarting;
|
|
483
|
+
if (collector && collector.exitCode === null && !collector.killed) return;
|
|
484
|
+
collectorStarting = (async () => {
|
|
485
|
+
if (collectorStopping) await collectorStopping;
|
|
486
|
+
await ensureDisplay();
|
|
487
|
+
collectorFailure = undefined; latestFrame = undefined;
|
|
488
|
+
let stderr = '';
|
|
489
|
+
const child = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'warning', '-nostdin', '-f', 'x11grab', '-draw_mouse', '0', '-video_size', `${width}x${height}`, '-framerate', String(DESKTOP_CAPTURE_FPS), '-i', `${display}.0+0,0`, '-an', '-vf', `setpts=N/(${DESKTOP_CAPTURE_FPS}*TB)`, '-c:v', 'mjpeg', '-pix_fmt', 'yuvj444p', '-filter_threads', '2', '-q:v', '3', '-threads', '2', '-fps_mode', 'passthrough', '-f', 'image2pipe', 'pipe:1'], { env: environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
490
|
+
collector = child;
|
|
491
|
+
const receive = createMjpegFrameParser({ onFrame: data => {
|
|
492
|
+
if (collector !== child || disposed) return;
|
|
493
|
+
latestFrame = { seq: ++sequence, capturedAt: now(), data }; capturedFrames += 1;
|
|
494
|
+
}, onDrop: () => { droppedFrames += 1; } });
|
|
495
|
+
child.stdout.on('data', receive);
|
|
496
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-2048); });
|
|
497
|
+
child.on('error', error => { collectorFailure = Object.assign(new Error(`desktop collector failed: ${error.message}`), { code: 'DESKTOP_FRAME_CAPTURE_FAILED', cause: error }); });
|
|
498
|
+
child.once('exit', code => {
|
|
499
|
+
if (collector !== child) return;
|
|
500
|
+
collector = undefined;
|
|
501
|
+
collectorFailure = Object.assign(new Error(`desktop collector exited ${code}: ${stderr}`), { code: 'DESKTOP_FRAME_CAPTURE_FAILED' });
|
|
502
|
+
});
|
|
503
|
+
const deadline = Date.now() + 2500;
|
|
504
|
+
while (!latestFrame && collector === child && Date.now() < deadline && !collectorFailure) await new Promise(resolve => setTimeout(resolve, 10));
|
|
505
|
+
if (!latestFrame) { await stopCollector(); throw collectorFailure || Object.assign(new Error('desktop first frame timed out'), { code: 'DESKTOP_FRAME_CAPTURE_FAILED' }); }
|
|
506
|
+
})();
|
|
507
|
+
try { await collectorStarting; } finally { collectorStarting = undefined; }
|
|
508
|
+
};
|
|
509
|
+
const captureFrame = async () => {
|
|
510
|
+
await ensureDisplay();
|
|
511
|
+
if (capture) return capture({ display, width, height });
|
|
512
|
+
await ensureCollector();
|
|
513
|
+
if (collectorFailure) throw collectorFailure;
|
|
514
|
+
return latestFrame.data;
|
|
515
|
+
};
|
|
516
|
+
const runXdotool = async (args) => {
|
|
517
|
+
await ensureDisplay();
|
|
518
|
+
await exec('xdotool', args, { env: environment, maxBuffer: 32 * 1024, timeout: 2000 });
|
|
519
|
+
};
|
|
520
|
+
const keyNames = Object.freeze({ Ctrl: 'Control_L', Cmd: 'Super_L', Command: 'Super_L', Option: 'Alt_L', ctrl: 'Control_L', Control: 'Control_L', Alt: 'Alt_L', alt: 'Alt_L', Shift: 'Shift_L', shift: 'Shift_L', Meta: 'Super_L', meta: 'Super_L', Enter: 'Return', Escape: 'Escape', Esc: 'Escape', ' ': 'space', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Backspace: 'BackSpace' });
|
|
521
|
+
const parsedKey = value => {
|
|
522
|
+
const source = safeText(value, 'event.key', 64);
|
|
523
|
+
const parts = source === ' ' ? [' '] : source.split('+');
|
|
524
|
+
const keys = [...new Set(parts.map(part => keyNames[part] || part))];
|
|
525
|
+
if (!keys.length || keys.some(key => !/^[A-Za-z0-9_:-]+$/u.test(key))) throw inputError('event.key contains unsupported characters');
|
|
526
|
+
return { primary: keys.at(-1), modifiers: keys.slice(0, -1) };
|
|
527
|
+
};
|
|
528
|
+
const pressKey = async (value, action = 'tap') => {
|
|
529
|
+
const { primary, modifiers } = parsedKey(value);
|
|
530
|
+
if (action === 'up') {
|
|
531
|
+
keyPresses.delete(primary);
|
|
532
|
+
syntheticModifiers.delete(primary);
|
|
533
|
+
const release = [primary, ...[...syntheticModifiers].filter(key => !keyPresses.has(key) && ![...keyPresses.values()].some(press => press.modifiers.includes(key)))];
|
|
534
|
+
await runXdotool(['keyup', ...release]);
|
|
535
|
+
for (const key of release) { pressedKeys.delete(key); syntheticModifiers.delete(key); }
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (action !== 'down' && action !== 'tap') throw inputError('event.key action must be down or up');
|
|
539
|
+
if (action === 'down' && keyPresses.has(primary)) return runXdotool(['keydown', primary]);
|
|
540
|
+
const addedModifiers = modifiers.filter(key => !pressedKeys.has(key));
|
|
541
|
+
await runXdotool(['keydown', ...addedModifiers, primary]);
|
|
542
|
+
for (const key of [...addedModifiers, primary]) pressedKeys.add(key);
|
|
543
|
+
if (action === 'down') { for (const key of addedModifiers) syntheticModifiers.add(key); keyPresses.set(primary, { modifiers }); return; }
|
|
544
|
+
await runXdotool(['keyup', primary, ...addedModifiers.toReversed()]);
|
|
545
|
+
for (const key of [primary, ...addedModifiers]) pressedKeys.delete(key);
|
|
546
|
+
};
|
|
547
|
+
const releasePressedInput = async () => {
|
|
548
|
+
const buttons = [...pressedButtons], keys = [...pressedKeys];
|
|
549
|
+
pressedButtons.clear(); pressedKeys.clear(); syntheticModifiers.clear(); keyPresses.clear(); inputOwner = undefined;
|
|
550
|
+
// Never call ensureDisplay here: cleanup must not create a fresh Xvfb.
|
|
551
|
+
if (!xvfb || xvfb.killed) return;
|
|
552
|
+
for (const button of buttons) await exec('xdotool', ['mouseup', String(button)], { env: environment, timeout: 2000 }).catch(() => {});
|
|
553
|
+
if (keys.length) await exec('xdotool', ['keyup', ...keys], { env: environment, timeout: 2000 }).catch(() => {});
|
|
554
|
+
};
|
|
555
|
+
const stopClipboardOwner = async owner => {
|
|
556
|
+
clipboardOwners.delete(owner);
|
|
557
|
+
const child = owner.child;
|
|
558
|
+
if (child.exitCode !== null) return;
|
|
559
|
+
try { child.kill('SIGTERM'); } catch { /* 进程已退出 */ }
|
|
560
|
+
await Promise.race([owner.closed, new Promise(resolve => setTimeout(resolve, 200))]);
|
|
561
|
+
if (child.exitCode === null) { try { child.kill('SIGKILL'); } catch { /* 进程已退出 */ } }
|
|
562
|
+
};
|
|
563
|
+
const releaseClipboardOwners = async () => { await Promise.all([...clipboardOwners].map(stopClipboardOwner)); };
|
|
564
|
+
// xclip must own the selection while the target requests its data. Waiting
|
|
565
|
+
// for it to exit before sending paste deadlocks. Keep a tracked foreground
|
|
566
|
+
// owner, verify its UTF-8 value, then hand it to the caller.
|
|
567
|
+
const ownClipboard = async text => {
|
|
568
|
+
await ensureDisplay();
|
|
569
|
+
// 新写入就是新的 selection;旧的 owner 必须先退出,不能留下两份文本。
|
|
570
|
+
await releaseClipboardOwners();
|
|
571
|
+
const child = spawn('xclip', ['-quiet', '-selection', 'clipboard', '-display', display, '-loops', '0', '-target', 'UTF8_STRING'], { env: environment, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
572
|
+
const owner = { child, stderr: '', closed: new Promise(resolve => child.once('close', resolve)) };
|
|
573
|
+
let spawnFailure;
|
|
574
|
+
child.on('error', error => { spawnFailure = error; });
|
|
575
|
+
child.stderr?.on('data', chunk => { owner.stderr = (owner.stderr + chunk.toString('utf8')).slice(-2048); });
|
|
576
|
+
child.stdin.on('error', () => {});
|
|
577
|
+
child.stdin.end(text, 'utf8');
|
|
578
|
+
try {
|
|
579
|
+
const deadline = Date.now() + 2000;
|
|
580
|
+
let ready = false;
|
|
581
|
+
while (Date.now() < deadline) {
|
|
582
|
+
if (spawnFailure || child.exitCode !== null) throw spawnFailure || new Error(owner.stderr || 'clipboard owner exited');
|
|
583
|
+
try {
|
|
584
|
+
const selected = await exec('xclip', ['-selection', 'clipboard', '-out', '-display', display, '-target', 'UTF8_STRING'], { env: environment, encoding: 'utf8', timeout: 500, maxBuffer: DESKTOP_CLIPBOARD_MAX_BYTES });
|
|
585
|
+
if (selected.stdout === text) { ready = true; break; }
|
|
586
|
+
} catch { /* the owner may still be establishing the selection */ }
|
|
587
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
588
|
+
}
|
|
589
|
+
if (!ready) throw new Error('clipboard selection did not become ready');
|
|
590
|
+
clipboardOwners.add(owner);
|
|
591
|
+
return owner;
|
|
592
|
+
} catch (error) {
|
|
593
|
+
await stopClipboardOwner(owner);
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const readClipboard = async () => {
|
|
598
|
+
await ensureDisplay();
|
|
599
|
+
try {
|
|
600
|
+
const result = await exec('xclip', ['-selection', 'clipboard', '-out', '-display', display, '-target', 'UTF8_STRING'], { env: environment, encoding: 'utf8', timeout: 2000, maxBuffer: 256 * 1024 });
|
|
601
|
+
return truncateUtf8(result?.stdout, DESKTOP_CLIPBOARD_MAX_BYTES);
|
|
602
|
+
} catch {
|
|
603
|
+
// 没有 owner、selection 为空或 X 尚不可用都等价于“没有可读文本”,
|
|
604
|
+
// 读剪贴板不能因为空 selection 打断宿主同步。
|
|
605
|
+
return '';
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const writeClipboard = async text => {
|
|
609
|
+
const value = String(text ?? '');
|
|
610
|
+
try { await ownClipboard(value); }
|
|
611
|
+
catch (error) { throw Object.assign(new Error(`desktop clipboard write failed: ${error.message}`), { code: 'DESKTOP_CLIPBOARD_FAILED', cause: error }); }
|
|
612
|
+
// owner 进程留在后台继续提供 selection,直到下一次写入或桌面停止。
|
|
613
|
+
return { written: true };
|
|
614
|
+
};
|
|
615
|
+
const pasteText = async text => {
|
|
616
|
+
let owner;
|
|
617
|
+
try {
|
|
618
|
+
owner = await ownClipboard(text);
|
|
619
|
+
const requestsSeen = () => Math.max(0, ...Array.from(owner.stderr.matchAll(/Waiting for selection request number (\d+)/gu), match => Number(match[1])));
|
|
620
|
+
const beforePaste = requestsSeen();
|
|
621
|
+
await pressKey('ctrl+shift+v');
|
|
622
|
+
// The key event is queued by X11 before the target asks for selection
|
|
623
|
+
// data. xclip reports that request in quiet mode; keep it alive until
|
|
624
|
+
// this transfer completes, with a bounded fallback for slow clients.
|
|
625
|
+
const deadlinePaste = Date.now() + 1000;
|
|
626
|
+
while (requestsSeen() <= beforePaste && Date.now() < deadlinePaste) await new Promise(resolve => setTimeout(resolve, 10));
|
|
627
|
+
if (requestsSeen() <= beforePaste) throw new Error('focused application did not consume pasted text');
|
|
628
|
+
} catch (error) {
|
|
629
|
+
throw Object.assign(new Error(`desktop clipboard input failed: ${error.message}`), { code: 'DESKTOP_CLIPBOARD_FAILED', cause: error });
|
|
630
|
+
} finally {
|
|
631
|
+
if (owner) await stopClipboardOwner(owner);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const injectInput = async event => {
|
|
635
|
+
if (!event || typeof event !== 'object') throw inputError('event must be an object');
|
|
636
|
+
const type = safeText(event.type, 'event.type', 32);
|
|
637
|
+
if (type === 'pointermove') return runXdotool(['mousemove', String(clamp(event.x, 0, width - 1)), String(clamp(event.y, 0, height - 1))]);
|
|
638
|
+
if (type === 'button') {
|
|
639
|
+
const button = Number(event.button || 1);
|
|
640
|
+
if (![1, 2, 3].includes(button)) throw inputError('button must be 1, 2 or 3');
|
|
641
|
+
if (event.x !== undefined || event.y !== undefined) await runXdotool(['mousemove', String(clamp(event.x, 0, width - 1)), String(clamp(event.y, 0, height - 1))]);
|
|
642
|
+
if (event.action === 'up') { pressedButtons.delete(button); return runXdotool(['mouseup', String(button)]); }
|
|
643
|
+
if (event.action === 'down') { pressedButtons.add(button); return runXdotool(['mousedown', String(button)]); }
|
|
644
|
+
if (event.action === 'double') return runXdotool(['click', '--repeat', '2', '--delay', '100', String(button)]);
|
|
645
|
+
return runXdotool(['click', String(button)]);
|
|
646
|
+
}
|
|
647
|
+
if (type === 'wheel') {
|
|
648
|
+
const amount = clamp(event.deltaY || event.delta || 0, -20, 20);
|
|
649
|
+
const button = amount < 0 ? 4 : 5;
|
|
650
|
+
return runXdotool(['click', '--repeat', String(Math.max(1, Math.min(20, Math.abs(amount)))), String(button)]);
|
|
651
|
+
}
|
|
652
|
+
if (type === 'text') {
|
|
653
|
+
const text = safeText(event.text, 'event.text', 16 * 1024);
|
|
654
|
+
if (text.length <= 64 && /^[\x20-\x7e]+$/u.test(text)) {
|
|
655
|
+
// 只有没有物理按住的键/鼠标时才清修饰键,避免 Shift+点击/拖动被破坏。
|
|
656
|
+
const clearModifiers = pressedKeys.size === 0 && pressedButtons.size === 0 ? ['--clearmodifiers'] : [];
|
|
657
|
+
try { return await runXdotool(['type', ...clearModifiers, '--delay', '0', '--', text]); }
|
|
658
|
+
catch (error) { throw Object.assign(new Error(`desktop text input failed: ${error.message}`), { code: 'DESKTOP_TEXT_INPUT_FAILED', cause: error }); }
|
|
659
|
+
}
|
|
660
|
+
return pasteText(text);
|
|
661
|
+
}
|
|
662
|
+
if (type === 'key') {
|
|
663
|
+
return pressKey(event.key, event.action || 'tap');
|
|
664
|
+
}
|
|
665
|
+
throw inputError(`unsupported desktop input type: ${type}`);
|
|
666
|
+
};
|
|
667
|
+
const touchActivity = () => { lastActivityAt = now(); for (const session of sessions.values()) session.lastInputAt = now(); };
|
|
668
|
+
const encodeFrame = (bytes, session, frameSequence) => ({ sessionId: session.sessionId, seq: frameSequence ?? ++sequence, format: 'jpeg', width, height, bytes: bytes.length, data: bytes });
|
|
669
|
+
const describe = session => ({ sessionId: session.sessionId, readOnly: !session.controllable, controllable: session.controllable, controlEpoch: session.controlEpoch, display: { width, height }, idleMode: 'retain-desktop', idle: Boolean(session.idle), idleTimeoutMs, idleRetentionMs, frameMaxBytes: DESKTOP_MAX_FRAME_BYTES, captureFps: DESKTOP_CAPTURE_FPS });
|
|
670
|
+
const openSession = async ({ identity, botId = null, controllable = false, leaseId = null, taskId = null, releaseLeaseOnIdle = false } = {}) => {
|
|
671
|
+
await ensureDisplay();
|
|
672
|
+
lastActivityAt = now();
|
|
673
|
+
const sessionId = randomUUID();
|
|
674
|
+
const session = { sessionId, identity: Object.freeze({ ...identity }), botId, taskId, controllable: Boolean(controllable), leaseId, releaseLeaseOnIdle: Boolean(releaseLeaseOnIdle), controlEpoch: 0, idle: false, createdAt: now(), lastInputAt: now(), lastFrameAt: 0, lastFrame: null };
|
|
675
|
+
sessions.set(sessionId, session);
|
|
676
|
+
return describe(session);
|
|
677
|
+
};
|
|
678
|
+
const getSession = sessionId => sessions.get(String(sessionId || ''));
|
|
679
|
+
const idleError = () => Object.assign(new Error('desktop session is idle'), { code: 'DESKTOP_SESSION_IDLE', status: 409 });
|
|
680
|
+
const suspendSession = async sessionId => {
|
|
681
|
+
const session = getSession(sessionId);
|
|
682
|
+
if (!session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
683
|
+
if (session.idleTransition) await session.idleTransition;
|
|
684
|
+
if (session.idle) return describe(session);
|
|
685
|
+
const owner = { sessionId: session.sessionId, botId: session.botId, taskId: session.taskId, leaseId: session.leaseId, releaseLeaseOnIdle: session.releaseLeaseOnIdle };
|
|
686
|
+
session.idle = true; session.idleAt = now(); session.controllable = false; session.leaseId = null; session.lastFrame = null; session.controlEpoch += 1;
|
|
687
|
+
flushPendingText();
|
|
688
|
+
session.idleTransition = (async () => {
|
|
689
|
+
if (inputOwner === session.sessionId) await enqueueInput(releasePressedInput);
|
|
690
|
+
// 输入已释放后才能让其他 Bot 获得租约;不能先删租约再释放修饰键。
|
|
691
|
+
await onIdle?.(owner);
|
|
692
|
+
if (![...sessions.values()].some(value => !value.idle)) await stopCollector();
|
|
693
|
+
})();
|
|
694
|
+
try { await session.idleTransition; } finally { session.idleTransition = null; }
|
|
695
|
+
return describe(session);
|
|
696
|
+
};
|
|
697
|
+
const resumeSession = async ({ sessionId, controllable = false, leaseId = null, releaseLeaseOnIdle = false } = {}) => {
|
|
698
|
+
const session = getSession(sessionId);
|
|
699
|
+
if (!session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
700
|
+
await session.idleTransition;
|
|
701
|
+
if (!session.idle) throw Object.assign(new Error('desktop session is already active'), { code: 'DESKTOP_SESSION_ALREADY_ACTIVE', status: 409 });
|
|
702
|
+
await ensureDisplay();
|
|
703
|
+
if (getSession(sessionId) !== session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
704
|
+
session.idle = false; session.idleAt = null; session.controllable = Boolean(controllable); session.leaseId = leaseId;
|
|
705
|
+
session.controlEpoch += 1;
|
|
706
|
+
session.releaseLeaseOnIdle = Boolean(releaseLeaseOnIdle); session.lastInputAt = now(); session.lastFrameAt = 0; lastActivityAt = now();
|
|
707
|
+
return describe(session);
|
|
708
|
+
};
|
|
709
|
+
const frameBinary = async ({ sessionId, afterSeq } = {}) => {
|
|
710
|
+
const session = getSession(sessionId);
|
|
711
|
+
if (!session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
712
|
+
if (session.idle) { if (![...sessions.values()].some(value => !value.idle)) await stopCollector(); throw idleError(); }
|
|
713
|
+
await ensureDisplay();
|
|
714
|
+
if (getSession(sessionId) !== session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
715
|
+
if (session.idle) throw idleError();
|
|
716
|
+
const nowValue = now();
|
|
717
|
+
const active = nowValue - session.lastInputAt < DESKTOP_ACTIVITY_WINDOW_MS;
|
|
718
|
+
const cadence = { nextFrameDelayMs: active ? DESKTOP_ACTIVE_INTERVAL_MS : DESKTOP_IDLE_INTERVAL_MS, captureFps: DESKTOP_CAPTURE_FPS };
|
|
719
|
+
if (!active && session.lastFrame && nowValue - session.lastFrameAt < DESKTOP_IDLE_INTERVAL_MS) return { ...session.lastFrame, ...cadence, sessionId, unchanged: true, afterSeq: Number(afterSeq || 0) };
|
|
720
|
+
const bytes = await captureFrame();
|
|
721
|
+
if (getSession(sessionId) !== session) { if (sessions.size === 0) await stopCollector(); throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 }); }
|
|
722
|
+
if (session.idle) { if (![...sessions.values()].some(value => !value.idle)) await stopCollector(); throw idleError(); }
|
|
723
|
+
if (!Buffer.isBuffer(bytes) || bytes.length > DESKTOP_MAX_FRAME_BYTES) throw Object.assign(new Error(`desktop frame exceeds ${DESKTOP_MAX_FRAME_BYTES} bytes`), { code: 'DESKTOP_FRAME_TOO_LARGE' });
|
|
724
|
+
const output = { ...encodeFrame(bytes, session, capture ? undefined : latestFrame.seq), ...cadence };
|
|
725
|
+
if (Number(afterSeq || 0) >= output.seq) return { ...output, unchanged: true };
|
|
726
|
+
session.lastFrame = output;
|
|
727
|
+
session.lastFrameAt = nowValue;
|
|
728
|
+
return output;
|
|
729
|
+
};
|
|
730
|
+
const frame = async options => { const result = await frameBinary(options); return { ...result, data: result.data?.toString('base64') }; };
|
|
731
|
+
const input = async ({ sessionId, event, controlEpoch } = {}) => {
|
|
732
|
+
const session = getSession(sessionId);
|
|
733
|
+
if (!session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
734
|
+
if (session.idle) throw idleError();
|
|
735
|
+
if (!session.controllable || !session.leaseId) throw Object.assign(new Error('desktop session is read-only'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
|
|
736
|
+
const inputLease = session.leaseId;
|
|
737
|
+
const inputEpoch = controlEpoch ?? session.controlEpoch;
|
|
738
|
+
const validateControl = () => {
|
|
739
|
+
if (!getSession(sessionId)) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
740
|
+
if (session.idle || !session.controllable || session.leaseId !== inputLease || session.controlEpoch !== inputEpoch) throw Object.assign(new Error('desktop control lease changed'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
|
|
741
|
+
};
|
|
742
|
+
validateControl();
|
|
743
|
+
const execute = async inputEvent => {
|
|
744
|
+
validateControl();
|
|
745
|
+
touchActivity(); inputOwner = sessionId;
|
|
746
|
+
await injectInput(inputEvent);
|
|
747
|
+
return { accepted: true, sessionId, at: now() };
|
|
748
|
+
};
|
|
749
|
+
if (event?.type === 'text') {
|
|
750
|
+
const text = safeText(event.text, 'event.text', 16 * 1024);
|
|
751
|
+
if (pendingText?.sessionId === sessionId && pendingText.leaseId === inputLease && pendingText.epoch === inputEpoch && pendingText.text.length + text.length <= 16 * 1024) {
|
|
752
|
+
pendingText.text += text;
|
|
753
|
+
return pendingText.result;
|
|
754
|
+
}
|
|
755
|
+
flushPendingText();
|
|
756
|
+
let flush;
|
|
757
|
+
const ready = new Promise(resolve => { flush = resolve; });
|
|
758
|
+
const batch = { sessionId, leaseId: inputLease, epoch: inputEpoch, text, flush };
|
|
759
|
+
pendingText = batch;
|
|
760
|
+
batch.timer = setTimeout(() => { if (pendingText === batch) flushPendingText(); }, DESKTOP_TEXT_COALESCE_MS);
|
|
761
|
+
batch.result = enqueueInput(async () => { await ready; return execute({ type: 'text', text: batch.text }); });
|
|
762
|
+
return batch.result;
|
|
763
|
+
}
|
|
764
|
+
flushPendingText();
|
|
765
|
+
return enqueueInput(() => execute(event));
|
|
766
|
+
};
|
|
767
|
+
const setSessionControl = async ({ sessionId, controllable = false, leaseId = null, taskId, controlEpoch } = {}) => {
|
|
768
|
+
const session = getSession(sessionId);
|
|
769
|
+
if (!session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
770
|
+
if (controlEpoch !== undefined && controlEpoch !== session.controlEpoch) throw Object.assign(new Error('desktop control lease changed'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
|
|
771
|
+
// 先封住旧代次,再等待已开始的输入结束并清键;旧排队事件不得借新租约执行。
|
|
772
|
+
const epoch = ++session.controlEpoch;
|
|
773
|
+
session.controllable = false; session.leaseId = null;
|
|
774
|
+
flushPendingText();
|
|
775
|
+
return enqueueInput(async () => {
|
|
776
|
+
if (inputOwner === sessionId) await releasePressedInput();
|
|
777
|
+
if (getSession(sessionId) !== session) throw Object.assign(new Error('desktop session not found'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
|
|
778
|
+
if (session.controlEpoch !== epoch) throw Object.assign(new Error('desktop control lease changed'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
|
|
779
|
+
session.controllable = Boolean(controllable && leaseId); session.leaseId = session.controllable ? leaseId : null;
|
|
780
|
+
if (taskId !== undefined) session.taskId = taskId;
|
|
781
|
+
return describe(session);
|
|
782
|
+
});
|
|
783
|
+
};
|
|
784
|
+
const closeSession = sessionId => {
|
|
785
|
+
const id = String(sessionId || ''), removed = sessions.delete(id);
|
|
786
|
+
if (pendingText?.sessionId === id) flushPendingText();
|
|
787
|
+
if (inputOwner === id) void enqueueInput(releasePressedInput);
|
|
788
|
+
if (removed && ![...sessions.values()].some(value => !value.idle)) void stopCollector();
|
|
789
|
+
if (removed && sessions.size === 0) lastActivityAt = now();
|
|
790
|
+
return removed;
|
|
791
|
+
};
|
|
792
|
+
const closeSessionsForLease = leaseId => { for (const [id, session] of sessions) if (session.leaseId === leaseId) closeSession(id); };
|
|
793
|
+
const reap = () => {
|
|
794
|
+
for (const [id, session] of sessions) {
|
|
795
|
+
if (session.idle && now() - session.idleAt >= idleRetentionMs) closeSession(id);
|
|
796
|
+
// 活跃会话本身就是观察者;没有输入也不能收回用户正在看的桌面。
|
|
797
|
+
}
|
|
798
|
+
// 没有观察者时仅停采集;X/WM/应用保持到 Pod 退出,恢复后还是原窗口。
|
|
799
|
+
if (sessions.size === 0) void stopCollector();
|
|
800
|
+
};
|
|
801
|
+
const stopDisplay = () => {
|
|
802
|
+
if (displayStopping) return displayStopping;
|
|
803
|
+
const stopping = (async () => {
|
|
804
|
+
await stopLauncher({ permanent: false });
|
|
805
|
+
await releasePressedInput();
|
|
806
|
+
await releaseClipboardOwners();
|
|
807
|
+
await stopCollector();
|
|
808
|
+
const processes = [dockProcess, fileManagerProcess, wm, terminalProcess, xvfb];
|
|
809
|
+
dockProcess = undefined; fileManagerProcess = undefined; wm = undefined; terminalProcess = undefined; xvfb = undefined; displayReady = undefined;
|
|
810
|
+
await Promise.all(processes.map(stopProcess));
|
|
811
|
+
const directory = wmConfiguration; wmConfiguration = undefined;
|
|
812
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
813
|
+
await onStop?.();
|
|
814
|
+
})();
|
|
815
|
+
displayStopping = stopping;
|
|
816
|
+
void stopping.finally(() => { if (displayStopping === stopping) displayStopping = undefined; }).catch(() => {});
|
|
817
|
+
return stopping;
|
|
818
|
+
};
|
|
819
|
+
const dispose = () => { disposed = true; sessions.clear(); flushPendingText(); clearInterval(reaper); return inputQueue.then(stopDisplay); };
|
|
820
|
+
const stats = () => ({ display, width, height, sessions: sessions.size, idleSessions: [...sessions.values()].filter(value => value.idle).length, xserverRunning: Boolean(xvfb && xvfb.exitCode === null), windowManagerRunning: Boolean(wm && wm.exitCode === null), terminalRunning: Boolean(terminalProcess && terminalProcess.exitCode === null), dockRunning: Boolean(dockProcess && dockProcess.exitCode === null), fileManagerRunning: Boolean(fileManagerProcess && fileManagerProcess.exitCode === null), collectorRunning: Boolean(collector && collector.exitCode === null), collectorPid: collector?.pid, captureFps: DESKTOP_CAPTURE_FPS, capturedFrames, droppedFrames, lastActivityAt });
|
|
821
|
+
const reaper = setInterval(reap, 1000);
|
|
822
|
+
reaper.unref?.();
|
|
823
|
+
const windows = async () => {
|
|
824
|
+
await ensureDisplay();
|
|
825
|
+
const result = await exec('xprop', ['-root', '_NET_CLIENT_LIST'], { env: environment, timeout: 2000 });
|
|
826
|
+
const ids = [...new Set(String(result.stdout).match(/0x[0-9a-f]+/giu) || [])].filter(id => Number(id) > 0).slice(0, 64);
|
|
827
|
+
return Promise.all(ids.map(async windowId => {
|
|
828
|
+
const name = await exec('xdotool', ['getwindowname', windowId], { env: environment, timeout: 2000 });
|
|
829
|
+
return { windowId, title: String(name.stdout).trim().slice(0, 512) };
|
|
830
|
+
}));
|
|
831
|
+
};
|
|
832
|
+
const focusWindow = async (windowId, beforeCommand) => {
|
|
833
|
+
const id = String(windowId || '');
|
|
834
|
+
if (!/^0x[0-9a-f]{1,16}$/iu.test(id) || !(await windows()).some(row => Number(row.windowId) === Number(id))) throw inputError('windowId must identify a current desktop window');
|
|
835
|
+
beforeCommand?.();
|
|
836
|
+
await exec('xdotool', ['windowactivate', '--sync', id], { env: environment, timeout: 2000 });
|
|
837
|
+
return { windowId: id, focused: true };
|
|
838
|
+
};
|
|
839
|
+
return Object.freeze({ openSession, getSession, suspendSession, resumeSession, setSessionControl, frame, frameBinary, input, closeSession, closeSessionsForLease, ensureDisplay, stopLauncher, dispose, stats, injectInput, captureFrame, windows, focusWindow, launchApplication, readClipboard, writeClipboard, get welcomeUrl() { return welcomeUrl; } });
|
|
840
|
+
}
|