@twinsearth/w2m-dsh-plugin 2.0.9 → 2.1.2
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/bin/remote-s2-capture.mjs +145 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.10/RELEASE-2100.json +19 -0
- package/docs/plans/REMOTE-DESKTOP-2.1.0.md +153 -0
- package/lib/client.js +180 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +11 -0
- package/src/agent/view/capture.mjs +111 -0
- package/src/agent/view/frame-channel.mjs +114 -0
- package/src/plugin/remote-view-route.mjs +144 -0
- package/src/plugin/tools.mjs +8 -3
- package/src/relay/remote-media.mjs +87 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "2.
|
|
8
|
+
"version": "2.1.2"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.1.2",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.
|
|
29
|
+
"version": "2.1.2"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.1.2] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
- Remote-view preview: settings toggle, iMac sidebar entry, local 1fps JPEG viewer, target capture module, P2P view frame protocol, host /remote/start|frame|stop routes, encrypted-relay media broker (safety core).
|
|
13
|
+
- Known gaps: P1.5 relay media is not wired end-to-end; peerKey/consent UI and real remote-desktop verification are pending. This release is a test candidate, not feature-complete.
|
|
14
|
+
|
|
15
|
+
## [2.1.0] - 2026-10-31
|
|
16
|
+
|
|
17
|
+
- Remote-view planning: REMOTE-DESKTOP-2.1.0.md and S2 capture probe (bin/remote-s2-capture.mjs).
|
|
18
|
+
- Packaging: GitHub Packages artifact published as 2.1.0 for version alignment.
|
|
19
|
+
- Status: the remote-desktop UI (settings toggle, iMac sidebar tab, viewer) is **not implemented yet**; 2.1.0 must not be described as feature-complete.
|
|
20
|
+
|
|
21
|
+
## [2.0.10] - 2026-10-31
|
|
22
|
+
|
|
23
|
+
- remote-view S2: ship scripts/remote-s2-capture.mjs so macOS capture/TCC probes run as a normal script (no node -e, no reparse refusal); measures 1/2 fps capture timing, JPEG size, ffmpeg presence, writes w2m-s2-result.json and supports exit-code summary.
|
|
24
|
+
- tests: remote-s2-capture summary/platform contract.
|
|
25
|
+
|
|
10
26
|
## [2.0.9] - 2026-10-30
|
|
11
27
|
|
|
12
28
|
- P2P 可靠性:ACK 驱动窗口、自适应窗口、RFC 6298 RTT/自适应 RTO;明文通道复用改为强制新握手。
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* S2: macOS screen capture probe for the remote-view P1 decision.
|
|
4
|
+
*
|
|
5
|
+
* Runs WITHOUT `node -e` (the agent refuses code-carrying argv under
|
|
6
|
+
* COMMAND_REPARSE), so it can be dispatched as:
|
|
7
|
+
* node <plugin>/scripts/remote-s2-capture.mjs --json
|
|
8
|
+
* node <plugin>/scripts/remote-s2-capture.mjs --exit-summary
|
|
9
|
+
*
|
|
10
|
+
* It measures two captures (spaced 1/fps), probes optional ffmpeg, guesses TCC
|
|
11
|
+
* denial/blank from exit status + JPEG size, writes ~/w2m-s2-result.json, and
|
|
12
|
+
* prints the same JSON. --exit-summary encodes the result into the exit code:
|
|
13
|
+
* 100 + bit0 ffmpeg | bit1 first ok | bit2 first >20KiB | bit3 first <500ms
|
|
14
|
+
* | bit4 second ok | bit5 second <500ms
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
|
|
22
|
+
export function parseArgs(argv) {
|
|
23
|
+
const out = { fps: 1, exitSummary: false, json: false, keep: false };
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
const arg = argv[i];
|
|
26
|
+
if (arg === '--fps') { out.fps = Number(argv[i + 1]) === 2 ? 2 : 1; i += 1; }
|
|
27
|
+
else if (arg === '--exit-summary') out.exitSummary = true;
|
|
28
|
+
else if (arg === '--json') out.json = true;
|
|
29
|
+
else if (arg === '--keep') out.keep = true;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function summaryBits(result) {
|
|
35
|
+
let bits = 0;
|
|
36
|
+
if (result?.ffmpeg?.present === true) bits |= 1;
|
|
37
|
+
const a = result?.captures?.[0];
|
|
38
|
+
const b = result?.captures?.[1];
|
|
39
|
+
if (a?.ok === true) bits |= 2;
|
|
40
|
+
if ((a?.bytes ?? 0) > 20 * 1024) bits |= 4;
|
|
41
|
+
if (Number.isFinite(a?.ms) && a.ms < 500) bits |= 8;
|
|
42
|
+
if (b?.ok === true) bits |= 16;
|
|
43
|
+
if (Number.isFinite(b?.ms) && b.ms < 500) bits |= 32;
|
|
44
|
+
return bits;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function captureWindows() {
|
|
48
|
+
const script = [
|
|
49
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
50
|
+
'$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
|
|
51
|
+
'$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
|
|
52
|
+
'$g=[System.Drawing.Graphics]::FromImage($bmp)',
|
|
53
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size)',
|
|
54
|
+
'$ms=New-Object System.IO.MemoryStream',
|
|
55
|
+
'$bmp.Save($ms,[System.Drawing.Imaging.ImageFormat]::Jpeg)',
|
|
56
|
+
'[Convert]::ToBase64String($ms.ToArray())',
|
|
57
|
+
].join(';');
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const run = spawnSync('powershell.exe', ['-NoProfile','-NonInteractive','-STA','-ExecutionPolicy','Bypass','-Command', script], { timeout: 20000, encoding: 'utf8' });
|
|
60
|
+
const ms = Date.now() - started;
|
|
61
|
+
const base64 = String(run.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
62
|
+
const bytes = /^[A-Za-z0-9+/=]{100,}$/.test(base64) ? Buffer.from(base64, 'base64').length : 0;
|
|
63
|
+
return { ok: run.status === 0 && bytes > 0, status: run.status, ms, bytes, width: null, height: null, error: run.status === 0 ? null : String(run.stderr ?? '').slice(0, 300) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function capture(file, dimensions = true) {
|
|
67
|
+
const started = Date.now();
|
|
68
|
+
const run = spawnSync('screencapture', ['-x', '-t', 'jpg', file], { timeout: 15000, encoding: 'utf8' });
|
|
69
|
+
const ms = Date.now() - started;
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
if (existsSync(file)) { try { bytes = statSync(file).size; } catch { bytes = 0; } }
|
|
72
|
+
let width = null; let height = null;
|
|
73
|
+
if (dimensions && bytes > 0) {
|
|
74
|
+
const sips = spawnSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeout: 5000, encoding: 'utf8' });
|
|
75
|
+
const w = /pixelWidth:\s*(\d+)/.exec(sips.stdout ?? '');
|
|
76
|
+
const h = /pixelHeight:\s*(\d+)/.exec(sips.stdout ?? '');
|
|
77
|
+
width = w === null ? null : Number(w[1]);
|
|
78
|
+
height = h === null ? null : Number(h[1]);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ok: run.status === 0 && bytes > 0,
|
|
82
|
+
status: run.status,
|
|
83
|
+
ms,
|
|
84
|
+
bytes,
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
error: run.status === 0 ? null : (run.stderr || 'screencapture failed').toString().slice(0, 300),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main() {
|
|
92
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
93
|
+
const result = {
|
|
94
|
+
ok: false,
|
|
95
|
+
at: new Date().toISOString(),
|
|
96
|
+
platform: process.platform,
|
|
97
|
+
fps: opts.fps,
|
|
98
|
+
ffmpeg: { present: false, version: null },
|
|
99
|
+
captures: [],
|
|
100
|
+
tcc_guess: 'unknown',
|
|
101
|
+
code: null,
|
|
102
|
+
};
|
|
103
|
+
if (process.platform === 'win32') {
|
|
104
|
+
result.captures.push(captureWindows());
|
|
105
|
+
spawnSync('ping', ['-n', opts.fps === 2 ? '1' : '2', '127.0.0.1'], { timeout: 5000 });
|
|
106
|
+
result.captures.push(captureWindows());
|
|
107
|
+
result.tcc_guess = 'not-applicable-windows';
|
|
108
|
+
result.ok = result.captures.every((entry) => entry.ok === true);
|
|
109
|
+
result.code = result.ok ? 'S2_CAPTURE_OK' : 'S2_CAPTURE_FAILED';
|
|
110
|
+
finish(result, opts, result.ok ? 0 : 3);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (process.platform !== 'darwin') {
|
|
114
|
+
result.code = 'S2_UNSUPPORTED_PLATFORM';
|
|
115
|
+
finish(result, opts, 2);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ff = spawnSync('ffmpeg', ['-version'], { timeout: 1500, encoding: 'utf8' });
|
|
119
|
+
result.ffmpeg = { present: ff.status === 0, version: ff.status === 0 ? String(ff.stdout).split('\n')[0].slice(0, 120) : null };
|
|
120
|
+
|
|
121
|
+
const dir = join(homedir(), 'w2m-s2');
|
|
122
|
+
const fileA = join(dir, 'frame-1.jpg');
|
|
123
|
+
const fileB = join(dir, 'frame-2.jpg');
|
|
124
|
+
try { spawnSync('mkdir', ['-p', dir], { timeout: 5000 }); } catch { /* best effort */ }
|
|
125
|
+
result.captures.push(capture(fileA));
|
|
126
|
+
spawnSync('sleep', [String(opts.fps === 2 ? 0.5 : 1.0)], { timeout: 5000 });
|
|
127
|
+
result.captures.push(capture(fileB));
|
|
128
|
+
|
|
129
|
+
const a = result.captures[0];
|
|
130
|
+
result.tcc_guess = a.status === 0 && a.bytes > 5000 ? 'granted-or-desktop-visible' : 'denied-or-blank';
|
|
131
|
+
result.ok = a.ok === true && result.captures[1].ok === true;
|
|
132
|
+
result.code = result.ok ? 'S2_CAPTURE_OK' : 'S2_CAPTURE_FAILED';
|
|
133
|
+
finish(result, opts, result.ok ? 0 : 3);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function finish(result, opts, normalCode) {
|
|
137
|
+
const out = join(homedir(), 'w2m-s2-result.json');
|
|
138
|
+
try { writeFileSync(out, JSON.stringify(result, null, 2)); result.result_file = out; } catch { /* stdout only */ }
|
|
139
|
+
if (opts.json || !opts.exitSummary) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
140
|
+
if (opts.exitSummary) process.exit(100 + summaryBits(result));
|
|
141
|
+
process.exit(normalCode);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const invoked = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
145
|
+
if (invoked) main();
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.10",
|
|
3
|
+
"case": "RELEASE-2100",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-30T21:19:15.294Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2100-1793394641623",
|
|
11
|
+
"requirement": "v2.0.10: ship remote-view S2 capture probe as a script (no node -e)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"s2_script": "bin/remote-s2-capture.mjs shipped (scripts/ is forbidden in the release tarball); summaryBits/platform test 2/2",
|
|
16
|
+
"preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE; 3299 tests / 244 files"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# v2.1.0 远程桌面子插件(仅查看)落地方案
|
|
2
|
+
|
|
3
|
+
状态:DRAFT,待 P0 spike 与决策确认;未改代码、未发布。
|
|
4
|
+
专家评审:国内网络架构工程师 + 视频流工程师(只读)。
|
|
5
|
+
目标:设置页开关;开启后侧边栏出现 iMac 图标标签;仅查看、不可控制;后续版本再开放鼠标操控。
|
|
6
|
+
|
|
7
|
+
## 1. 结论
|
|
8
|
+
|
|
9
|
+
1. v2.1.0 的现实目标是 **加密、可用的 1–2 fps 查看**,不是 RustDesk 级 30fps 远控;10fps+ 需要 sidecar/不可靠传输,排 P4。
|
|
10
|
+
2. **不 vendor RustDesk/hbb_common**(AGPL 边界 + 与现有 P2P 重复);只借鉴分层与自适应思路,sidecar 必须可选、独立进程、外部二进制。
|
|
11
|
+
3. 先做 P0 三个 spike(浏览器流、macOS TCC/采集、VK 握手),再写 UI;UI 是最不会失败的部分。
|
|
12
|
+
4. 媒体 E2E 加密,relay 只做信令/授权/密文回退;目标端默认拒绝,每次会话显式同意。
|
|
13
|
+
5. v2.1.0 不做:输入控制、剪贴板、文件、音频、录制、无人值守、多查看者、Windows 被查看端。
|
|
14
|
+
|
|
15
|
+
## 2. 分层
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
L5 浏览器:sidebar iMac 标签 + canvas 查看页 + 设置页开关
|
|
19
|
+
L4 DSH 宿主:remote-view route/service/session(同源 /api/w2m/remote/*)
|
|
20
|
+
L3 agent:view 会话、采集、同意、帧加密(view.* P2P 路由)
|
|
21
|
+
L2 传输:P2PChannel(优先)| relay 密文帧(回退);VK 会话加密
|
|
22
|
+
L1 relay:/v1/remote/signal、/v1/remote/stream、/v1/remote/frame(只密文)
|
|
23
|
+
L0 平台:macOS screencapture/sips(P0)→ ScreenCaptureKit(P3/P4)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
同包内子模块 `src/plugin/remote-view/*` + `src/agent/view/*`,不拆 npm 包;单版本、单 patch row。
|
|
27
|
+
|
|
28
|
+
## 3. UI 接口
|
|
29
|
+
|
|
30
|
+
- `settings.section`:远程桌面开关(默认关闭),写 `<stateDir>/remote-view.json`(0600)。
|
|
31
|
+
- `sidebar.panellist`:iMac 图标,仅开关开启时渲染;关闭时返回 null。
|
|
32
|
+
- `main`:查看页(目标选择、同意状态、canvas、直连/中继徽标、停止、“仅查看”常驻说明)。
|
|
33
|
+
- 每个 slot 独立 try/catch,不新增 inject;动态显隐需 P0 验证外壳是否折叠空行,失败则提示刷新。
|
|
34
|
+
|
|
35
|
+
## 4. 信令与安全
|
|
36
|
+
|
|
37
|
+
- 身份:fleet 准入(relay)+ X25519 device key(通道认证)+ 临时 X25519 三重 DH 派生 VK(媒体 E2E)。
|
|
38
|
+
- invite/accept:`k_mac = HKDF(ECDH(static keys))` 密封;VK 派生后媒体/控制分方向密钥。
|
|
39
|
+
- 目标端授权:默认拒绝;每次 `osascript display dialog`,30s 超时;可选内存态“允许 30 分钟”,不落盘。
|
|
40
|
+
- relay:同 fleet 校验;只接受 `{session_id, seq, ct}`;拒绝 JPEG/PNG magic、明文 JSON、输入类型;TLS 强制(否则 `VIEW_RELAY_INSECURE`)。
|
|
41
|
+
- 错误码:VIEW_DISABLED/TARGET_BUSY/NO_ANSWER/CONSENT_DENIED/P2P_TIMEOUT/CAPTURE_DENIED/PLAINTEXT_MEDIA_REFUSED 等。
|
|
42
|
+
|
|
43
|
+
## 5. 视频链路(专家修订版)
|
|
44
|
+
|
|
45
|
+
### 5.1 安全上下文:不是部署要求
|
|
46
|
+
|
|
47
|
+
页面从本机 DSH GUI(`127.0.0.1`)加载,属于 potentially trustworthy origin;媒体帧由目标机经 P2P/relay 到本机宿主,再由同源 route 给浏览器。因此:
|
|
48
|
+
|
|
49
|
+
| 拓扑 | 页面 origin | isSecureContext | WebCodecs | P1 |
|
|
50
|
+
|---|---|---|---|---|
|
|
51
|
+
| A 本机 DSH 面板 | 127.0.0.1 | true | 可用 | 主路径 |
|
|
52
|
+
| B 本机 DSH + HTTPS | https | true | 可用 | 可选 |
|
|
53
|
+
| C 局域网直开 DSH | http://192.168.x.x | false | 不可用 | 仅 JPEG 档 |
|
|
54
|
+
| D 局域网 HTTPS 反代 | https | true | 可用 | 需证书运维 |
|
|
55
|
+
|
|
56
|
+
P1 不要求 HTTPS;运行时探针 `isSecureContext / VideoDecoder / createImageBitmap`,C 拓扑只走 JPEG。不要把整个 DSH GUI 绑到 0.0.0.0;局域网只读查看应使用独立端口 + 一次性 token。
|
|
57
|
+
|
|
58
|
+
### 5.2 渲染:SSE + createImageBitmap(P1 默认)
|
|
59
|
+
|
|
60
|
+
每帧独立 JPEG/WebP/AVIF,经同源 SSE 下发:
|
|
61
|
+
`{ session_id, seq, ts_ms, w, h, mime, bytes, data_b64 }`;无变化发 `view.unchanged`。
|
|
62
|
+
前端 `createImageBitmap(new Blob([bytes]))` → canvas `drawImage`;不需要 WebCodecs,不需要 HTTPS。
|
|
63
|
+
“最新帧优先”:上一帧未投递完就丢新帧(dropped++),不排队。
|
|
64
|
+
|
|
65
|
+
### 5.3 编解码:帧内优先,视频档可选
|
|
66
|
+
|
|
67
|
+
| 层 | 编码 | 1–2fps 收益 | 结论 |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| 帧内 | JPEG | 地板,永可用 | P1 默认 |
|
|
70
|
+
| 帧内 | WebP | 体积 0.65–0.75× | P1.5 升级 |
|
|
71
|
+
| 帧内 | AVIF/HEIC | 0.5–0.7×,编码 100–300ms | P2(需 ffmpeg/Safari) |
|
|
72
|
+
| 帧间 | H.264/HEVC/AV1 | 1–2fps 下 IDR/GOP 成本抵消收益 | 可选加速档,非地板 |
|
|
73
|
+
| 帧间 | VP9 | 无 Apple 硬编 | 不建议 |
|
|
74
|
+
|
|
75
|
+
“不接受仅 H.264”的落法:**P1 的媒体地板不是 H.264,而是 JPEG**;HEVC/AV1 作为探测后的可选加速档,缺失不影响可用性。
|
|
76
|
+
1–2fps 最有效的省流不是换编码器,而是“画面未变”抑制(可砍 50–80% 帧)。
|
|
77
|
+
|
|
78
|
+
### 5.4 macOS 采集
|
|
79
|
+
|
|
80
|
+
- P1:`screencapture -x -t jpg -D 1`(1fps 稳,2fps 边际);relay 档再加 `sips -Z 1024 -s formatOptions 45`。
|
|
81
|
+
- 2fps 可选:`ffmpeg -f avfoundation -framerate 2 -c:v mjpeg` 常驻,外部二进制、不打包、缺失即回落。
|
|
82
|
+
- TCC:Screen Recording 按责任进程归属;必须区分 `CAPTURE_DENIED / CAPTURE_BLANK / CAPTURE_FAILED`,目标端 GUI 给系统设置跳转。
|
|
83
|
+
- 采集 argv 固定,不走 allow-list。
|
|
84
|
+
|
|
85
|
+
### 5.5 同意 UI(目标端 DSH GUI)
|
|
86
|
+
|
|
87
|
+
目标端也跑 DSH GUI:同意弹窗显示 viewer/屏幕/分辨率/fps/时长;允许后常驻“正在被查看”横幅 + 停止按钮;默认拒绝、30s 超时、内存态授权 ≤30min;锁屏/关闭面板/停止 → 停采 ≤1s。headless 才用 osascript 兜底。
|
|
88
|
+
|
|
89
|
+
### 5.6 帧预算与传输
|
|
90
|
+
|
|
91
|
+
| 通道 | 单帧 raw 上限 | 目标 | 默认 fps | 超限行为 |
|
|
92
|
+
|---|---|---|---|---|
|
|
93
|
+
| P2P | 96 KiB | 64 KiB | 1(P1 只承诺 1fps) | 降分辨率,不分块 |
|
|
94
|
+
| relay | 47 KiB 硬顶 | 40 KiB | 1 | 降分辨率/1fps |
|
|
95
|
+
|
|
96
|
+
- relay 只接受 `{session_id, seq, ct}`;拒绝 JPEG/PNG magic、明文 JSON、输入类型;媒体必须 E2E 加密(TLS 不能替代)。
|
|
97
|
+
- 不把帧塞进 `/v1/fleet/messages`(4 KiB + 配额 + 通知不带正文)。
|
|
98
|
+
- 新 feature:`remote-frame-v1`;老 relay → `VIEW_RELAY_UNSUPPORTED`,不发第一帧。
|
|
99
|
+
- P1 建议:P2P 媒体 + relay 信令;relay 密文帧放 P1.5(40 KiB 清晰度有限,先做逃生门)。
|
|
100
|
+
|
|
101
|
+
### 5.7 P0 spike(3–4 人日)
|
|
102
|
+
|
|
103
|
+
1. S1 JPEG→canvas 1–2fps 渲染(≤30ms/帧);
|
|
104
|
+
2. S2 macOS 采集 1/2fps + TCC 三形态(先跑,唯一能推翻 fps 目标);
|
|
105
|
+
3. S3 安全上下文矩阵(127.0.0.1 vs 局域网 IP);
|
|
106
|
+
4. S4 P2P 40/64/96/128 KiB 单帧预算;
|
|
107
|
+
5. S5 relay 47 KiB 边界/413/magic 拒绝;
|
|
108
|
+
6. S6 同意/撤销六态。
|
|
109
|
+
|
|
110
|
+
## 6. 阶段
|
|
111
|
+
|
|
112
|
+
| 阶段 | 目标 | 退出条件 | 人日 |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| P0 | 证伪浏览器流/TCC/VK 三假设 | 首帧延迟、采集耗时、权限拒绝行为、VK 单测 | 3–5 |
|
|
115
|
+
| P1 | 最小可用:P2P JPEG + relay 信令 + UI 开关/标签/同意 | Windows 看 Mac 1fps;错误码齐全;relay 无媒体明文 | 8–12 |
|
|
116
|
+
| P2 | 隐私/生命周期硬化 | 30min soak、锁屏暂停、限流、默认拒绝 | 5–8 |
|
|
117
|
+
| P3 | 密文中继回退 + TLS + 384KiB 帧上限 | 阻断 UDP 后仍 1fps;relay 无 JPEG magic | 8–12 |
|
|
118
|
+
| P4 | 可选 sidecar(非 RustDesk)10–15fps + 窗口选择 | sidecar 缺失时 P1 仍可用 | 10–15 |
|
|
119
|
+
|
|
120
|
+
## 7. 验收(节选)
|
|
121
|
+
|
|
122
|
+
- 开关关闭:无标签、`start` 返回 VIEW_DISABLED、无 UDP bind/relay stream。
|
|
123
|
+
- 开关开启:iMac 标签出现;roster 只列 `caps.view` 的 macOS 机器。
|
|
124
|
+
- 同意:拒绝/超时/忙/离线均有具名 code;默认拒绝。
|
|
125
|
+
- 性能:LAN 首帧 ≤2.5s;1fps 持续 10 分钟;停止 ≤1s;中继 ≥0.8fps。
|
|
126
|
+
- 安全:relay 请求体无 `FFD8FF`/`image/jpeg`;发真 JPEG 到 frame 端点返回 400;输入类型返回 VIEW_INPUT_FORBIDDEN。
|
|
127
|
+
- 零依赖:`dependencies` 不变;client bundle 测试通过;旧 relay/旧 agent 有明确降级。
|
|
128
|
+
|
|
129
|
+
## 8. 决策与待确认
|
|
130
|
+
|
|
131
|
+
已确认:
|
|
132
|
+
|
|
133
|
+
1. 接受 1–2 fps 查看;
|
|
134
|
+
2. relay 有 TLS;
|
|
135
|
+
3. macOS 目标端也跑 DSH GUI(同意 UI 放目标端 GUI);
|
|
136
|
+
4. 不接受仅 H.264 → 媒体地板改为 JPEG,HEVC/AV1 作为可选加速档;
|
|
137
|
+
5. 允许局域网 HTTP → C 拓扑只走 JPEG,不要求 WebCodecs;
|
|
138
|
+
6. **relay 密文帧路径进 P1.5**;P1 为 P2P 媒体 + relay 信令,P2P 失败明确 `P2P_UNAVAILABLE`;
|
|
139
|
+
7. **P1 不包含 2fps 档**:只有 1fps 的对外承诺;2fps 在 S2 实测达标后仅作为隐藏实验开关,不进 UI/验收/文档承诺。
|
|
140
|
+
8. **允许 2fps 依赖外部 ffmpeg**(面向 P1.5):可选、不打包、不自动下载、缺失回落;`dependencies` 保持为空。
|
|
141
|
+
|
|
142
|
+
P1 基线(无 ffmpeg):`screencapture` 1fps + JPEG;有 ffmpeg:2fps 档可选。
|
|
143
|
+
|
|
144
|
+
## 9. 2fps 决策(2026-10-30)
|
|
145
|
+
|
|
146
|
+
结论:P1 不设 2fps 交付档位。
|
|
147
|
+
|
|
148
|
+
- 唯一保证的采集后端 screencapture 单帧 p50 215–360ms / p95 590ms,2fps 的 500ms 预算越界;
|
|
149
|
+
- 2fps 的收益仅约 250–450ms 新鲜度,1fps 与 2fps 同属幻灯片(8–15fps 才是感知门槛);
|
|
150
|
+
- 1% 丢包下 57 分片帧受损概率 43%,一次 RTO 300ms 吃掉 2fps 预算 60%;
|
|
151
|
+
- P1 的坑应在同意/TCC/撤销,不在帧率;2fps 留 P1.5 视频档或 S2 后隐藏开关。
|
|
152
|
+
|
|
153
|
+
替代方案(P1 推荐):1fps + 变化追帧 burst(检测到变化时最多 2.5fps 持续 0.75s,令牌桶)+ 首帧双发 + 帧龄 HUD + unchanged 抑制读数。
|
package/lib/client.js
CHANGED
|
@@ -2115,6 +2115,153 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
2115
2115
|
]);
|
|
2116
2116
|
}
|
|
2117
2117
|
|
|
2118
|
+
/* ------------------------------------------------------------------ */
|
|
2119
|
+
/* v2.1.0 remote-view skeleton (view-only; media not wired yet) */
|
|
2120
|
+
/* ------------------------------------------------------------------ */
|
|
2121
|
+
|
|
2122
|
+
const REMOTE_VIEW_KEY = 'w2m.remoteView.enabled';
|
|
2123
|
+
const REMOTE_CONFIG_URL = '/api/w2m/remote/config';
|
|
2124
|
+
const REMOTE_FRAME_URL = '/api/w2m/remote/frame';
|
|
2125
|
+
const readLocalFlag = () => {
|
|
2126
|
+
try { return window.localStorage.getItem(REMOTE_VIEW_KEY) === '1'; } catch { return false; }
|
|
2127
|
+
};
|
|
2128
|
+
const cacheFlag = (value) => { try { window.localStorage.setItem(REMOTE_VIEW_KEY, value ? '1' : '0'); } catch { /* denied */ } };
|
|
2129
|
+
const announceFlag = () => { try { window.dispatchEvent(new Event('w2m-remote-view-changed')); } catch { /* headless */ } };
|
|
2130
|
+
|
|
2131
|
+
function useRemoteViewFlag() {
|
|
2132
|
+
const [enabled, setEnabledState] = React.useState(readLocalFlag);
|
|
2133
|
+
const [ready, setReady] = React.useState(false);
|
|
2134
|
+
React.useEffect(() => {
|
|
2135
|
+
let alive = true;
|
|
2136
|
+
fetch(REMOTE_CONFIG_URL, { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
2137
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2138
|
+
.then((body) => {
|
|
2139
|
+
if (!alive) return;
|
|
2140
|
+
const value = body?.enabled === true;
|
|
2141
|
+
cacheFlag(value);
|
|
2142
|
+
setEnabledState(value);
|
|
2143
|
+
setReady(true);
|
|
2144
|
+
})
|
|
2145
|
+
.catch(() => { if (alive) setReady(true); });
|
|
2146
|
+
const sync = () => setEnabledState(readLocalFlag());
|
|
2147
|
+
window.addEventListener('w2m-remote-view-changed', sync);
|
|
2148
|
+
window.addEventListener('storage', sync);
|
|
2149
|
+
return () => {
|
|
2150
|
+
alive = false;
|
|
2151
|
+
window.removeEventListener('w2m-remote-view-changed', sync);
|
|
2152
|
+
window.removeEventListener('storage', sync);
|
|
2153
|
+
};
|
|
2154
|
+
}, []);
|
|
2155
|
+
const setEnabled = (value) => {
|
|
2156
|
+
const next = value === true;
|
|
2157
|
+
cacheFlag(next);
|
|
2158
|
+
setEnabledState(next);
|
|
2159
|
+
announceFlag();
|
|
2160
|
+
fetch(REMOTE_CONFIG_URL, {
|
|
2161
|
+
method: 'POST', credentials: 'same-origin',
|
|
2162
|
+
headers: { 'content-type': 'application/json' },
|
|
2163
|
+
body: JSON.stringify({ enabled: next }),
|
|
2164
|
+
}).catch(() => { /* local flag remains the fallback */ });
|
|
2165
|
+
};
|
|
2166
|
+
return [enabled, setEnabled, ready];
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function RemoteViewSettings() {
|
|
2170
|
+
const [enabled, setEnabled, ready] = useRemoteViewFlag();
|
|
2171
|
+
return h('div', { style: { display: 'grid', gap: '10px', padding: '4px 0' } }, [
|
|
2172
|
+
h('div', { key: 'title', style: { fontWeight: 600 } }, '远程桌面'),
|
|
2173
|
+
h('label', { key: 'toggle', style: { display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', opacity: ready ? 1 : 0.6 } }, [
|
|
2174
|
+
h('input', { key: 'box', type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }),
|
|
2175
|
+
h('span', { key: 'text' }, enabled ? '已开启' : '已关闭'),
|
|
2176
|
+
]),
|
|
2177
|
+
h('div', { key: 'hint', style: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 } },
|
|
2178
|
+
'仅查看,不发送鼠标/键盘输入。P1 目标为最高 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,relay 只承载信令。'),
|
|
2179
|
+
h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, 'P1 当前为本机回环查看;远端采集/P2P 在后续切片接入。'),
|
|
2180
|
+
]);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function RemoteViewSidebarIcon() {
|
|
2184
|
+
const [enabled, , ready] = useRemoteViewFlag();
|
|
2185
|
+
if (!ready || !enabled) return null;
|
|
2186
|
+
return h('span', { title: '远程桌面', style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } }, h('svg', {
|
|
2187
|
+
width: 20, height: 18, viewBox: '0 0 24 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
2188
|
+
}, [
|
|
2189
|
+
h('rect', { key: 'screen', x: 2.5, y: 2, width: 19, height: 12, rx: 1.5 }),
|
|
2190
|
+
h('path', { key: 'stand', d: 'M8.5 18h7M12 14.2V18' }),
|
|
2191
|
+
]));
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function RemoteViewMainPage() {
|
|
2195
|
+
const [enabled] = useRemoteViewFlag();
|
|
2196
|
+
const [machines, setMachines] = React.useState([]);
|
|
2197
|
+
const [target, setTarget] = React.useState('');
|
|
2198
|
+
const [session, setSession] = React.useState(null);
|
|
2199
|
+
const [live, setLive] = React.useState(true);
|
|
2200
|
+
const [frame, setFrame] = React.useState(null);
|
|
2201
|
+
const [stats, setStats] = React.useState(null);
|
|
2202
|
+
const [error, setError] = React.useState(null);
|
|
2203
|
+
React.useEffect(() => {
|
|
2204
|
+
if (!enabled) return undefined;
|
|
2205
|
+
let alive = true;
|
|
2206
|
+
fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
2207
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2208
|
+
.then((body) => { if (!alive) return; const list = Array.isArray(body?.machines) ? body.machines.filter((m) => m.online === true) : []; setMachines(list); if (list.length > 0) setTarget((current) => current || list[0].machine_id); })
|
|
2209
|
+
.catch(() => { /* roster unavailable */ });
|
|
2210
|
+
return () => { alive = false; };
|
|
2211
|
+
}, [enabled]);
|
|
2212
|
+
React.useEffect(() => {
|
|
2213
|
+
if (!enabled || !live) return undefined;
|
|
2214
|
+
let alive = true;
|
|
2215
|
+
const tick = async () => {
|
|
2216
|
+
try {
|
|
2217
|
+
const response = await fetch(REMOTE_FRAME_URL, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
2218
|
+
const body = await response.json().catch(() => null);
|
|
2219
|
+
if (!alive) return;
|
|
2220
|
+
if (body?.ok === true) { setFrame(body); setStats({ capture_ms: body.capture_ms, bytes: body.bytes, at: body.at, encrypted: body.encrypted }); setError(null); }
|
|
2221
|
+
else if (body?.code === 'VIEW_WAITING_FOR_FRAME') { setError(null); }
|
|
2222
|
+
else { setError(body?.code ?? ('HTTP ' + response.status)); }
|
|
2223
|
+
} catch (failure) { if (alive) setError(failure instanceof Error ? failure.message : String(failure)); }
|
|
2224
|
+
};
|
|
2225
|
+
tick();
|
|
2226
|
+
const timer = setInterval(tick, 1000);
|
|
2227
|
+
return () => { alive = false; clearInterval(timer); };
|
|
2228
|
+
}, [enabled, live, session]);
|
|
2229
|
+
const connect = async () => {
|
|
2230
|
+
setError(null);
|
|
2231
|
+
try {
|
|
2232
|
+
const response = await fetch('/api/w2m/remote/start', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ machine_id: target }) });
|
|
2233
|
+
const body = await response.json().catch(() => null);
|
|
2234
|
+
if (!response.ok || body?.ok !== true) { setError(body?.code ?? body?.error ?? ('HTTP ' + response.status)); return; }
|
|
2235
|
+
setFrame(null); setSession(body);
|
|
2236
|
+
} catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
|
|
2237
|
+
};
|
|
2238
|
+
const disconnect = async () => {
|
|
2239
|
+
try { await fetch('/api/w2m/remote/stop', { method: 'POST', credentials: 'same-origin' }); } catch { /* best effort */ }
|
|
2240
|
+
setSession(null); setFrame(null); setStats(null);
|
|
2241
|
+
};
|
|
2242
|
+
const ready = enabled && frame?.data_base64;
|
|
2243
|
+
return h('div', { style: { padding: '16px', display: 'grid', gap: '12px', maxWidth: '1100px' } }, [
|
|
2244
|
+
h('div', { key: 'title', style: { fontSize: '16px', fontWeight: 600 } }, '远程桌面 · 仅查看'),
|
|
2245
|
+
h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('P2P 会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
|
|
2246
|
+
h('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
|
|
2247
|
+
h('select', { key: 'target', value: target, disabled: !enabled, onChange: (event) => setTarget(event.target.value), style: { minWidth: '180px' } }, machines.length === 0 ? h('option', { value: '' }, '无在线机器') : machines.map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, (machine.name ?? machine.machine_id)))),
|
|
2248
|
+
h('button', { key: 'connect', type: 'button', disabled: !enabled || target === '' || session !== null, onClick: connect }, '连接'),
|
|
2249
|
+
h('button', { key: 'stop', type: 'button', disabled: session === null, onClick: disconnect }, '停止'),
|
|
2250
|
+
h('button', { key: 'pause', type: 'button', disabled: !enabled, onClick: () => setLive((value) => !value) }, live ? '暂停' : '继续'),
|
|
2251
|
+
]),
|
|
2252
|
+
h('div', { key: 'canvas', style: { minHeight: '320px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-sm)', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--dsw-alias-bg-layer-1)' } }, ready
|
|
2253
|
+
? h('img', { src: 'data:' + (frame.mime ?? 'image/jpeg') + ';base64,' + frame.data_base64, alt: '远程桌面画面', style: { maxWidth: '100%', maxHeight: '70vh', display: 'block' } })
|
|
2254
|
+
: h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '本机回环预览(选择目标后点连接)')))),
|
|
2255
|
+
h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
|
|
2256
|
+
h('span', { key: 'fps' }, '1 fps 上限'),
|
|
2257
|
+
h('span', { key: 'channel' }, '通道:' + (session ? 'P2P' : '本机回环')),
|
|
2258
|
+
h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
|
|
2259
|
+
h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
|
|
2260
|
+
h('span', { key: 'age' }, '最近帧:' + (stats?.at ?? '—')),
|
|
2261
|
+
]),
|
|
2262
|
+
]);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2118
2265
|
function FleetChat({ sessionId, viewMode }) {
|
|
2119
2266
|
// v1.9.18: chat emotes are local slash commands, never image bytes. The relay only sees text.
|
|
2120
2267
|
const CHAT_COMMANDS = ['/微笑', '/火箭', '/烟花'];
|
|
@@ -2802,6 +2949,39 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
2802
2949
|
error,
|
|
2803
2950
|
);
|
|
2804
2951
|
}
|
|
2952
|
+
// v2.1.0: remote-view skeleton — settings toggle, conditional iMac sidebar entry, viewer page.
|
|
2953
|
+
try {
|
|
2954
|
+
slots.inject('settings.section', () => slots.register({
|
|
2955
|
+
name: 'settings.section',
|
|
2956
|
+
id: 'w2m-remote-view',
|
|
2957
|
+
order: 13,
|
|
2958
|
+
label: () => '远程桌面',
|
|
2959
|
+
inject: () => ({ t }),
|
|
2960
|
+
}, RemoteViewSettings));
|
|
2961
|
+
} catch (error) {
|
|
2962
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view settings section was not mounted', error);
|
|
2963
|
+
}
|
|
2964
|
+
try {
|
|
2965
|
+
slots.inject('sidebar.panellist', () => slots.register({
|
|
2966
|
+
name: 'sidebar.panellist',
|
|
2967
|
+
id: 'w2m-remote-view',
|
|
2968
|
+
order: 5,
|
|
2969
|
+
label: () => '',
|
|
2970
|
+
inject: () => ({}),
|
|
2971
|
+
}, RemoteViewSidebarIcon));
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view sidebar entry was not mounted', error);
|
|
2974
|
+
}
|
|
2975
|
+
try {
|
|
2976
|
+
slots.inject('main', () => slots.register({
|
|
2977
|
+
name: 'main',
|
|
2978
|
+
key: 'w2m-remote-view',
|
|
2979
|
+
label: () => '远程桌面',
|
|
2980
|
+
inject: () => ({ t }),
|
|
2981
|
+
}, RemoteViewMainPage));
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view main page was not mounted', error);
|
|
2984
|
+
}
|
|
2805
2985
|
}
|
|
2806
2986
|
|
|
2807
2987
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"pack": "node scripts/pack.mjs",
|
|
67
67
|
"reachability": "node scripts/reachability-check.mjs",
|
|
68
68
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
69
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs",
|
|
69
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs",
|
|
70
70
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
71
71
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
72
72
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.2",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
} from '../util/exec-capability.mjs';
|
|
71
71
|
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
|
|
72
72
|
import { createXferFrameRouter } from './transfer/router.mjs';
|
|
73
|
+
import { captureScreen } from './view/capture.mjs';
|
|
74
|
+
import { createViewTarget } from './view/frame-channel.mjs';
|
|
73
75
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
74
76
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
75
77
|
import { createSigner } from '../signing.mjs';
|
|
@@ -3966,6 +3968,15 @@ let attemptAttached = false;
|
|
|
3966
3968
|
xferRouter.handle(payload, { channel: event.channel });
|
|
3967
3969
|
return;
|
|
3968
3970
|
}
|
|
3971
|
+
if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('view.')) {
|
|
3972
|
+
const target = createViewTarget({
|
|
3973
|
+
capture: () => captureScreen({}),
|
|
3974
|
+
allow: process.env.W2M_VIEW_ALLOW === '1',
|
|
3975
|
+
send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
|
|
3976
|
+
});
|
|
3977
|
+
target.handle(payload);
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3969
3980
|
if (payload === null) {
|
|
3970
3981
|
log('warn', 'ignoring non-JSON frame on a P2P channel', {
|
|
3971
3982
|
peer: event.peer ?? null,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target-side screen capture for remote-view P1.
|
|
3
|
+
*
|
|
4
|
+
* One JPEG per call, bounded by a timeout, injectable `run` so tests never
|
|
5
|
+
* launch PowerShell or screencapture. Errors are named, never "capture failed"
|
|
6
|
+
* without a reason.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const VIEW_CAPTURE_CODE = Object.freeze({
|
|
14
|
+
OK: 'CAPTURE_OK',
|
|
15
|
+
UNSUPPORTED: 'CAPTURE_UNSUPPORTED_PLATFORM',
|
|
16
|
+
DENIED: 'CAPTURE_DENIED',
|
|
17
|
+
BLANK: 'CAPTURE_BLANK',
|
|
18
|
+
FAILED: 'CAPTURE_FAILED',
|
|
19
|
+
TOO_LARGE: 'CAPTURE_TOO_LARGE',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_MAX_FRAME_BYTES = 96 * 1024;
|
|
23
|
+
|
|
24
|
+
const defaultRun = (argv, options = {}) => spawnSync(argv[0], argv.slice(1), {
|
|
25
|
+
timeout: options.timeoutMs ?? 15000,
|
|
26
|
+
encoding: options.encoding ?? 'utf8',
|
|
27
|
+
env: options.env ?? process.env,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function windowsCaptureScript(maxBytes) {
|
|
31
|
+
return [
|
|
32
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
33
|
+
'$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
|
|
34
|
+
'$src=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
|
|
35
|
+
'$g=[System.Drawing.Graphics]::FromImage($src)',
|
|
36
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$src.Size)',
|
|
37
|
+
'$codec=[System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }',
|
|
38
|
+
'$max=' + String(maxBytes),
|
|
39
|
+
'$best=$null',
|
|
40
|
+
'foreach($w in @(1280,1024,800,640)){',
|
|
41
|
+
' $h=[int]($b.Height*$w/$b.Width)',
|
|
42
|
+
' $dst=New-Object System.Drawing.Bitmap $w,$h',
|
|
43
|
+
' $dg=[System.Drawing.Graphics]::FromImage($dst)',
|
|
44
|
+
' $dg.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
|
|
45
|
+
' $dg.DrawImage($src,0,0,$w,$h)',
|
|
46
|
+
' foreach($q in @(70,55,40)){',
|
|
47
|
+
' $p=New-Object System.Drawing.Imaging.EncoderParameters(1)',
|
|
48
|
+
' $p.Param[0]=New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality,[long]$q)',
|
|
49
|
+
' $ms=New-Object System.IO.MemoryStream',
|
|
50
|
+
' $dst.Save($ms,$codec,$p)',
|
|
51
|
+
' $bytes=$ms.ToArray()',
|
|
52
|
+
' if($best -eq $null -or $bytes.Length -lt $best.Length){$best=$bytes}',
|
|
53
|
+
' if($bytes.Length -le $max){[Convert]::ToBase64String($bytes); exit 0}',
|
|
54
|
+
' }',
|
|
55
|
+
'}',
|
|
56
|
+
'[Convert]::ToBase64String($best)',
|
|
57
|
+
].join(';');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number}} [options]
|
|
63
|
+
* @returns {{ok: true, code: string, mime: string, data: Buffer, base64: string, bytes: number, capture_ms: number, width: number|null, height: number|null}
|
|
64
|
+
* | {ok: false, code: string, error: string, capture_ms: number}}
|
|
65
|
+
*/
|
|
66
|
+
export function captureScreen(options = {}) {
|
|
67
|
+
const platform = options.platform ?? process.platform;
|
|
68
|
+
const run = options.run ?? defaultRun;
|
|
69
|
+
const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_FRAME_BYTES;
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
try {
|
|
72
|
+
let base64 = null;
|
|
73
|
+
let width = null;
|
|
74
|
+
let height = null;
|
|
75
|
+
if (platform === 'win32') {
|
|
76
|
+
const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
|
|
77
|
+
if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.code ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
|
|
78
|
+
base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
79
|
+
if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
|
|
80
|
+
} else if (platform === 'darwin') {
|
|
81
|
+
const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-'));
|
|
82
|
+
const file = join(dir, 'frame.jpg');
|
|
83
|
+
try {
|
|
84
|
+
const result = run(['/usr/sbin/screencapture', '-x', '-t', 'jpg', file], { timeoutMs: 15000 });
|
|
85
|
+
if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.code})`, capture_ms: Date.now() - started };
|
|
86
|
+
const data = readFileSync(file);
|
|
87
|
+
base64 = data.toString('base64');
|
|
88
|
+
const sips = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 5000 });
|
|
89
|
+
const w = /pixelWidth:\s*(\d+)/.exec(sips?.stdout ?? '');
|
|
90
|
+
const h = /pixelHeight:\s*(\d+)/.exec(sips?.stdout ?? '');
|
|
91
|
+
width = w === null ? null : Number(w[1]);
|
|
92
|
+
height = h === null ? null : Number(h[1]);
|
|
93
|
+
} finally {
|
|
94
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
return { ok: false, code: VIEW_CAPTURE_CODE.UNSUPPORTED, error: `screen capture is not implemented for ${platform}`, capture_ms: Date.now() - started };
|
|
98
|
+
}
|
|
99
|
+
const data = Buffer.from(base64, 'base64');
|
|
100
|
+
if (data.length < 1000) return { ok: false, code: VIEW_CAPTURE_CODE.BLANK, error: 'captured JPEG is too small to be a screen', capture_ms: Date.now() - started };
|
|
101
|
+
if (data.length > maxBytes) return { ok: false, code: VIEW_CAPTURE_CODE.TOO_LARGE, error: `frame ${data.length} > ${maxBytes}`, capture_ms: Date.now() - started };
|
|
102
|
+
return { ok: true, code: VIEW_CAPTURE_CODE.OK, mime: 'image/jpeg', data, base64, bytes: data.length, capture_ms: Date.now() - started, width, height };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: error instanceof Error ? error.message : String(error), capture_ms: Date.now() - started };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Test/util helper: file size without reading the whole image. */
|
|
109
|
+
export function frameBytes(file) {
|
|
110
|
+
try { return statSync(file).size; } catch { return 0; }
|
|
111
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P view frame channel (P1, view-only).
|
|
3
|
+
*
|
|
4
|
+
* Frames are JSON on the existing P2PChannel (one JPEG per frame, ≤96 KiB).
|
|
5
|
+
* The target defaults to DENY; consent is a separate decision supplied by the
|
|
6
|
+
* caller (`allow: true`). Nothing here accepts input events.
|
|
7
|
+
*/
|
|
8
|
+
export const VIEW_FRAME = Object.freeze({
|
|
9
|
+
REQUEST: 'view.request',
|
|
10
|
+
FRAME: 'view.frame',
|
|
11
|
+
STOP: 'view.stop',
|
|
12
|
+
ERROR: 'view.error',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const VIEW_CODE = Object.freeze({
|
|
16
|
+
CONSENT_REQUIRED: 'VIEW_CONSENT_REQUIRED',
|
|
17
|
+
DISABLED: 'VIEW_DISABLED',
|
|
18
|
+
FRAME_TOO_LARGE: 'VIEW_FRAME_TOO_LARGE',
|
|
19
|
+
BAD_FRAME: 'VIEW_BAD_FRAME',
|
|
20
|
+
CAPTURE_FAILED: 'VIEW_CAPTURE_FAILED',
|
|
21
|
+
INPUT_FORBIDDEN: 'VIEW_INPUT_FORBIDDEN',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
const isInputFrame = (type) => /^(mouse|key|pointer|input)\./.test(type);
|
|
26
|
+
|
|
27
|
+
export function parseViewFrame(payload) {
|
|
28
|
+
try {
|
|
29
|
+
const frame = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload;
|
|
30
|
+
if (!isObject(frame) || typeof frame.type !== 'string' || !frame.type.startsWith('view.')) return null;
|
|
31
|
+
return frame;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Target side: answer view.request with one JPEG frame; refuse by default.
|
|
37
|
+
*
|
|
38
|
+
* @param {{capture: Function, allow?: boolean, send: Function, maxBytes?: number, fps?: number}} deps
|
|
39
|
+
*/
|
|
40
|
+
export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
|
|
41
|
+
if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
|
|
42
|
+
let seq = 0;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
handle(payload) {
|
|
48
|
+
let raw = null;
|
|
49
|
+
try { raw = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; } catch { raw = null; }
|
|
50
|
+
if (isObject(raw) && typeof raw.type === 'string' && isInputFrame(raw.type)) {
|
|
51
|
+
reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.INPUT_FORBIDDEN, session_id: raw.session_id ?? null });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const frame = parseViewFrame(payload);
|
|
55
|
+
if (frame === null) return false;
|
|
56
|
+
if (frame.type === VIEW_FRAME.STOP) { stopped = true; return true; }
|
|
57
|
+
if (frame.type !== VIEW_FRAME.REQUEST) return true;
|
|
58
|
+
if (allow !== true) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.CONSENT_REQUIRED, session_id: frame.session_id ?? null }); return true; }
|
|
59
|
+
if (stopped) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.DISABLED, session_id: frame.session_id ?? null }); return true; }
|
|
60
|
+
const result = capture({ maxBytes });
|
|
61
|
+
if (result?.ok !== true) {
|
|
62
|
+
reply({ type: VIEW_FRAME.ERROR, code: result?.code ?? VIEW_CODE.CAPTURE_FAILED, session_id: frame.session_id ?? null });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
seq += 1;
|
|
66
|
+
reply({
|
|
67
|
+
type: VIEW_FRAME.FRAME,
|
|
68
|
+
session_id: frame.session_id ?? null,
|
|
69
|
+
seq,
|
|
70
|
+
ts_ms: Date.now(),
|
|
71
|
+
mime: result.mime ?? 'image/jpeg',
|
|
72
|
+
bytes: result.bytes,
|
|
73
|
+
data_b64: result.base64,
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes }; },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Viewer side: request a frame and accept/validate the response.
|
|
83
|
+
*
|
|
84
|
+
* @param {{send: Function, onFrame: Function, maxBytes?: number}} deps
|
|
85
|
+
*/
|
|
86
|
+
export function createViewClient({ send, onFrame, maxBytes = 96 * 1024 } = {}) {
|
|
87
|
+
if (typeof send !== 'function' || typeof onFrame !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
|
|
88
|
+
let sessionId = null;
|
|
89
|
+
let lastSeq = 0;
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
request({ machineId, session }) {
|
|
93
|
+
sessionId = session ?? ('view-' + Date.now());
|
|
94
|
+
send(JSON.stringify({ type: VIEW_FRAME.REQUEST, session_id: sessionId, machine_id: machineId ?? null, fps: 1, max_bytes: maxBytes }));
|
|
95
|
+
return sessionId;
|
|
96
|
+
},
|
|
97
|
+
accept(payload) {
|
|
98
|
+
const frame = parseViewFrame(payload);
|
|
99
|
+
if (frame === null) return false;
|
|
100
|
+
if (frame.type === VIEW_FRAME.ERROR) { onFrame({ ok: false, code: frame.code ?? VIEW_CODE.CAPTURE_FAILED }); return true; }
|
|
101
|
+
if (frame.type !== VIEW_FRAME.FRAME) return true;
|
|
102
|
+
if (sessionId !== null && frame.session_id !== sessionId) return true;
|
|
103
|
+
if (!Number.isInteger(frame.seq) || frame.seq <= lastSeq) return true;
|
|
104
|
+
if (typeof frame.data_b64 !== 'string' || frame.data_b64 === '') { onFrame({ ok: false, code: VIEW_CODE.BAD_FRAME }); return true; }
|
|
105
|
+
const bytes = Buffer.from(frame.data_b64, 'base64').length;
|
|
106
|
+
if (bytes > maxBytes) { onFrame({ ok: false, code: VIEW_CODE.FRAME_TOO_LARGE }); return true; }
|
|
107
|
+
lastSeq = frame.seq;
|
|
108
|
+
onFrame({ ok: true, seq: frame.seq, ts_ms: frame.ts_ms, mime: frame.mime ?? 'image/jpeg', bytes, data_b64: frame.data_b64 });
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
stop() { if (sessionId !== null) send(JSON.stringify({ type: VIEW_FRAME.STOP, session_id: sessionId })); },
|
|
112
|
+
stats() { return { sessionId, lastSeq }; },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.1.x remote-view host routes.
|
|
3
|
+
*
|
|
4
|
+
* local : /frame captures this host (P1 fallback, no target selected)
|
|
5
|
+
* remote : /start dials the target over P2P and asks its agent for view frames;
|
|
6
|
+
* /frame serves the newest cached frame; /stop ends the session.
|
|
7
|
+
*
|
|
8
|
+
* Remote frames never traverse the relay. A P2P failure is P2P_UNAVAILABLE,
|
|
9
|
+
* never satisfied by a relay/plaintext media fallback.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { captureHostScreen } from './capture-route.mjs';
|
|
14
|
+
|
|
15
|
+
export const REMOTE_CONFIG_PATH = '/api/w2m/remote/config';
|
|
16
|
+
export const REMOTE_FRAME_PATH = '/api/w2m/remote/frame';
|
|
17
|
+
export const REMOTE_START_PATH = '/api/w2m/remote/start';
|
|
18
|
+
export const REMOTE_STOP_PATH = '/api/w2m/remote/stop';
|
|
19
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
20
|
+
|
|
21
|
+
function readConfig(file) {
|
|
22
|
+
try { return { ok: true, enabled: JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))?.enabled === true }; }
|
|
23
|
+
catch { return { ok: true, enabled: false }; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeConfig(file, enabled) {
|
|
27
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
28
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
29
|
+
writeFileSync(tmp, JSON.stringify({ enabled: enabled === true, updatedAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
|
|
30
|
+
renameSync(tmp, file);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function payloadJson(payload) {
|
|
34
|
+
try { return Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; }
|
|
35
|
+
catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} ctx Cordis context
|
|
40
|
+
* @param {{stateDir:string, p2p?:object, capture?:Function, platform?:NodeJS.Platform, maxBytes?:number}} deps
|
|
41
|
+
*/
|
|
42
|
+
export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
43
|
+
const status = { mounted: false, reason: 'the host has no Connection service', configPath: null };
|
|
44
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
45
|
+
if (typeof deps.stateDir !== 'string' || deps.stateDir === '') { status.reason = 'stateDir is not configured'; return status; }
|
|
46
|
+
const file = join(deps.stateDir, 'remote-view.json');
|
|
47
|
+
const capture = deps.capture ?? captureHostScreen;
|
|
48
|
+
const platform = deps.platform ?? process.platform;
|
|
49
|
+
const p2p = deps.p2p ?? null;
|
|
50
|
+
const maxBytes = Number.isInteger(deps.maxBytes) ? deps.maxBytes : 96 * 1024;
|
|
51
|
+
let active = null;
|
|
52
|
+
let listener = null;
|
|
53
|
+
|
|
54
|
+
const stopActive = async () => {
|
|
55
|
+
if (active === null) return;
|
|
56
|
+
try { await active.channel?.send?.(JSON.stringify({ type: 'view.stop', session_id: active.sessionId })); } catch { /* peer gone */ }
|
|
57
|
+
try { active.channel?.close?.(); } catch { /* already closed */ }
|
|
58
|
+
if (p2p?.node && typeof p2p.node.off === 'function' && listener !== null) p2p.node.off('message', listener);
|
|
59
|
+
active = null;
|
|
60
|
+
listener = null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
64
|
+
const registry = webCtx?.connection?.fetch;
|
|
65
|
+
if (registry === undefined || typeof registry.register !== 'function') { status.reason = 'the connection service carries no fetch registry'; return; }
|
|
66
|
+
registry.register({
|
|
67
|
+
path: REMOTE_CONFIG_PATH,
|
|
68
|
+
methods: ['GET', 'POST'],
|
|
69
|
+
requestBody: 'buffered',
|
|
70
|
+
fetch: async (request) => {
|
|
71
|
+
if (request.method === 'GET') return Response.json({ ok: true, ...readConfig(file), session: active === null ? null : { machine_id: active.machineId, encrypted: active.encrypted === true } }, { headers: NO_STORE });
|
|
72
|
+
let body = null;
|
|
73
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
74
|
+
if (body === null || typeof body.enabled !== 'boolean') return Response.json({ ok: false, error: 'a JSON body with {"enabled": true|false} is required' }, { status: 400, headers: NO_STORE });
|
|
75
|
+
if (body.enabled === false) await stopActive();
|
|
76
|
+
writeConfig(file, body.enabled);
|
|
77
|
+
return Response.json({ ok: true, enabled: body.enabled }, { headers: NO_STORE });
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
registry.register({
|
|
82
|
+
path: REMOTE_START_PATH,
|
|
83
|
+
methods: ['POST'],
|
|
84
|
+
requestBody: 'buffered',
|
|
85
|
+
fetch: async (request) => {
|
|
86
|
+
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
87
|
+
let body = null;
|
|
88
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
89
|
+
const machineId = typeof body?.machine_id === 'string' ? body.machine_id : '';
|
|
90
|
+
if (machineId === '') return Response.json({ ok: false, error: 'a JSON body with {"machine_id": "..."} is required' }, { status: 400, headers: NO_STORE });
|
|
91
|
+
if (p2p?.node === null || p2p?.node === undefined) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: 'no P2P node on this host' }, { status: 503, headers: NO_STORE });
|
|
92
|
+
await stopActive();
|
|
93
|
+
try { await p2p.node.start?.(); } catch (error) {
|
|
94
|
+
return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: error instanceof Error ? error.message : String(error) }, { status: 503, headers: NO_STORE });
|
|
95
|
+
}
|
|
96
|
+
const dialled = await p2p.node.dial(machineId, {});
|
|
97
|
+
if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
|
|
98
|
+
const sessionId = 'view-' + Date.now().toString(36);
|
|
99
|
+
active = { machineId, sessionId, channel: dialled.channel, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
|
|
100
|
+
listener = (event) => {
|
|
101
|
+
const frame = payloadJson(event?.payload);
|
|
102
|
+
if (frame === null || frame?.session_id !== sessionId) return;
|
|
103
|
+
if (frame.type === 'view.frame') {
|
|
104
|
+
const bytes = typeof frame.data_b64 === 'string' ? Buffer.from(frame.data_b64, 'base64').length : 0;
|
|
105
|
+
if (bytes > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
|
|
106
|
+
active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes, data_base64: frame.data_b64, at: new Date().toISOString(), encrypted: active.encrypted };
|
|
107
|
+
} else if (frame.type === 'view.error') {
|
|
108
|
+
active.error = frame.code ?? 'VIEW_ERROR';
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
p2p.node.on('message', listener);
|
|
112
|
+
await dialled.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
|
|
113
|
+
return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
registry.register({
|
|
118
|
+
path: REMOTE_STOP_PATH,
|
|
119
|
+
methods: ['POST'],
|
|
120
|
+
fetch: async () => { await stopActive(); return Response.json({ ok: true }, { headers: NO_STORE }); },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
registry.register({
|
|
124
|
+
path: REMOTE_FRAME_PATH,
|
|
125
|
+
methods: ['POST'],
|
|
126
|
+
fetch: async () => {
|
|
127
|
+
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
128
|
+
if (active !== null) {
|
|
129
|
+
if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
|
|
130
|
+
if (active.error !== null) return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE });
|
|
131
|
+
return Response.json({ ok: false, code: 'VIEW_WAITING_FOR_FRAME', machine_id: active.machineId }, { status: 202, headers: NO_STORE });
|
|
132
|
+
}
|
|
133
|
+
const started = Date.now();
|
|
134
|
+
const result = await capture({ platform });
|
|
135
|
+
if (result.ok !== true) return Response.json({ ok: false, code: 'CAPTURE_FAILED', error: result.error ?? null }, { status: 502, headers: NO_STORE });
|
|
136
|
+
return Response.json({ ok: true, mime: result.mime ?? 'image/jpeg', data_base64: result.data_base64, bytes: Math.ceil((result.data_base64?.length ?? 0) * 3 / 4), capture_ms: Date.now() - started, at: new Date().toISOString() }, { headers: NO_STORE });
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
status.mounted = true;
|
|
140
|
+
status.reason = null;
|
|
141
|
+
status.configPath = file;
|
|
142
|
+
});
|
|
143
|
+
return status;
|
|
144
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -198,7 +198,8 @@ import { registerProjectStreamRoute } from './project-stream-route.mjs';
|
|
|
198
198
|
import { planProgress } from './joint-dev/progress.mjs';
|
|
199
199
|
import { registerJointTools } from './tools-joint.mjs';
|
|
200
200
|
import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
|
|
201
|
-
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
201
|
+
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
202
|
+
import { registerRemoteViewRoutes } from './remote-view-route.mjs';
|
|
202
203
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
203
204
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
204
205
|
import { renderMemoryView } from './memory-view.mjs';
|
|
@@ -291,7 +292,7 @@ const MAX_STAGES = 16;
|
|
|
291
292
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
292
293
|
* overrides it at runtime for tests and packagers.
|
|
293
294
|
*/
|
|
294
|
-
const PLUGIN_VERSION = '2.
|
|
295
|
+
const PLUGIN_VERSION = '2.1.2';
|
|
295
296
|
|
|
296
297
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
297
298
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3609,7 +3610,11 @@ export async function apply(ctx, config = {}) {
|
|
|
3609
3610
|
// v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
|
|
3610
3611
|
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
3611
3612
|
// depend on those permissions.
|
|
3612
|
-
registerCaptureRoutes(ctx);
|
|
3613
|
+
registerCaptureRoutes(ctx);
|
|
3614
|
+
const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
|
|
3615
|
+
if (remoteViewRoutes.mounted !== true) {
|
|
3616
|
+
ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
|
|
3617
|
+
}
|
|
3613
3618
|
if (capabilityRoutes.mounted !== true) {
|
|
3614
3619
|
ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
|
|
3615
3620
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1.5 encrypted relay media broker (in-memory only).
|
|
3
|
+
*
|
|
4
|
+
* The relay never sees a JPEG or a session key:
|
|
5
|
+
* - payloads are opaque ciphertext, size-capped to fit the relay frame budget;
|
|
6
|
+
* - one newest frame per session (latest-frame-wins), no history;
|
|
7
|
+
* - every entry expires; nothing is written to disk or logged.
|
|
8
|
+
*
|
|
9
|
+
* HTTP/agent wiring is a separate slice; this module is the safety contract.
|
|
10
|
+
*/
|
|
11
|
+
export const REMOTE_MEDIA_CODE = Object.freeze({
|
|
12
|
+
OK: 'REMOTE_MEDIA_OK',
|
|
13
|
+
BAD_SESSION: 'REMOTE_MEDIA_BAD_SESSION',
|
|
14
|
+
BAD_PAYLOAD: 'REMOTE_MEDIA_BAD_PAYLOAD',
|
|
15
|
+
TOO_LARGE: 'REMOTE_MEDIA_TOO_LARGE',
|
|
16
|
+
NOT_FOUND: 'REMOTE_MEDIA_NOT_FOUND',
|
|
17
|
+
EXPIRED: 'REMOTE_MEDIA_EXPIRED',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_REMOTE_MEDIA_MAX_BYTES = 47 * 1024;
|
|
21
|
+
export const DEFAULT_REMOTE_MEDIA_TTL_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{maxBytes?: number, ttlMs?: number, now?: () => number}} [options]
|
|
27
|
+
*/
|
|
28
|
+
export function createRemoteMediaBroker(options = {}) {
|
|
29
|
+
const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_REMOTE_MEDIA_MAX_BYTES;
|
|
30
|
+
const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : DEFAULT_REMOTE_MEDIA_TTL_MS;
|
|
31
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
32
|
+
const sessions = new Map();
|
|
33
|
+
|
|
34
|
+
const prune = () => {
|
|
35
|
+
const at = now();
|
|
36
|
+
for (const [id, entry] of sessions) if (entry.expiresAt <= at) sessions.delete(id);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
maxBytes,
|
|
41
|
+
ttlMs,
|
|
42
|
+
/** Open or refresh a session addressed to one target machine. */
|
|
43
|
+
open(sessionId, targetMachineId) {
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof targetMachineId !== 'string' || targetMachineId === '') {
|
|
45
|
+
return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
46
|
+
}
|
|
47
|
+
sessions.set(sessionId, { target: targetMachineId, frame: null, expiresAt: now() + ttlMs });
|
|
48
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, session_id: sessionId, target: targetMachineId };
|
|
49
|
+
},
|
|
50
|
+
/** Store one opaque ciphertext frame (latest wins). */
|
|
51
|
+
put(sessionId, targetMachineId, ciphertext) {
|
|
52
|
+
prune();
|
|
53
|
+
const entry = sessions.get(sessionId);
|
|
54
|
+
if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
|
|
55
|
+
if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
56
|
+
if (!(ciphertext instanceof Uint8Array) || ciphertext.byteLength === 0) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
57
|
+
if (ciphertext.byteLength > maxBytes) return { ok: false, code: REMOTE_MEDIA_CODE.TOO_LARGE };
|
|
58
|
+
entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, at: now() };
|
|
59
|
+
entry.expiresAt = now() + ttlMs;
|
|
60
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, bytes: ciphertext.byteLength };
|
|
61
|
+
},
|
|
62
|
+
/** Read and consume the newest frame, or report why not. */
|
|
63
|
+
take(sessionId, targetMachineId) {
|
|
64
|
+
prune();
|
|
65
|
+
const entry = sessions.get(sessionId);
|
|
66
|
+
if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.EXPIRED };
|
|
67
|
+
if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
68
|
+
if (entry.frame === null) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
|
|
69
|
+
const frame = entry.frame;
|
|
70
|
+
entry.frame = null;
|
|
71
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, ...frame };
|
|
72
|
+
},
|
|
73
|
+
close(sessionId) { sessions.delete(sessionId); },
|
|
74
|
+
stats() { prune(); return { sessions: sessions.size, maxBytes, ttlMs }; },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Validate the JSON body of a frame write without decoding the ciphertext. */
|
|
79
|
+
export function parseRemoteFrame(body) {
|
|
80
|
+
if (!isObject(body)) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
81
|
+
if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
82
|
+
if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
83
|
+
if (typeof body.ct !== 'string' || body.ct === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
84
|
+
let ciphertext;
|
|
85
|
+
try { ciphertext = Buffer.from(body.ct, 'base64'); } catch { return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD }; }
|
|
86
|
+
return { ok: true, session_id: body.session_id, target: body.target, ciphertext };
|
|
87
|
+
}
|